diff --git a/contracts/finance/Move.lock b/contracts/finance/Move.lock index 1bdb91d6..2226f80c 100644 --- a/contracts/finance/Move.lock +++ b/contracts/finance/Move.lock @@ -4,6 +4,30 @@ [move] version = 4 +[pinned.mainnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } +use_environment = "mainnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.mainnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } +use_environment = "mainnet" +manifest_digest = "CD547CB1ACCE0880C835DAED2D8FFCB91D56C833AE5240D3AA5B918398263195" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.mainnet.openzeppelin_finance] +source = { root = true } +use_environment = "mainnet" +manifest_digest = "7C0AA1FA8A2A8D05C350DF6C1B74B7E5015BC8CD5A2FE59EAD29F5FE7D910B36" +deps = { openzeppelin_math = "openzeppelin_math", std = "MoveStdlib", sui = "Sui" } + +[pinned.mainnet.openzeppelin_math] +source = { local = "../../math/core" } +use_environment = "mainnet" +manifest_digest = "E41BBD67BE8940D26C79D78B028477EF5B33BA217A1282C78ACB344CF8A5ECF6" +deps = { std = "MoveStdlib", sui = "Sui" } + [pinned.testnet.MoveStdlib] source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } use_environment = "testnet" diff --git a/contracts/finance/README.md b/contracts/finance/README.md index b1cd2259..06dd7106 100644 --- a/contracts/finance/README.md +++ b/contracts/finance/README.md @@ -52,23 +52,23 @@ is not required up front. choose a topology in the same PTB. `create_and_share`/`create_and_share_continuous` are sugar that builds the wallet, shares it, and returns its `ID` and `DestroyCap` in one call. -2. **Fund** - `deposit` a `Coin`. Permissionless: anyone may fund, and funds added - after the schedule starts participate retroactively. +2. **Fund** - `deposit` a `Balance`, claim an addressed `Coin` with + `receive_and_deposit`, or pull settled address-balance funds in with `sweep_settled`. + Permissionless: anyone may fund, and funds added after the schedule starts + participate retroactively. 3. **Release** - `release` evaluates the curve at the current `Clock` and pays the - not-yet-released portion to the beneficiary. Permissionless and idempotent: if - nothing new has vested it is a no-op. Each call pays the newly vested amount as a - fresh `Coin`, so a third party (not only the beneficiary) can call it repeatedly - as the schedule progresses and split the payout into many small coins - bounded - (one coin per distinct clock value over the window), costly to force (gas per tx), - and with totals always preserved. + not-yet-released portion into the beneficiary's address balance. Permissionless + and idempotent: if nothing new has vested it is a no-op. No payout `Coin` object + is minted. 4. **Inspect** - `releasable` returns what `release` would pay right now; `start_ms`, `period_ms`, `steps`, `duration_ms`, `end_ms`, and `cliff_ms` read the schedule. -5. **Tear down** - once drained, `vesting_wallet::destroy_empty` reclaims the storage - rebate and returns a `DestroyReceipt`; hand that, together with the wallet's - `DestroyCap`, to `vesting_wallet_linear::destroy`, which requires the schedule to have - ended before accepting the teardown. Authority is the cap, not the caller's address, - so a wallet whose `beneficiary` is an object (never a transaction sender) can still be - torn down by whoever holds its cap. +5. **Tear down** - once drained and any settled funds have been swept, + `vesting_wallet::destroy_empty` reclaims the storage rebate and returns a + `DestroyReceipt`; hand that, together with the wallet's `DestroyCap`, to + `vesting_wallet_linear::destroy`, which requires the schedule to have ended before + accepting the teardown. Authority is the cap, not the caller's address, so a wallet + whose `beneficiary` is an object (never a transaction sender) can still be torn down + by whoever holds its cap. ### Usage @@ -94,7 +94,7 @@ public fun grant(beneficiary: address, start_ms: u64, funds: Coin, ctx: &m 4, // steps ctx, ); - wallet.deposit(funds); + wallet.deposit(funds.into_balance()); transfer::public_share_object(wallet); transfer::public_transfer(cap, beneficiary); } @@ -112,8 +112,8 @@ public fun stream(beneficiary: address, start_ms: u64, ctx: &mut TxContext) { } // Anyone can release; the beneficiary is read fresh from the wallet at call time. -public fun claim(wallet: &mut VestingWallet, clock: &Clock, ctx: &mut TxContext) { - vesting_wallet_linear::release(wallet, clock, ctx); +public fun claim(wallet: &mut VestingWallet, clock: &Clock) { + vesting_wallet_linear::release(wallet, clock); } // A read-only "what can I claim?" query for clients. @@ -130,21 +130,19 @@ and you pick the topology: - **Shared** (recommended) - `transfer::public_share_object(wallet)`, or use `create_and_share`/`create_and_share_continuous`. Anyone can poke `release`, and the beneficiary always receives the funds regardless of who triggered it. - Because `release` is permissionless and pays each newly vested tranche as a fresh - `Coin`, a third party can call it repeatedly as the schedule progresses and split - the payout into many small coins - bounded (one coin per distinct clock value over - the window) and costly to force, with totals always preserved. An integrator pointing - a wallet at an object beneficiary should plan for possibly many `Receiving`s to - process rather than a few large payouts. + `release` pays into the beneficiary's address balance with `balance::send_funds`, + so no payout `Coin` object is minted. - **Owned** (fast path) - `transfer::public_transfer(wallet, holder)`. Only the holder can pass the wallet by `&mut`, so release is reachable from the holder's transactions only. Outside parties fund an owned wallet by `public_transfer`-ing a `Coin` to the wallet's object address; the holder then claims each with - `receive_and_deposit`. Liveness risk: `release`, `deposit`, `receive_and_deposit`, - and `destroy_empty` all require `&mut` or by-value access only the holder can - produce, so a holder who is not the beneficiary and turns uncooperative can withhold - every payout with no on-chain path for the beneficiary to force one. The recommended - Shared topology avoids this because its `release` is permissionless. + `receive_and_deposit`. They can also settle a `Balance` into the address for the + holder to pull in with `sweep_settled`. Liveness risk: `release`, `deposit`, + `receive_and_deposit`, `sweep_settled`, and `destroy_empty` all require `&mut` or + by-value access only the holder can produce, so a holder who is not the beneficiary + and turns uncooperative can withhold every payout with no on-chain path for the + beneficiary to force one. The recommended Shared topology avoids this because its + `release` is permissionless. The `beneficiary` is fixed at construction. To rotate the recipient, point `beneficiary` at a consumer-owned object and rotate ownership of that object instead. @@ -207,10 +205,9 @@ public fun inner( public fun release( self: &mut GatedVault, vested: &VestedAmount, - ctx: &mut TxContext, ) { // ... enforce protocol invariants (not paused, caller approved, ...) ... - self.inner.release(vested, ctx); + self.inner.release(vested); } /// Re-expose `receive_and_deposit` so address-targeted funding can still be claimed @@ -228,7 +225,7 @@ The caller picks the curve module at the call site; the vault never knows which ```move let v = vesting_wallet_linear::vested_amount(vault.inner(), clock); -vault.release(&v, ctx); +vault.release(&v); ``` If `release` instead required the witness `S`, this would be impossible: a wrapper @@ -265,14 +262,14 @@ To author a new curve, follow the `vesting_wallet_linear` pattern: through `new`. 3. A `vested_amount(&VestingWallet, &Clock): VestedAmount` that evaluates the curve and ends in `wallet.mint_vested_amount(MyCurve {}, amount)`. -4. A teardown that calls `wallet.destroy_empty()` for a `DestroyReceipt`, then `vesting_wallet::consume_receipt(receipt, cap, MyCurve {})` - passing - the wallet's `DestroyCap` - to recover the schedule parameters and destructure - them. `destroy_empty` is permissionless; `consume_receipt` is gated on both the - witness (so the curve can run teardown logic or veto) and the cap (the teardown - authority). **Gate teardown on the cap, never on `ctx.sender() == beneficiary`:** an - object beneficiary is never a transaction sender, so that check could never be - satisfied and would brick teardown for object-beneficiary wallets. + the wallet's `DestroyCap` - to recover the schedule parameters and destructure them. + `destroy_empty` is permissionless; `consume_receipt` is gated on both the witness + (so the curve can run teardown logic or veto) and the cap (the teardown authority). + **Gate teardown on the cap, never on `ctx.sender() == beneficiary`:** an object + beneficiary is never a transaction sender, so that check could never be satisfied + and would brick teardown for object-beneficiary wallets. The curve **must be monotonically non-decreasing in time and bounded above by `balance + released`.** `release` enforces only the failure modes that threaten funds: @@ -315,8 +312,9 @@ one per integration boundary described above: teardown. Works with any present or future curve. - [`splitter`](examples/vesting_wallet/splitter.move) - the **beneficiary-as-object** pattern: point a wallet's `beneficiary` at a shared `Beneficiary` object so each - release lands as a `Receiving>` that anyone can `disperse` to many receivers - by fixed weights. Composes with any curve and topology. + release settles into the object's accumulator, which anyone can `disperse` to many + receivers by fixed weights (crediting each via `balance::send_funds`). Composes with + any curve and topology. ## Security Notes diff --git a/contracts/finance/examples/vesting_wallet/pausable_grant.move b/contracts/finance/examples/vesting_wallet/pausable_grant.move index 29126aef..67a9f821 100644 --- a/contracts/finance/examples/vesting_wallet/pausable_grant.move +++ b/contracts/finance/examples/vesting_wallet/pausable_grant.move @@ -14,7 +14,7 @@ /// /// ```move /// let v = vesting_wallet_linear::vested_amount(grant.inner(), clock); -/// grant.release(&v, ctx); +/// grant.release(&v); /// ``` /// /// # What the curve-agnostic core can and cannot do @@ -26,7 +26,9 @@ /// does not refund. /// /// Teardown follows the same split. `vesting_wallet::destroy_empty` is permissionless, -/// but it requires a drained wallet and only hands back a `DestroyReceipt` that the +/// but it requires a fully emptied wallet - no held balance *and* no unswept settled +/// funds at its address (`sweep_settled` first) - and only hands back a `DestroyReceipt` +/// that the /// curve module must consume with its witness `S`. A curve-agnostic wrapper has no `S`, /// so it cannot finalize teardown itself. What it *can* do is dissolve the wrapper: /// `unwrap` consumes the grant and its admin cap and returns the bare nested wallet, so @@ -113,10 +115,9 @@ public fun inner( public fun release( self: &mut PausableGrant, vested: &VestedAmount, - ctx: &mut TxContext, ) { assert!(!self.paused, EPaused); - self.inner.release(vested, ctx); + self.inner.release(vested); } /// Freeze releases. Idempotent. @@ -147,7 +148,7 @@ public fun resume( /// wallet to the caller. This is the curve-agnostic half of teardown - the wrapper /// holds no witness `S`, so it cannot consume a `DestroyReceipt` itself; it stops at /// handing back the bare wallet. The caller finishes teardown through the curve -/// module: `vesting_wallet::destroy_empty(wallet)` for the receipt, then the curve's +/// module: `vesting_wallet::destroy_empty(wallet, root)` for the receipt, then the curve's /// witness-gated `destroy` to consume it (which can impose its own gates, e.g. that /// the schedule has ended). /// diff --git a/contracts/finance/examples/vesting_wallet/splitter.move b/contracts/finance/examples/vesting_wallet/splitter.move index 2458b638..96323a8b 100644 --- a/contracts/finance/examples/vesting_wallet/splitter.move +++ b/contracts/finance/examples/vesting_wallet/splitter.move @@ -2,15 +2,21 @@ /// by fixed weights - a worked example of pointing a wallet's `beneficiary` at an /// object instead of a person. /// -/// A `VestingWallet`'s `beneficiary` is just an address: `release` `public_transfer`s -/// the payout coin to it. Point it at a shared `Beneficiary` and each release lands at -/// the object's address as a `Receiving>`; anyone can then poke `disperse` to -/// split that coin among the receivers by their allocations, fixed at creation and -/// free to differ between receivers. +/// A `VestingWallet`'s `beneficiary` is just an address: `release` credits the payout +/// to it via `balance::send_funds`, the funds-accumulator transfer. Point it at a +/// shared `Beneficiary` and each release settles into the object's address balance; +/// anyone can then poke `disperse` to withdraw all of it from the object's accumulator +/// and split it among the receivers - crediting each via +/// `balance::send_funds` too - by their allocations, fixed at creation and free to +/// differ between receivers. Each split emits a `Dispersed` event recording the +/// per-receiver amounts. +/// +/// For coins delivered the older way - `public_transfer`'d to the object's address by +/// an upstream emitter, landing as a `Receiving>` - `receive_and_disperse` +/// claims that coin, turns it into a `Balance`, and fans it out the same way. /// /// This composes with *any* curve and topology: the wallet neither knows nor cares -/// that its beneficiary is a contract. The split is per payout, so each `release` -/// queues one more `Receiving` for `disperse` to fan out. +/// that its beneficiary is a contract. The split is per payout. /// /// # Disclaimer /// @@ -21,7 +27,10 @@ module openzeppelin_finance::example_splitter; use openzeppelin_math::rounding; use openzeppelin_math::u64::mul_div; +use sui::accumulator::AccumulatorRoot; +use sui::balance::{Self, Balance}; use sui::coin::Coin; +use sui::event; use sui::transfer::Receiving; // === Errors === @@ -36,7 +45,8 @@ const EZeroWeight: vector = "Every weight must be greater than zero"; // === Structs === /// A shared payout splitter. Set a vesting wallet's `beneficiary` to this object's -/// address; releases land here and `disperse` fans each one out by weight. +/// address; releases settle into this object's accumulator and `disperse` fans each +/// one out by weight. public struct Beneficiary has key { id: UID, /// Payout recipients. @@ -47,11 +57,34 @@ public struct Beneficiary has key { total_weight: u64, } +// === Events === + +/// Emitted once per `disperse` / `receive_and_disperse` call: the payout was split and +/// credited to `receivers` via `balance::send_funds`, in the parallel `amounts` +/// (receiver order). The amounts sum to the dispersed payout. +public struct Dispersed has copy, drop { + /// The splitter object that fanned the payout out. + splitter: ID, + /// Recipients paid, in order. + receivers: vector
, + /// Amount credited to each receiver, parallel to `receivers`. + amounts: vector, +} + // === Public Functions === /// Create and share a splitter, returning the object's address to use as a vesting /// wallet's `beneficiary`. Allocations are fixed here and may differ per receiver. /// +/// #### Parameters +/// - `receivers`: Payout recipients; must be non-empty. +/// - `weights`: Allocation weight per receiver, parallel to `receivers`; each must be +/// positive. +/// - `ctx`: Transaction context, used to allocate the splitter's `UID`. +/// +/// #### Returns +/// - The shared splitter object's address, to set as a vesting wallet's `beneficiary`. +/// /// #### Aborts /// - `EBadConfig` if the vectors are empty or of unequal length. /// - `EZeroWeight` if any weight is zero. @@ -72,15 +105,71 @@ public fun new(receivers: vector
, weights: vector, ctx: &mut TxCon addr } -/// Pull one payout coin parked at this object's address (from a wallet `release`) and -/// fan it out to the receivers by weight. Permissionless. Conserves value exactly: -/// integer division floors each share and the last receiver absorbs the remainder, so -/// the transfers sum to the coin's value with nothing created or stranded as dust. -public fun disperse(self: &mut Beneficiary, payout: Receiving>, ctx: &mut TxContext) { - let mut coin = transfer::public_receive(&mut self.id, payout); - let value = coin.value(); +// TODO: add tests for this function once Sui supports creating AccumulatorRoot in tests. +/// Withdraw *all* of this object's settled `C` - where a vesting wallet `release` +/// credits its object beneficiary via `balance::send_funds` - and fan it out to the +/// receivers by weight. Permissionless. Always processes the entire settled balance in +/// one shot, so the rounding boundary is fixed by the configured split rather than by a +/// caller-chosen amount; a splitter with no settled funds is a no-op. +/// +/// #### Parameters +/// - `self`: The splitter holding the settled payout. +/// - `root`: The shared `AccumulatorRoot`, read to find the splitter's settled funds. +public fun disperse(self: &mut Beneficiary, root: &AccumulatorRoot) { + let addr = self.id.to_address(); + let amount = balance::settled_funds_value(root, addr); + if (amount == 0) return; + let withdrawal = balance::withdraw_funds_from_object(&mut self.id, amount); + self.fan_out(balance::redeem_funds(withdrawal)); +} + +/// Claim one payout coin parked at this object's address - `public_transfer`'d there by +/// an upstream emitter (or a stray coin) - turn it into a `Balance`, and fan it out to +/// the receivers by weight. The coin-object counterpart to `disperse`'s accumulator +/// withdrawal. Permissionless. +/// +/// #### Parameters +/// - `self`: The splitter the coin was parked at. +/// - `payout`: The `Coin` `public_transfer`'d to this object's address, to be +/// claimed and split. +/// +/// #### Aborts +/// - The native receive abort, raised at `transfer::public_receive`, if `payout` does +/// not match a coin currently sent to this object's address (wrong id or version). +public fun receive_and_disperse(self: &mut Beneficiary, payout: Receiving>) { + let coin = transfer::public_receive(&mut self.id, payout); + self.fan_out(coin.into_balance()); +} + +// === View helpers === + +/// The configured payout recipients. +public fun receivers(self: &Beneficiary): vector
{ + self.receivers +} + +/// The configured allocation weights, parallel to `receivers`. +public fun weights(self: &Beneficiary): vector { + self.weights +} + +/// The sum of all weights, the denominator of each receiver's share. +public fun total_weight(self: &Beneficiary): u64 { + self.total_weight +} + +// === Private Functions === + +/// Fan one payout balance out to the receivers by weight, crediting each via +/// `balance::send_funds` (address-balance-first), and emit `Dispersed`. Conserves value +/// exactly: integer division floors each share and the last receiver absorbs the +/// remainder, so the credits sum to the balance's value with nothing created or +/// stranded as dust. +fun fan_out(self: &Beneficiary, mut payout: Balance) { + let value = payout.value(); let n = self.receivers.length(); + let mut amounts = vector[]; let mut i = 0; while (i < n - 1) { let share = mul_div( @@ -89,26 +178,43 @@ public fun disperse(self: &mut Beneficiary, payout: Receiving>, ctx: self.total_weight, rounding::down(), ).destroy_some(); - transfer::public_transfer(coin.split(share, ctx), self.receivers[i]); + amounts.push_back(share); + balance::send_funds(payout.split(share), self.receivers[i]); i = i + 1; }; // The last receiver takes whatever remains, so floored shares never strand dust. - transfer::public_transfer(coin, self.receivers[n - 1]); -} + amounts.push_back(payout.value()); + balance::send_funds(payout, self.receivers[n - 1]); -// === View helpers === - -/// The configured payout recipients. -public fun receivers(self: &Beneficiary): vector
{ - self.receivers + event::emit(Dispersed { + splitter: object::id(self), + receivers: self.receivers, + amounts, + }); } -/// The configured allocation weights, parallel to `receivers`. -public fun weights(self: &Beneficiary): vector { - self.weights +// === Test-Only Helpers === + +/// Disperse an explicit `amount` from this object's accumulator without the +/// `AccumulatorRoot` settled-funds lookup, so unit tests can exercise the settled-funds +/// fan-out path without constructing an `AccumulatorRoot` - which has no test constructor +/// in the pinned Sui release. +/// +/// TODO: remove this and route the test through `disperse` with a real `AccumulatorRoot` +/// (via `accumulator::create_for_testing`) once that test helper ships in the published +/// Sui mainnet framework. +#[test_only] +public fun disperse_for_testing(self: &mut Beneficiary, amount: u64) { + let withdrawal = balance::withdraw_funds_from_object(&mut self.id, amount); + self.fan_out(balance::redeem_funds(withdrawal)); } -/// The sum of all weights. -public fun total_weight(self: &Beneficiary): u64 { - self.total_weight +/// Build a `Dispersed` event value for asserting against `event::events_by_type`. +#[test_only] +public fun test_new_dispersed( + splitter: ID, + receivers: vector
, + amounts: vector, +): Dispersed { + Dispersed { splitter, receivers, amounts } } diff --git a/contracts/finance/examples/vesting_wallet/tests/pausable_grant_tests.move b/contracts/finance/examples/vesting_wallet/tests/pausable_grant_tests.move index 9e0a7bf4..0e024b41 100644 --- a/contracts/finance/examples/vesting_wallet/tests/pausable_grant_tests.move +++ b/contracts/finance/examples/vesting_wallet/tests/pausable_grant_tests.move @@ -1,10 +1,11 @@ module openzeppelin_finance::example_pausable_grant_tests; use openzeppelin_finance::example_pausable_grant::{Self, PausableGrant, GrantAdminCap}; -use openzeppelin_finance::vesting_wallet::{Self, DestroyCap}; +use openzeppelin_finance::vesting_wallet::{Self, DestroyCap, Released}; use openzeppelin_finance::vesting_wallet_linear::{Self as linear, Linear, Params}; use std::unit_test::{assert_eq, destroy}; -use sui::coin::{Self, Coin}; +use sui::balance; +use sui::event; use sui::test_scenario as ts; /// Phantom coin marker for the vested asset. @@ -26,7 +27,7 @@ fun create_grant(scenario: &mut ts::Scenario) { BENEFICIARY, scenario.ctx(), ); - wallet.deposit(coin::mint_for_testing(TOTAL, scenario.ctx())); + wallet.deposit(balance::create_for_testing(TOTAL)); let admin_cap = example_pausable_grant::new(wallet, scenario.ctx()); transfer::public_transfer(admin_cap, EMPLOYER); // The wrapper never sees the teardown cap; the admin keeps it for later teardown. @@ -35,13 +36,9 @@ fun create_grant(scenario: &mut ts::Scenario) { // Evaluate the curve through the grant's immutable `inner()` view, then release // through the grant's own `release`. The caller never touches `&mut inner`. -fun release( - grant: &mut PausableGrant, - clock: &sui::clock::Clock, - ctx: &mut TxContext, -) { +fun release(grant: &mut PausableGrant, clock: &sui::clock::Clock) { let vested = linear::vested_amount(grant.inner(), clock); - grant.release(&vested, ctx); + grant.release(&vested); } // Happy path: a curve-agnostic release flows through the wrapper to the beneficiary. @@ -58,13 +55,17 @@ fun release_flows_through_wrapper_to_beneficiary() { // Halfway through the linear schedule: half is releasable. clock.set_for_testing(START_MS + DURATION_MS / 2); - release(&mut grant, &clock, scenario.ctx()); - - scenario.next_tx(BENEFICIARY); - let paid = scenario.take_from_address>(BENEFICIARY); - assert_eq!(paid.value(), TOTAL / 2); + release(&mut grant, &clock); + + // The half-vested payout was directed to the beneficiary. + let wallet_id = object::id(grant.inner()); + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, TOTAL / 2), + ); - destroy(paid); ts::return_shared(grant); destroy(clock); scenario.end(); @@ -86,7 +87,7 @@ fun release_aborts_while_paused() { assert!(grant.is_paused()); clock.set_for_testing(START_MS + DURATION_MS / 2); - release(&mut grant, &clock, scenario.ctx()); + release(&mut grant, &clock); abort } @@ -110,13 +111,17 @@ fun resume_restores_releases() { grant.resume(&cap); // The full total is now releasable in one go. - release(&mut grant, &clock, scenario.ctx()); - - scenario.next_tx(BENEFICIARY); - let paid = scenario.take_from_address>(BENEFICIARY); - assert_eq!(paid.value(), TOTAL); + release(&mut grant, &clock); + + // What accrued during the pause is paid to the beneficiary in full. + let wallet_id = object::id(grant.inner()); + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, TOTAL), + ); - destroy(paid); destroy(cap); ts::return_shared(grant); destroy(clock); @@ -139,7 +144,7 @@ fun unwrap_then_curve_teardown() { // Drain the grant at the end of the schedule. let mut grant = scenario.take_shared>(); clock.set_for_testing(START_MS + DURATION_MS); - release(&mut grant, &clock, scenario.ctx()); + release(&mut grant, &clock); // Admin dissolves the wrapper, recovering the bare wallet, and finalizes teardown // with the teardown cap it has held since creation: permissionless `destroy_empty` @@ -148,7 +153,9 @@ fun unwrap_then_curve_teardown() { let admin_cap = scenario.take_from_sender(); let destroy_cap = scenario.take_from_sender(); let wallet = grant.unwrap(admin_cap); - let receipt = wallet.destroy_empty(); + // TODO: use `destroy_empty` with a real `AccumulatorRoot` once + // `accumulator::create_for_testing` ships in the published Sui mainnet framework. + let receipt = wallet.destroy_empty_for_testing(); linear::destroy(receipt, destroy_cap, &clock); destroy(clock); @@ -168,7 +175,7 @@ fun foreign_cap_cannot_unwrap() { create_grant(&mut scenario); scenario.next_tx(EMPLOYER); - let grant_a = ts::take_shared_by_id>(&scenario, id_a); + let grant_a = scenario.take_shared_by_id>(id_a); // The sender holds two caps; the most recent one belongs to grant B. let cap_b = scenario.take_from_sender(); @@ -191,7 +198,7 @@ fun foreign_cap_cannot_pause() { create_grant(&mut scenario); scenario.next_tx(EMPLOYER); - let mut grant_a = ts::take_shared_by_id>(&scenario, id_a); + let mut grant_a = scenario.take_shared_by_id>(id_a); // The sender holds two caps; the most recent one belongs to grant B. let cap_b = scenario.take_from_sender(); @@ -213,7 +220,7 @@ fun foreign_cap_cannot_resume() { create_grant(&mut scenario); scenario.next_tx(EMPLOYER); - let mut grant_a = ts::take_shared_by_id>(&scenario, id_a); + let mut grant_a = scenario.take_shared_by_id>(id_a); // The sender holds two caps; the most recent one belongs to grant B. let cap_b = scenario.take_from_sender(); diff --git a/contracts/finance/examples/vesting_wallet/tests/splitter_tests.move b/contracts/finance/examples/vesting_wallet/tests/splitter_tests.move index a2252fb6..5c6f714b 100644 --- a/contracts/finance/examples/vesting_wallet/tests/splitter_tests.move +++ b/contracts/finance/examples/vesting_wallet/tests/splitter_tests.move @@ -1,10 +1,11 @@ module openzeppelin_finance::example_splitter_tests; -use openzeppelin_finance::example_splitter::{Self, Beneficiary}; +use openzeppelin_finance::example_splitter::{Self, Beneficiary, Dispersed}; use openzeppelin_finance::vesting_wallet::VestingWallet; use openzeppelin_finance::vesting_wallet_linear::{Self as linear, Linear, Params}; use std::unit_test::{assert_eq, destroy}; use sui::coin::{Self, Coin}; +use sui::event; use sui::test_scenario as ts; /// Phantom coin marker for the vested asset. @@ -47,33 +48,37 @@ fun release_to_splitter_fans_out_by_weight() { scenario.next_tx(EMPLOYER); let mut wallet = scenario.take_shared>(); - wallet.deposit(coin::mint_for_testing(TOTAL, scenario.ctx())); + wallet.deposit(coin::mint_for_testing(TOTAL, scenario.ctx()).into_balance()); // Release the full total at the end of the schedule; it lands at the splitter. clock.set_for_testing(START_MS + DURATION_MS); - linear::release(&mut wallet, &clock, scenario.ctx()); + linear::release(&mut wallet, &clock); ts::return_shared(wallet); - // Anyone fans the parked payout out to the three receivers. + // Anyone withdraws the full settled payout from the splitter's accumulator and fans + // it out to the three receivers. Uses the test-only bypass because `disperse` reads + // the settled balance from an `AccumulatorRoot`, which has no test constructor in the + // pinned Sui release. scenario.next_tx(EMPLOYER); let mut splitter = scenario.take_shared(); - let payout = ts::most_recent_receiving_ticket>(&object::id(&splitter)); - splitter.disperse(payout, scenario.ctx()); - ts::return_shared(splitter); + let splitter_id = object::id(&splitter); + splitter.disperse_for_testing(TOTAL); + + // 50/30/20 of the total, conserving every unit. The per-receiver credits land in + // each address's accumulator (not takeable as coins in the unit-test VM), so the + // split is attested by the `Dispersed` event. + let dispersed = event::events_by_type>(); + assert_eq!(dispersed.length(), 1); + assert_eq!( + dispersed[0], + example_splitter::test_new_dispersed( + splitter_id, + vector[ALICE, BOB, CAROL], + vector[TOTAL * 50 / 100, TOTAL * 30 / 100, TOTAL * 20 / 100], + ), + ); - // 50/30/20 of the total, conserving every unit. - scenario.next_tx(EMPLOYER); - let to_alice = scenario.take_from_address>(ALICE); - let to_bob = scenario.take_from_address>(BOB); - let to_carol = scenario.take_from_address>(CAROL); - assert_eq!(to_alice.value(), TOTAL * 50 / 100); - assert_eq!(to_bob.value(), TOTAL * 30 / 100); - assert_eq!(to_carol.value(), TOTAL * 20 / 100); - assert_eq!(to_alice.value() + to_bob.value() + to_carol.value(), TOTAL); - - destroy(to_alice); - destroy(to_bob); - destroy(to_carol); + ts::return_shared(splitter); destroy(clock); scenario.end(); } @@ -98,21 +103,22 @@ fun rounding_dust_goes_to_last_receiver() { scenario.next_tx(EMPLOYER); let payout = ts::most_recent_receiving_ticket>(&object::id(&splitter)); - splitter.disperse(payout, scenario.ctx()); - ts::return_shared(splitter); + let splitter_id = object::id(&splitter); + splitter.receive_and_disperse(payout); + + // Floored 33/33 with the last receiver absorbing the +1 dust, summing to 100. + let dispersed = event::events_by_type>(); + assert_eq!(dispersed.length(), 1); + assert_eq!( + dispersed[0], + example_splitter::test_new_dispersed( + splitter_id, + vector[ALICE, BOB, CAROL], + vector[33, 33, 34], + ), + ); - scenario.next_tx(EMPLOYER); - let to_alice = scenario.take_from_address>(ALICE); - let to_bob = scenario.take_from_address>(BOB); - let to_carol = scenario.take_from_address>(CAROL); - assert_eq!(to_alice.value(), 33); - assert_eq!(to_bob.value(), 33); - assert_eq!(to_carol.value(), 34); // absorbs the +1 dust - assert_eq!(to_alice.value() + to_bob.value() + to_carol.value(), 100); - - destroy(to_alice); - destroy(to_bob); - destroy(to_carol); + ts::return_shared(splitter); scenario.end(); } @@ -130,14 +136,18 @@ fun single_receiver_takes_everything() { scenario.next_tx(EMPLOYER); let payout = ts::most_recent_receiving_ticket>(&object::id(&splitter)); - splitter.disperse(payout, scenario.ctx()); - ts::return_shared(splitter); - - scenario.next_tx(EMPLOYER); - let to_alice = scenario.take_from_address>(ALICE); - assert_eq!(to_alice.value(), 100); + let splitter_id = object::id(&splitter); + splitter.receive_and_disperse(payout); + + // The lone receiver absorbs the full payout as the "last" one. + let dispersed = event::events_by_type>(); + assert_eq!(dispersed.length(), 1); + assert_eq!( + dispersed[0], + example_splitter::test_new_dispersed(splitter_id, vector[ALICE], vector[100]), + ); - destroy(to_alice); + ts::return_shared(splitter); scenario.end(); } diff --git a/contracts/finance/examples/vesting_wallet/tests/vesting_quadratic_tests.move b/contracts/finance/examples/vesting_wallet/tests/vesting_quadratic_tests.move index 72b36b7e..9b112a53 100644 --- a/contracts/finance/examples/vesting_wallet/tests/vesting_quadratic_tests.move +++ b/contracts/finance/examples/vesting_wallet/tests/vesting_quadratic_tests.move @@ -1,9 +1,10 @@ module openzeppelin_finance::example_vesting_quadratic_tests; use openzeppelin_finance::example_vesting_quadratic::{Self as quadratic, Quadratic, Params}; -use openzeppelin_finance::vesting_wallet::{Self, VestingWallet, DestroyCap}; +use openzeppelin_finance::vesting_wallet::{Self, VestingWallet, DestroyCap, Released}; use std::unit_test::{assert_eq, destroy}; -use sui::coin::{Self, Coin}; +use sui::balance; +use sui::event; use sui::test_scenario as ts; /// Phantom coin marker for the vested asset. @@ -26,7 +27,7 @@ fun create_and_share(scenario: &mut ts::Scenario) { BENEFICIARY, scenario.ctx(), ); - wallet.deposit(coin::mint_for_testing(TOTAL, scenario.ctx())); + wallet.deposit(balance::create_for_testing(TOTAL)); transfer::public_share_object(wallet); // Park the teardown cap with the beneficiary; the teardown test takes it from there. transfer::public_transfer(cap, BENEFICIARY); @@ -44,6 +45,7 @@ fun compose_create_fund_and_release_across_modules() { scenario.next_tx(BENEFICIARY); let mut wallet = scenario.take_shared>(); + let wallet_id = object::id(&wallet); // Before start: nothing vested. clock.set_for_testing(START_MS); @@ -58,24 +60,33 @@ fun compose_create_fund_and_release_across_modules() { clock.set_for_testing(START_MS + DURATION_MS / 2); assert_eq!(quadratic::releasable(&wallet, &clock), TOTAL / 4); let vested = quadratic::vested_amount(&wallet, &clock); - wallet.release(&vested, scenario.ctx()); + wallet.release(&vested); assert_eq!(wallet.released(), TOTAL / 4); // After end: everything remaining is releasable, draining the wallet. clock.set_for_testing(START_MS + DURATION_MS); assert_eq!(quadratic::releasable(&wallet, &clock), TOTAL - TOTAL / 4); - quadratic_release(&mut wallet, &clock, scenario.ctx()); + quadratic_release(&mut wallet, &clock); assert_eq!(wallet.released(), TOTAL); assert_eq!(wallet.balance(), 0); - // The beneficiary received exactly the total across the two releases. - scenario.next_tx(BENEFICIARY); - let paid = scenario.take_from_address>(BENEFICIARY); - let paid_2 = scenario.take_from_address>(BENEFICIARY); - assert_eq!(paid.value() + paid_2.value(), TOTAL); + // The beneficiary was paid exactly the total across the two releases, attested + // by the two `Released` events: TOTAL / 4 then the remainder. + let released = event::events_by_type>(); + assert_eq!(released.length(), 2); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, TOTAL / 4), + ); + assert_eq!( + released[1], + vesting_wallet::test_new_released( + wallet_id, + BENEFICIARY, + TOTAL - TOTAL / 4, + ), + ); - destroy(paid); - destroy(paid_2); ts::return_shared(wallet); destroy(clock); scenario.end(); @@ -123,7 +134,7 @@ fun late_deposit_vests_at_current_proportion() { // Halfway through, top up by the original total. New total is 2*TOTAL, and the // halfway proportion (1/4) applies to all of it. clock.set_for_testing(START_MS + DURATION_MS / 2); - wallet.deposit(coin::mint_for_testing(TOTAL, scenario.ctx())); + wallet.deposit(balance::create_for_testing(TOTAL)); assert_eq!(quadratic::releasable(&wallet, &clock), 2 * TOTAL / 4); ts::return_shared(wallet); @@ -160,24 +171,30 @@ fun compose_destroy_after_drain() { scenario.next_tx(BENEFICIARY); let mut wallet = scenario.take_shared>(); + let wallet_id = object::id(&wallet); // Run to the end and drain the wallet. clock.set_for_testing(START_MS + DURATION_MS); - quadratic_release(&mut wallet, &clock, scenario.ctx()); + quadratic_release(&mut wallet, &clock); assert_eq!(wallet.balance(), 0); + // The single release paid the full total to the beneficiary. + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, TOTAL), + ); + // Permissionless half reclaims the storage rebate; the gated half consumes the // receipt with the wallet's `DestroyCap` (parked with the beneficiary by // `create_and_share`) and enforces the ended gate. + // TODO: use `destroy_empty` with a real `AccumulatorRoot` once + // `accumulator::create_for_testing` ships in the published Sui mainnet framework. let cap = scenario.take_from_sender(); - let receipt = wallet.destroy_empty(); + let receipt = wallet.destroy_empty_for_testing(); quadratic::destroy(receipt, cap, &clock); - scenario.next_tx(BENEFICIARY); - let paid = scenario.take_from_address>(BENEFICIARY); - assert_eq!(paid.value(), TOTAL); - - destroy(paid); destroy(clock); scenario.end(); } @@ -198,7 +215,9 @@ fun destroy_aborts_before_end() { ); clock.set_for_testing(START_MS + DURATION_MS - 1); - let receipt = wallet.destroy_empty(); + // TODO: use `destroy_empty` with a real `AccumulatorRoot` once + // `accumulator::create_for_testing` ships in the published Sui mainnet framework. + let receipt = wallet.destroy_empty_for_testing(); quadratic::destroy(receipt, cap, &clock); abort @@ -225,7 +244,9 @@ fun destroy_rejects_wrong_cap() { ); clock.set_for_testing(START_MS + DURATION_MS); // after end, so the ended gate cannot fire first - let receipt = wallet.destroy_empty(); + // TODO: use `destroy_empty` with a real `AccumulatorRoot` once + // `accumulator::create_for_testing` ships in the published Sui mainnet framework. + let receipt = wallet.destroy_empty_for_testing(); quadratic::destroy(receipt, other_cap, &clock); abort @@ -248,8 +269,7 @@ fun params_rejects_overflowing_end() { fun quadratic_release( wallet: &mut VestingWallet, clock: &sui::clock::Clock, - ctx: &mut TxContext, ) { let vested = quadratic::vested_amount(wallet, clock); - wallet.release(&vested, ctx); + wallet.release(&vested); } diff --git a/contracts/finance/examples/vesting_wallet/vesting_quadratic.move b/contracts/finance/examples/vesting_wallet/vesting_quadratic.move index 3d22ddcb..d0515ab1 100644 --- a/contracts/finance/examples/vesting_wallet/vesting_quadratic.move +++ b/contracts/finance/examples/vesting_wallet/vesting_quadratic.move @@ -116,9 +116,9 @@ public fun releasable(wallet: &VestingWallet, clock: &C /// primitive's gate, so this curve does not (and must not) re-implement it as a /// `ctx.sender() == beneficiary` check, which could never be satisfied for a wallet /// whose beneficiary is an object address. The cap holder bears the strand risk for any -/// coin sent to the wallet's address but not yet `receive_and_deposit`'d (invisible to -/// `destroy_empty`'s empty check). The ended gate stops a teardown ahead of a deposit -/// intended to arrive later. +/// coin sent to the wallet's address but not yet `receive_and_deposit`'d, and must +/// sweep settled address-balance funds before `destroy_empty` accepts the teardown. The +/// ended gate stops a teardown ahead of a deposit intended to arrive later. /// /// #### Aborts /// - `EWrongCap` if `cap` was minted for a different wallet. diff --git a/contracts/finance/sources/vesting_wallet.move b/contracts/finance/sources/vesting_wallet.move index 0baed088..e1c50841 100644 --- a/contracts/finance/sources/vesting_wallet.move +++ b/contracts/finance/sources/vesting_wallet.move @@ -17,7 +17,7 @@ /// The wallet itself never interprets the schedule - it only enforces release /// accounting and conservation of funds. A curve module evaluates its curve for /// the current clock, mints a `VestedAmount`, and `release` pays out the -/// not-yet-released portion. +/// not-yet-released portion into the beneficiary's address balance. /// /// This module ships no curve of its own. The built-in linear-with-cliff schedule /// lives in the sibling `vesting_wallet_linear` module - the reference curve, and the @@ -69,7 +69,7 @@ /// itself (calling `vesting_wallet::new` directly) without routing through `new`. /// 3. A `vested(&VestingWallet, &Clock): VestedAmount` /// that ends in `vesting_wallet::mint_vested_amount(wallet, MyCurve {}, amount)`. -/// 4. A teardown that calls `vesting_wallet::destroy_empty(wallet)` to get a +/// 4. A teardown that calls `vesting_wallet::destroy_empty(wallet, root)` to get a /// `DestroyReceipt`, then /// `vesting_wallet::consume_receipt(receipt, cap, MyCurve {})` - passing the wallet's /// `DestroyCap` - to recover the schedule parameters for the curve module to @@ -92,29 +92,28 @@ /// /// - **Shared** (recommended): `transfer::public_share_object(wallet)` - anyone /// can poke `release`. `vesting_wallet_linear` exposes `create_and_share` sugar. -/// Because `release` is permissionless and pays each newly vested tranche as a -/// fresh `Coin`, a third party can call it repeatedly as the schedule progresses -/// and split the payout into many small coins - bounded (one coin per distinct clock -/// value over the window) and costly to force, with totals always preserved. Plan -/// for possibly many small payouts, especially when the beneficiary is an object. +/// `release` pays into the beneficiary's address balance with `balance::send_funds`, +/// so no payout `Coin` object is minted. /// - **Owned** (fast path): `transfer::public_transfer(wallet, addr)` - only the /// holder can pass the wallet by `&mut`, so funding and release are reachable /// from the holder's transactions only. Outside parties fund it by -/// `public_transfer`ing a `Coin` to the wallet's object address; the holder -/// then claims each with `receive_and_deposit`. Liveness risk: `release`, -/// `deposit`, `receive_and_deposit`, and `destroy_empty` all need `&mut` or -/// by-value access only the holder can produce, so a holder who is not the -/// beneficiary and turns uncooperative can withhold every payout with no on-chain -/// path for the beneficiary to force one. The recommended Shared topology avoids -/// this because its `release` is permissionless. +/// `public_transfer`ing a `Coin` to the wallet's object address (the holder +/// claims each with `receive_and_deposit`) or by settling a `Balance` into the +/// address (the holder pulls it in with `sweep_settled`). Liveness risk: `release`, +/// `deposit`, `receive_and_deposit`, `sweep_settled`, and `destroy_empty` all need +/// `&mut` or by-value access only the holder can produce, so a holder who is not the +/// beneficiary and turns uncooperative can withhold every payout with no on-chain path +/// for the beneficiary to force one. The recommended Shared topology avoids this +/// because its `release` is permissionless. /// /// The `beneficiary` is fixed at construction. To rotate the recipient, point /// `beneficiary` at a consumer-owned object and rotate ownership of that object /// instead. module openzeppelin_finance::vesting_wallet; +use sui::accumulator::AccumulatorRoot; use sui::balance::{Self, Balance}; -use sui::coin::{Self, Coin}; +use sui::coin::Coin; use sui::event; use sui::transfer::Receiving; @@ -149,6 +148,11 @@ const EInsufficientBalance: vector = "Releasable amount exceeds the wallet's #[error(code = 5)] const EWrongCap: vector = "DestroyCap does not match this wallet"; +/// `destroy_empty` was called on a wallet that still has unswept settled funds at +/// its object address. Call `sweep_settled` first. +#[error(code = 6)] +const EUnsweptFunds: vector = "Wallet has unswept settled funds at its address"; + // === Structs === /// The vesting wallet. `schedule_params` and `beneficiary` are fixed at @@ -207,7 +211,7 @@ public struct VestingWallet /// /// ```move /// let v = some_curve::vested_amount(wrapper.inner(), clock); -/// wrapper.release(&v, ctx); +/// wrapper.release(&v); /// ``` /// /// Handing out `&inner` is safe: it only allows views and curve-gated minting of an @@ -265,14 +269,30 @@ public struct Created has copy, drop { schedule_params: P, } -/// Emitted by `deposit` (and `receive_and_deposit`) when a non-zero amount is -/// added. A deposit of a zero-value coin emits no event. +/// Emitted by `deposit` when a non-zero amount is added. A deposit of a +/// zero-value balance emits no event. public struct Deposited has copy, drop { wallet_id: ID, /// Amount added to the balance by this deposit. amount: u64, } +/// Emitted by `sweep_settled` when a non-zero amount is added. A sweep of a +/// zero-value balance emits no event. +public struct Swept has copy, drop { + wallet_id: ID, + /// Amount added to the balance by this sweep. + amount: u64, +} + +/// Emitted by `receive_and_deposit` when a non-zero amount is added. A deposit +/// of a zero-value coin emits no event. +public struct Received has copy, drop { + wallet_id: ID, + /// Amount added to the balance by this receival. + amount: u64, +} + /// Emitted by `release` when a non-zero amount is paid to the beneficiary. A /// release that pays out nothing emits no event. public struct Released has copy, drop { @@ -281,11 +301,6 @@ public struct Released has copy, drop { /// Amount paid to the beneficiary by this release (the incremental portion, /// not the cumulative `released` total). amount: u64, - /// Object id of the payout `Coin` transferred to `beneficiary`. When the - /// beneficiary is an object address, this is the id to hand to - /// `transfer::public_receive`, letting off-chain consumers correlate this - /// event with the specific pending `Receiving>` it produced. - coin_id: ID, } /// Emitted by `destroy_empty` when a drained wallet is torn down. @@ -383,35 +398,71 @@ public fun mint_vested_amount( VestedAmount { wallet_id: object::id(wallet), amount } } -/// Add a coin to the wallet's balance. Permissionless - the beneficiary's +/// Add a `Balance` to the wallet's balance. Permissionless - the beneficiary's /// identity is data, not a capability, and anyone may fund. /// -/// A deposit of a zero-value coin is a no-op: the (empty) balance is consumed but +/// A deposit of a zero-value balance is a no-op: the (empty) balance is consumed but /// nothing changes and no `Deposited` event is emitted. /// +/// #### Parameters +/// - `wallet`: The wallet to fund. +/// - `balance`: The funds to add to the wallet's balance. +/// /// #### Aborts /// - `EBalanceOverflow` if the deposit would push the wallet's lifetime total /// `balance + released` (== `Σ(deposits)`) past `u64::MAX`, which would /// indefinitely brick the release path. public fun deposit( wallet: &mut VestingWallet, - coin: Coin, + balance: Balance, ) { - let amount = coin.value(); - - assert!( - std::u64::max_value!() - wallet.balance.value() - wallet.released >= amount, - EBalanceOverflow, - ); - - wallet.balance.join(coin.into_balance()); + let amount = wallet.deposit_internal(balance); if (amount == 0) return; event::emit(Deposited { wallet_id: object::id(wallet), amount }); } +/// Sweep all settled funds from the wallet's own object address balance into its +/// on-book `balance`. +/// +/// A wallet with no settled funds at its address is a no-op: nothing is swept and +/// no `Swept` event is emitted. +/// +/// #### Parameters +/// - `wallet`: The wallet to sweep into. +/// - `root`: The shared `AccumulatorRoot`, read to find the wallet's settled funds. +/// +/// #### Aborts +/// - `EBalanceOverflow` if sweeping the settled funds would push the wallet's +/// lifetime total `balance + released` past `u64::MAX` (propagated from +/// `deposit`). +/// - `sui::funds_accumulator::EObjectFundsWithdrawNotEnabled` if object funds +/// withdrawal is not enabled by protocol configuration. +/// - The `sui::funds_accumulator` redemption can abort if the withdrawal cannot be +/// redeemed for the settled funds observed at the wallet's object address. +public fun sweep_settled( + wallet: &mut VestingWallet, + root: &AccumulatorRoot, +) { + let addr = wallet.id.to_address(); + let amount = balance::settled_funds_value(root, addr); + if (amount == 0) return; + let w = balance::withdraw_funds_from_object(&mut wallet.id, amount); + // amount is already known and positive, so the returned value is redundant here. + _ = wallet.deposit_internal(balance::redeem_funds(w)); + event::emit(Swept { wallet_id: object::id(wallet), amount }); +} + /// Claim a coin that an upstream emitter `public_transfer`'d to this wallet's -/// object address, then funnel it through the standard deposit path. Used by -/// emission schedules and payroll robots that don't hold a wallet reference. +/// object address, then add it to the balance. Used by emission schedules and +/// payroll robots that don't hold a wallet reference. +/// +/// A claim of a zero-value coin is a no-op: the (empty) balance is consumed but +/// nothing changes and no `Received` event is emitted. +/// +/// #### Parameters +/// - `wallet`: The wallet to fund. +/// - `receiving`: The `Coin` transferred to the wallet's object address, to be +/// claimed and deposited. /// /// Requires `&mut wallet`. If the wallet is nested in a wrapper that keeps /// `&mut inner` private and does not re-expose this function, a coin sent to the @@ -436,32 +487,25 @@ public fun receive_and_deposit( receiving: Receiving>, ) { let coin = transfer::public_receive(&mut wallet.id, receiving); - wallet.deposit(coin); + let amount = wallet.deposit_internal(coin.into_balance()); + if (amount == 0) return; + event::emit(Received { wallet_id: object::id(wallet), amount }); } -/// Pay the not-yet-released portion attested by `vested` to the beneficiary. -/// Permissionless: anyone holding references to the wallet and a `VestedAmount` can -/// poke this. The recipient is always read fresh from `wallet.beneficiary` at call -/// time. `vested` is borrowed, not consumed, so the same attestation can still be -/// passed to a later call in the PTB. +/// Pay the not-yet-released portion attested by `vested` into the beneficiary's +/// address balance (via `balance::send_funds`) - no `Coin` object is minted or +/// transferred. Permissionless: anyone holding references to the wallet and a +/// `VestedAmount` can poke this. The recipient is always read fresh from +/// `wallet.beneficiary` at call time. `vested` is borrowed, not consumed, so the +/// same attestation can still be passed to a later call in the PTB. /// /// If nothing new is vested since the last release (the wallet is already drained -/// at this clock), the call is a no-op: no coin is transferred and no event is +/// at this clock), the call is a no-op: nothing is paid out and no event is /// emitted. /// -/// Each call pays the portion vested since the last release as one fresh `Coin`. -/// Because it is permissionless, a third party - not only the beneficiary - can call -/// it repeatedly as the schedule progresses, splitting the payout into many small -/// coins rather than a few large ones. The fragmentation is bounded (at most one coin -/// per distinct clock value over the vesting window) and costly to force (gas per -/// transaction), and totals are always preserved. Still, an integrator pointing a -/// wallet at an object beneficiary should plan for possibly many `Receiving`s to -/// process rather than a few large payouts. -/// /// #### Parameters /// - `wallet`: The wallet to release from. /// - `vested`: A `VestedAmount` minted for this wallet by its curve module. -/// - `ctx`: Transaction context, used to mint the payout coin. /// /// #### Aborts /// - `EWalletMismatch` if `vested` was not minted for this wallet. @@ -471,7 +515,6 @@ public fun receive_and_deposit( public fun release( wallet: &mut VestingWallet, vested: &VestedAmount, - ctx: &mut TxContext, ) { let VestedAmount { wallet_id, amount: vested_amount } = vested; assert!(wallet_id == object::id(wallet), EWalletMismatch); @@ -481,17 +524,17 @@ public fun release( if (releasable == 0) return; assert!(releasable <= wallet.balance.value(), EInsufficientBalance); - wallet.released = wallet.released + releasable; - let coin = coin::from_balance(wallet.balance.split(releasable), ctx); let beneficiary = wallet.beneficiary; - let coin_id = object::id(&coin); - transfer::public_transfer(coin, beneficiary); + + wallet.released = wallet.released + releasable; + + let payout = wallet.balance.split(releasable); + balance::send_funds(payout, beneficiary); event::emit(Released { wallet_id: *wallet_id, beneficiary, amount: releasable, - coin_id, }); } @@ -509,12 +552,20 @@ public fun release( /// split as `VestedAmount`: one half stays callable without the witness, the other half /// is gated. /// -/// Coins `public_transfer`'d to a destroyed wallet's address after this call have no -/// path back - pair destruction with halting any upstream emissions that target this -/// wallet. +/// Coins `public_transfer`'d to this wallet's address but not claimed before this call +/// are invisible to the held-balance check, and funds settled at the wallet's address +/// must be swept before teardown. Funds sent to this address after the wallet's `UID` +/// is deleted have no path back: a coin `public_transfer`'d there is unaddressable, and +/// a balance `send_funds`'d there settles against an object whose `UID` is gone, so no +/// `withdraw_funds_from_object` path can ever reclaim it. Pair destruction with halting +/// upstream emissions, claiming outstanding transferred coins, sweeping settled funds, +/// and letting the halt settle before tearing the wallet down. /// /// #### Parameters -/// - `wallet`: The wallet to destroy. Must hold a zero balance. +/// - `wallet`: The wallet to destroy. Must hold a zero balance and have no pending +/// settled funds at its object address (`sweep_settled` first if it does). +/// - `root`: The shared `AccumulatorRoot`, read to confirm the wallet's object +/// address holds no unswept settled funds. /// /// #### Returns /// - A `DestroyReceipt` carrying the wallet's schedule parameters, to be passed to @@ -522,22 +573,16 @@ public fun release( /// /// #### Aborts /// - `ENotEmpty` if the wallet still holds a balance. +/// - `EUnsweptFunds` if the wallet has any pending settled funds. public fun destroy_empty( wallet: VestingWallet, + root: &AccumulatorRoot, ): DestroyReceipt { assert!(wallet.balance.value() == 0, ENotEmpty); + let settled = balance::settled_funds_value(root, wallet.id.to_address()); + assert!(settled == 0, EUnsweptFunds); - let wallet_id = object::id(&wallet); - let beneficiary = wallet.beneficiary; - let total_released = wallet.released; - - let VestingWallet { id, balance, schedule_params, .. } = wallet; - balance.destroy_zero(); - id.delete(); - - event::emit(Destroyed { wallet_id, beneficiary, total_released }); - - DestroyReceipt { wallet_id, params: schedule_params } + wallet.finish_destroy() } /// Unwrap a `DestroyReceipt` to recover the destroyed wallet's schedule @@ -603,35 +648,17 @@ public fun releasable( /// Read the cumulative vested total recorded in a `VestedAmount` without /// consuming it. -/// -/// #### Parameters -/// - `vested`: The `VestedAmount` to read. -/// -/// #### Returns -/// - The cumulative vested total recorded in `vested`. public fun amount(vested: &VestedAmount): u64 { vested.amount } /// Read the wallet's schedule parameters. Ungated - curve parameters are public /// information. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The wallet's stored schedule parameters. public fun schedule_params(wallet: &VestingWallet): P { wallet.schedule_params } /// Address that receives every `release`. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The address that receives every `release`. public fun beneficiary( wallet: &VestingWallet, ): address { @@ -639,29 +666,79 @@ public fun beneficiary( } /// Cumulative amount released so far. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The cumulative amount released so far. public fun released(wallet: &VestingWallet): u64 { wallet.released } /// Funds currently held by the wallet and not yet released. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The funds currently held by the wallet and not yet released. public fun balance(wallet: &VestingWallet): u64 { wallet.balance.value() } +// === Private Functions === + +/// Join `balance` into the wallet's balance and return the amount added, leaving +/// event emission to the caller. Shared by `deposit`, `sweep_settled`, and +/// `receive_and_deposit`. +/// +/// #### Aborts +/// - `EBalanceOverflow` if the deposit would push the wallet's lifetime total +/// `balance + released` (== `Σ(deposits)`) past `u64::MAX`. +fun deposit_internal( + wallet: &mut VestingWallet, + balance: Balance, +): u64 { + let amount = balance.value(); + + // SAFETY: the subtractions cannot underflow because `balance + released` is + // held `<= u64::MAX` by this same check on every deposit (the only place the + // sum grows); `release` merely shifts value between the two fields. + assert!( + std::u64::max_value!() - wallet.balance.value() - wallet.released >= amount, + EBalanceOverflow, + ); + + wallet.balance.join(balance); + amount +} + +/// Shared teardown for `destroy_empty`: consume the drained wallet, emit `Destroyed`, and +/// return the receipt. Assumes the empty-balance and settled-funds gates have already +/// passed. +fun finish_destroy( + wallet: VestingWallet, +): DestroyReceipt { + let wallet_id = object::id(&wallet); + let beneficiary = wallet.beneficiary; + let total_released = wallet.released; + + let VestingWallet { id, balance, schedule_params, .. } = wallet; + balance.destroy_zero(); + id.delete(); + + event::emit(Destroyed { wallet_id, beneficiary, total_released }); + + DestroyReceipt { wallet_id, params: schedule_params } +} + // === Test-Only Helpers === +/// Tear down a drained wallet without the `AccumulatorRoot` settled-funds gate, so unit +/// tests can exercise teardown without constructing an `AccumulatorRoot` - which has no +/// test constructor in the pinned Sui release. The empty-balance gate is kept, so the +/// `ENotEmpty` path stays covered through this entry too. +/// +/// TODO: remove this and route the teardown tests through `destroy_empty` with a real +/// `AccumulatorRoot` (via `accumulator::create_for_testing`) once that test helper ships +/// in the published Sui mainnet framework. +#[test_only] +public fun destroy_empty_for_testing( + wallet: VestingWallet, +): DestroyReceipt { + assert!(wallet.balance.value() == 0, ENotEmpty); + wallet.finish_destroy() +} + /// Build a `Created` event value for asserting against `event::events_by_type`. #[test_only] public fun test_new_created( @@ -678,15 +755,26 @@ public fun test_new_deposited(wallet_id: ID, amount: u64): Deposited Deposited { wallet_id, amount } } +/// Build a `Swept` event value for asserting against `event::events_by_type`. +#[test_only] +public fun test_new_swept(wallet_id: ID, amount: u64): Swept { + Swept { wallet_id, amount } +} + +/// Build a `Received` event value for asserting against `event::events_by_type`. +#[test_only] +public fun test_new_received(wallet_id: ID, amount: u64): Received { + Received { wallet_id, amount } +} + /// Build a `Released` event value for asserting against `event::events_by_type`. #[test_only] public fun test_new_released( wallet_id: ID, beneficiary: address, amount: u64, - coin_id: ID, ): Released { - Released { wallet_id, beneficiary, amount, coin_id } + Released { wallet_id, beneficiary, amount } } /// Build a `Destroyed` event value for asserting against `event::events_by_type`. @@ -699,13 +787,6 @@ public fun test_new_destroyed( Destroyed { wallet_id, beneficiary, total_released } } -/// Read a `Released` event's `coin_id` for test assertions; the event's fields are -/// otherwise private. -#[test_only] -public fun test_released_coin_id(released: &Released): ID { - released.coin_id -} - /// Read a `DestroyReceipt`'s `params` for test assertions; the receipt is otherwise /// opaque (a hot potato with private fields). #[test_only] diff --git a/contracts/finance/sources/vesting_wallet_linear.move b/contracts/finance/sources/vesting_wallet_linear.move index bf9bcd9e..a44e2944 100644 --- a/contracts/finance/sources/vesting_wallet_linear.move +++ b/contracts/finance/sources/vesting_wallet_linear.move @@ -339,14 +339,9 @@ public fun vested_amount( /// #### Parameters /// - `wallet`: The wallet to release from. /// - `clock`: Sui `Clock`, read for the current timestamp. -/// - `ctx`: Transaction context, used to mint the payout coin. -public fun release( - wallet: &mut VestingWallet, - clock: &Clock, - ctx: &mut TxContext, -) { +public fun release(wallet: &mut VestingWallet, clock: &Clock) { let v = vested_amount(wallet, clock); - wallet.release(&v, ctx); + wallet.release(&v); } /// How much `release` would pay out right now, without the caller minting a @@ -375,14 +370,19 @@ public fun releasable(wallet: &VestingWallet, clock: &Cloc /// `DestroyCap` may tear it down. This is what lets a wallet whose `beneficiary` is an /// object address be torn down at all - an object address is never a `ctx.sender()`, so /// the previous "only the beneficiary may destroy" gate could never be satisfied for it. -/// The cap holder bears the strand risk the old beneficiary gate guarded: a coin +/// The cap holder bears the strand risk the old beneficiary gate guarded. A coin /// `public_transfer`'d to the wallet's address but not yet `receive_and_deposit`'d is -/// invisible to `destroy_empty`'s empty check, so finalizing teardown forfeits it. Route -/// the cap to the party that should own that decision (commonly the beneficiary or its -/// controller). -/// -/// The ended gate is retained: it stops a wallet being torn down ahead of a pending -/// deposit, front-running funding intended to arrive later. +/// invisible to the held-balance check, and settled address-balance funds must be swept +/// before `destroy_empty` accepts the teardown. Route the cap to the party that should +/// own that decision (commonly the beneficiary or its controller). Destruction is an +/// operational step rather than a guaranteed one-shot: halt upstream emissions first, +/// claim any transferred coins that already target the wallet, sweep settled funds, +/// and allow at least one checkpoint for in-flight emissions to settle before retrying +/// teardown. +/// +/// The ended gate is retained: it stops a wallet being torn down ahead of a scheduled +/// future deposit, front-running funding intended to arrive later. It cannot detect +/// coins already sent to the wallet's address but still unclaimed by the wallet. /// /// #### Parameters /// - `receipt`: The `DestroyReceipt` returned by @@ -402,68 +402,32 @@ public fun destroy(receipt: DestroyReceipt, cap: DestroyCap, clo // === View helpers === /// Timestamp (ms) at which vesting begins. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The timestamp (ms) at which vesting begins. public fun start_ms(wallet: &VestingWallet): u64 { wallet.schedule_params().start_ms } /// Length of each tranche period (ms). -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The length of each tranche period (ms). public fun period_ms(wallet: &VestingWallet): u64 { wallet.schedule_params().period_ms } /// Number of equal tranches. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The number of equal tranches. public fun steps(wallet: &VestingWallet): u64 { wallet.schedule_params().steps } /// Length of the vesting period (ms): `period_ms * steps`. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The length of the vesting period (ms): `period_ms * steps`. public fun duration_ms(wallet: &VestingWallet): u64 { let params = wallet.schedule_params(); params.period_ms * params.steps } /// Timestamp (ms) at which the schedule ends (`start_ms + period_ms * steps`). -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The timestamp (ms) at which the schedule ends (`start_ms + period_ms * steps`). public fun end_ms(wallet: &VestingWallet): u64 { wallet.schedule_params().calculate_end() } /// Read the configured cliff length (ms from `start_ms`). `0` means no cliff. -/// -/// #### Parameters -/// - `wallet`: The wallet to query. -/// -/// #### Returns -/// - The configured cliff length (ms from `start_ms`); `0` means no cliff. public fun cliff_ms(wallet: &VestingWallet): u64 { wallet.schedule_params().cliff_ms } diff --git a/contracts/finance/tests/vesting_wallet_linear_tests.move b/contracts/finance/tests/vesting_wallet_linear_tests.move index a58a31fb..b36a5784 100644 --- a/contracts/finance/tests/vesting_wallet_linear_tests.move +++ b/contracts/finance/tests/vesting_wallet_linear_tests.move @@ -3,6 +3,7 @@ module openzeppelin_finance::vesting_wallet_linear_tests; use openzeppelin_finance::vesting_wallet::{Self, VestingWallet, Created, Released, Destroyed}; use openzeppelin_finance::vesting_wallet_linear::{Self, Linear, Params}; use std::unit_test::{assert_eq, destroy}; +use sui::balance; use sui::clock::{Self, Clock}; use sui::coin; use sui::event; @@ -64,8 +65,8 @@ fun new_continuous( wallet } -fun fund(wallet: &mut VestingWallet, amount: u64, ctx: &mut TxContext) { - wallet.deposit(coin::mint_for_testing(amount, ctx)); +fun fund(wallet: &mut VestingWallet, amount: u64) { + wallet.deposit(balance::create_for_testing(amount)); } /// The curve's cumulative vested total at the given clock. @@ -137,7 +138,7 @@ fun new_accepts_cliff_equal_to_duration() { // duration = 1000 * 4 = 4000. let mut wallet = new_stepped(0, 4000, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(3999); assert_eq!(wallet.vested(&clk), 0); // still gated by the cliff @@ -192,7 +193,7 @@ fun params_drives_bare_primitive() { let p = vesting_wallet_linear::params(100, 250, 1000, 4); let (mut wallet, cap) = vesting_wallet::new(p, BENEFICIARY, test.ctx()); destroy(cap); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); assert_eq!(vesting_wallet_linear::start_ms(&wallet), 100); assert_eq!(vesting_wallet_linear::cliff_ms(&wallet), 250); @@ -280,7 +281,7 @@ fun vested_amount_pre_start_is_zero() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(1000, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(999); assert_eq!(wallet.vested(&clk), 0); @@ -297,7 +298,7 @@ fun vested_amount_is_a_staircase() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); // First period [0, 1000): zero steps elapsed, nothing vested. clk.set_for_testing(0); @@ -331,7 +332,7 @@ fun vested_amount_pre_cliff_is_zero() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 500, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(0); assert_eq!(wallet.vested(&clk), 0); @@ -357,7 +358,7 @@ fun vested_amount_at_cliff_jumps_to_catch_up() { // 8 steps of 1000ms (duration 8000); cliff at 3000 spans 3 full periods. let mut wallet = new_stepped(0, 3000, 1000, 8, test.ctx()); - wallet.fund(8000, test.ctx()); + wallet.fund(8000); clk.set_for_testing(2999); assert_eq!(wallet.vested(&clk), 0); // gated by the cliff @@ -379,7 +380,7 @@ fun vested_amount_post_end_clamps_to_total() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); // Last boundary is the end (start + period * steps = 4000): full total. clk.set_for_testing(4000); @@ -400,7 +401,7 @@ fun vested_amount_is_nondecreasing_in_time() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 1000, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); let mut prev = 0; vector[0u64, 500, 1000, 1500, 2000, 3000, 4000, 5000].do!(|sample| { @@ -425,7 +426,7 @@ fun vested_amount_uses_u128_intermediate_at_max() { // 2 steps of (max / 2) each: duration = max - 1 (max is odd), end = max - 1. let half = max / 2; let mut wallet = new_stepped(0, 0, half, 2, test.ctx()); - wallet.fund(max, test.ctx()); + wallet.fund(max); // One step elapsed: max * 1 / 2 = floor(max / 2), with no overflow and no abort. clk.set_for_testing(half); @@ -447,7 +448,7 @@ fun deposit_vests_as_if_from_start() { clk.set_for_testing(2000); // two steps elapsed assert_eq!(wallet.vested(&clk), 0); // nothing deposited yet - wallet.fund(1000, test.ctx()); + wallet.fund(1000); // two of four steps elapsed, so half the fresh deposit is already vested. assert_eq!(wallet.vested(&clk), 500); @@ -467,41 +468,23 @@ fun release_pays_step_portion_and_is_permissionless() { let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); let wallet_id = object::id(&wallet); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(2000); // two steps => 500 - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 500); assert_eq!(wallet.balance(), 500); - // The event is emitted with the payout coin's id (read here before `next_tx` - // flushes the event buffer). let released = event::events_by_type>(); assert_eq!(released.length(), 1); - let coin_id = vesting_wallet::test_released_coin_id(&released[0]); assert_eq!( released[0], - vesting_wallet::test_new_released( - wallet_id, - BENEFICIARY, - 500, - coin_id, - ), + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, 500), ); destroy(wallet); - // The beneficiary owns exactly the released coin, and its id matches the one - // the event carried - so an off-chain consumer can correlate the event with - // the specific payout coin it produced. - test.next_tx(BENEFICIARY); - let coin = test.take_from_sender>(); - assert_eq!(coin.value(), 500); - assert_eq!(object::id(&coin), coin_id); - - destroy(coin); - destroy(clk); test.end(); } @@ -514,17 +497,17 @@ fun release_then_release_within_same_period_is_noop() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(1000); // one step => 250 - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 250); assert_eq!(event::events_by_type>().length(), 1); // Still in the same period: nothing new, no event, no change. test.next_tx(@0x1); clk.set_for_testing(1999); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 250); assert_eq!(wallet.balance(), 750); assert_eq!(event::events_by_type>().length(), 0); @@ -541,12 +524,12 @@ fun releasable_view_matches_release() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(2000); // two steps => 500 assert_eq!(vesting_wallet_linear::releasable(&wallet, &clk), 500); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(vesting_wallet_linear::releasable(&wallet, &clk), 0); destroy(wallet); @@ -565,7 +548,7 @@ fun releasable_is_zero_pre_start_and_pre_cliff() { // Opens at 1000; cliff at start + 3000 spans 3 full periods. let mut wallet = new_stepped(1000, 3000, 1000, 8, test.ctx()); - wallet.fund(8000, test.ctx()); + wallet.fund(8000); clk.set_for_testing(999); // pre-start assert_eq!(vesting_wallet_linear::releasable(&wallet, &clk), 0); @@ -584,10 +567,10 @@ fun full_release_after_end_then_releasable_zero() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(4000); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 1000); assert_eq!(wallet.balance(), 0); assert_eq!(vesting_wallet_linear::releasable(&wallet, &clk), 0); @@ -613,14 +596,16 @@ fun destroy_after_end_on_empty_wallet() { test.ctx(), ); let wallet_id = object::id(&wallet); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(4000); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.balance(), 0); test.next_tx(BENEFICIARY); - let receipt = wallet.destroy_empty(); + // TODO: use `destroy_empty` with a real `AccumulatorRoot` once + // `accumulator::create_for_testing` ships in the published Sui mainnet framework. + let receipt = wallet.destroy_empty_for_testing(); vesting_wallet_linear::destroy(receipt, cap, &clk); let destroyed = event::events_by_type>(); @@ -661,14 +646,14 @@ fun destroy_works_for_object_beneficiary() { 4, test.ctx(), ); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(4000); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); // drains to the object beneficiary + vesting_wallet_linear::release(&mut wallet, &clk); // drains to the object beneficiary assert_eq!(wallet.balance(), 0); // A keypair that is NOT the object beneficiary tears the wallet down with the cap. test.next_tx(@0xCAFE); - let receipt = wallet.destroy_empty(); + let receipt = wallet.destroy_empty_for_testing(); vesting_wallet_linear::destroy(receipt, cap, &clk); assert_eq!(event::events_by_type>().length(), 1); @@ -686,7 +671,7 @@ fun destroy_rejects_before_end() { let (wallet, cap) = vesting_wallet_linear::new(BENEFICIARY, 0, 0, 1000, 4, test.ctx()); clk.set_for_testing(3999); - let receipt = wallet.destroy_empty(); + let receipt = wallet.destroy_empty_for_testing(); vesting_wallet_linear::destroy(receipt, cap, &clk); abort } @@ -705,9 +690,9 @@ fun destroy_rejects_nonempty_balance() { 4, test.ctx(), ); - wallet.fund(1, test.ctx()); + wallet.fund(1); clk.set_for_testing(5000); // after end, so the ended gate cannot fire - let receipt = wallet.destroy_empty(); + let receipt = wallet.destroy_empty_for_testing(); vesting_wallet_linear::destroy(receipt, cap, &clk); abort } @@ -730,7 +715,7 @@ fun destroy_rejects_wrong_cap() { ); clk.set_for_testing(5000); // after end, so the ended gate cannot fire first - let receipt = wallet.destroy_empty(); + let receipt = wallet.destroy_empty_for_testing(); vesting_wallet_linear::destroy(receipt, other_cap, &clk); abort } @@ -743,9 +728,9 @@ fun create_deposit_release_in_one_flow() { let (mut test, mut clk) = setup(0); let mut wallet = new_stepped(0, 0, 1000, 4, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(2000); // two steps => 500 - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 500); assert_eq!(wallet.balance(), 500); @@ -767,14 +752,14 @@ fun release_before_first_step_moves_no_funds() { // Opens at 1000, first tranche at 2000. let mut wallet = new_stepped(1000, 0, 1000, 4, test.ctx()); - wallet.fund(1_000_000, test.ctx()); + wallet.fund(1_000_000); clk.set_for_testing(999); // pre-start - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); clk.set_for_testing(1000); // at start, first period, zero steps elapsed - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); clk.set_for_testing(1999); // one ms before the first tranche - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 0); assert_eq!(wallet.balance(), 1_000_000); @@ -834,7 +819,7 @@ fun new_continuous_accepts_cliff_equal_to_duration() { let (mut test, mut clk) = setup(0); let mut wallet = new_continuous(0, 1000, 1000, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(999); assert_eq!(wallet.vested(&clk), 0); // still gated by the cliff @@ -902,7 +887,7 @@ fun vested_amount_continuous_at_exact_start_is_zero() { let (mut test, mut clk) = setup(0); let mut wallet = new_continuous(1000, 0, 1000, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(1000); // now == start_ms, elapsed == 0 assert_eq!(wallet.vested(&clk), 0); @@ -920,7 +905,7 @@ fun vested_amount_continuous_at_cliff_jumps_to_proportional() { // total = 1000, cliff = 1000, duration = 4000 => jump to 1000 * 1000 / 4000 = 250 let mut wallet = new_continuous(0, 1000, 4000, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(999); assert_eq!(wallet.vested(&clk), 0); @@ -938,7 +923,7 @@ fun vested_amount_continuous_is_linear_mid_schedule() { let (mut test, mut clk) = setup(0); let mut wallet = new_continuous(0, 1000, 4000, test.ctx()); - wallet.fund(1000, test.ctx()); + wallet.fund(1000); clk.set_for_testing(2000); assert_eq!(wallet.vested(&clk), 500); // 1000 * 2000 / 4000 @@ -958,7 +943,7 @@ fun vested_amount_continuous_uses_u128_intermediate_at_max() { let max = std::u64::max_value!(); let mut wallet = new_continuous(0, 0, max, test.ctx()); - wallet.fund(max, test.ctx()); + wallet.fund(max); clk.set_for_testing(max - 1); // max * (max - 1) / max == max - 1, with no overflow and no abort. @@ -995,7 +980,7 @@ fun receive_and_deposit_then_release_in_one_flow() { let receiving = test_scenario::receiving_ticket_by_id>(coin_id); wallet.receive_and_deposit(receiving); clk.set_for_testing(500); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); + vesting_wallet_linear::release(&mut wallet, &clk); assert_eq!(wallet.released(), 500); assert_eq!(wallet.balance(), 500); @@ -1018,20 +1003,20 @@ fun retroactive_deposit_never_over_releases() { let (mut test, mut clk) = setup(0); let mut wallet = new_continuous(0, 0, 100, test.ctx()); - wallet.fund(100, test.ctx()); + wallet.fund(100); clk.set_for_testing(50); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); // floor(100 * 50 / 100) = 50 + vesting_wallet_linear::release(&mut wallet, &clk); // floor(100 * 50 / 100) = 50 assert_eq!(wallet.released(), 50); assert_eq!(wallet.balance(), 50); // Late deposit; total is now 200, re-derived fresh at call time. - wallet.fund(100, test.ctx()); + wallet.fund(100); let releasable = vesting_wallet_linear::releasable(&wallet, &clk); assert_eq!(releasable, 50); // floor(200 * 50 / 100) = 100 cumulative, minus 50 already released assert!(releasable <= wallet.balance()); // never exceeds the balance backing it - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); // does not abort, does not over-pay + vesting_wallet_linear::release(&mut wallet, &clk); // does not abort, does not over-pay assert_eq!(wallet.released(), 100); assert_eq!(wallet.balance(), 100); assert_eq!(wallet.balance() + wallet.released(), 200); // conserved: nothing minted from nowhere @@ -1052,13 +1037,13 @@ fun overflowing_refund_is_rejected_at_deposit() { let max = std::u64::max_value!(); let mut wallet = new_continuous(0, 0, max, test.ctx()); - wallet.fund(max, test.ctx()); + wallet.fund(max); clk.set_for_testing(1); - vesting_wallet_linear::release(&mut wallet, &clk, test.ctx()); // releases 1; balance = max - 1, released = 1 + vesting_wallet_linear::release(&mut wallet, &clk); // releases 1; balance = max - 1, released = 1 // balance + released == max already; refunding the released 1 would overflow it, // so the deposit is rejected here rather than bricking a later release. - wallet.fund(1, test.ctx()); + wallet.fund(1); abort } diff --git a/contracts/finance/tests/vesting_wallet_tests.move b/contracts/finance/tests/vesting_wallet_tests.move index fcd53ed9..e6b2ef19 100644 --- a/contracts/finance/tests/vesting_wallet_tests.move +++ b/contracts/finance/tests/vesting_wallet_tests.move @@ -6,10 +6,12 @@ use openzeppelin_finance::vesting_wallet::{ VestedAmount, Created, Deposited, + Received, Released, Destroyed }; use std::unit_test::{assert_eq, destroy}; +use sui::balance::{Self, Balance}; use sui::coin::{Self, Coin}; use sui::event; use sui::test_scenario; @@ -56,8 +58,8 @@ fun new_wallet( wallet } -fun mint(amount: u64, ctx: &mut TxContext): Coin { - coin::mint_for_testing(amount, ctx) +fun mint(amount: u64): Balance { + balance::create_for_testing(amount) } fun wrap(inner: VestingWallet, ctx: &mut TxContext): Wrapper { @@ -72,8 +74,8 @@ fun inner(wrapper: &Wrapper): &VestingWallet { /// The wrapper's own `release`: takes a `&VestedAmount` (no witness) and delegates to /// the private `&mut inner`. -fun release(wrapper: &mut Wrapper, vested: &VestedAmount, ctx: &mut TxContext) { - wrapper.inner.release(vested, ctx); +fun release(wrapper: &mut Wrapper, vested: &VestedAmount) { + wrapper.inner.release(vested); } // === Construction & topology === @@ -135,7 +137,7 @@ fun deposit_increases_balance_emits_and_is_permissionless() { let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); let wallet_id = object::id(&wallet); - wallet.deposit(mint(1000, scenario.ctx())); + wallet.deposit(mint(1000)); assert_eq!(wallet.balance(), 1000); assert_eq!(wallet.released(), 0); @@ -151,7 +153,7 @@ fun deposit_increases_balance_emits_and_is_permissionless() { } // Only receipts addressed to this wallet are claimable; doing so emits a single -// `Deposited` (no separate `Received` event) and conserves funds. +// `Received` event (not `Deposited`) and conserves funds. #[test] fun receive_and_deposit_claims_addressed_coin() { let mut scenario = test_scenario::begin(@0x1); @@ -164,20 +166,22 @@ fun receive_and_deposit_claims_addressed_coin() { // An upstream emitter sends a coin to the wallet's object address. scenario.next_tx(@0x1); - let coin = mint(1000, scenario.ctx()); + let coin = coin::mint_for_testing(1000, scenario.ctx()); let coin_id = object::id(&coin); transfer::public_transfer(coin, wallet_addr); - // The wallet claims it through the standard deposit path. + // The wallet claims it, adding the coin to its balance. scenario.next_tx(@0x1); let mut wallet = scenario.take_shared>(); let receiving = test_scenario::receiving_ticket_by_id>(coin_id); wallet.receive_and_deposit(receiving); assert_eq!(wallet.balance(), 1000); - let deposited = event::events_by_type>(); - assert_eq!(deposited.length(), 1); - assert_eq!(deposited[0], vesting_wallet::test_new_deposited(wallet_id, 1000)); + let received = event::events_by_type>(); + assert_eq!(received.length(), 1); + assert_eq!(received[0], vesting_wallet::test_new_received(wallet_id, 1000)); + // A claim emits `Received`, never `Deposited`. + assert_eq!(event::events_by_type>().length(), 0); test_scenario::return_shared(wallet); scenario.end(); @@ -190,7 +194,7 @@ fun deposit_zero_value_coin_is_noop() { let mut scenario = test_scenario::begin(@0xCAFE); let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); - wallet.deposit(mint(0, scenario.ctx())); + wallet.deposit(mint(0)); assert_eq!(wallet.balance(), 0); assert_eq!(wallet.released(), 0); @@ -216,25 +220,54 @@ fun receive_and_deposit_claims_addressed_coin_owned() { // An upstream emitter sends a coin to the wallet's object address. scenario.next_tx(@0x1); - let coin = mint(1000, scenario.ctx()); + let coin = coin::mint_for_testing(1000, scenario.ctx()); let coin_id = object::id(&coin); transfer::public_transfer(coin, wallet_addr); - // The holder takes their owned wallet and claims the coin through the deposit path. + // The holder takes their owned wallet and claims the coin. scenario.next_tx(holder); let mut wallet = scenario.take_from_sender>(); let receiving = test_scenario::receiving_ticket_by_id>(coin_id); wallet.receive_and_deposit(receiving); assert_eq!(wallet.balance(), 1000); - let deposited = event::events_by_type>(); - assert_eq!(deposited.length(), 1); - assert_eq!(deposited[0], vesting_wallet::test_new_deposited(wallet_id, 1000)); + let received = event::events_by_type>(); + assert_eq!(received.length(), 1); + assert_eq!(received[0], vesting_wallet::test_new_received(wallet_id, 1000)); destroy(wallet); scenario.end(); } +// Claiming a zero-value coin is a no-op: balance and ledger are untouched and no +// `Received` event is emitted (mirroring `deposit` of a zero-value coin). +#[test] +fun receive_and_deposit_zero_value_coin_is_noop() { + let mut scenario = test_scenario::begin(@0x1); + + let wallet = new_wallet(BENEFICIARY, scenario.ctx()); + let wallet_addr = object::id_address(&wallet); + transfer::public_share_object(wallet); + + // An upstream emitter sends a zero-value coin to the wallet's object address. + scenario.next_tx(@0x1); + let coin = coin::mint_for_testing(0, scenario.ctx()); + let coin_id = object::id(&coin); + transfer::public_transfer(coin, wallet_addr); + + scenario.next_tx(@0x1); + let mut wallet = scenario.take_shared>(); + let receiving = test_scenario::receiving_ticket_by_id>(coin_id); + wallet.receive_and_deposit(receiving); + + assert_eq!(wallet.balance(), 0); + assert_eq!(wallet.released(), 0); + assert_eq!(event::events_by_type>().length(), 0); + + test_scenario::return_shared(wallet); + scenario.end(); +} + // A deposit that would push the lifetime total `balance + released` past u64::MAX is // rejected up front with `EOverflow` - the wallet's u64 accounting cannot represent a // larger total. The funds already held stay releasable rather than the deposit @@ -245,20 +278,20 @@ fun deposit_rejects_overflowing_total() { let max = std::u64::max_value!(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(max, &mut ctx)); // balance = max, released = 0 + wallet.deposit(mint(max)); // balance = max, released = 0 // Release 1 so released > 0 while balance + released stays == max. let vested = wallet.mint_vested_amount(TestCurve {}, 1); - wallet.release(&vested, &mut ctx); // released = 1, balance = max - 1 + wallet.release(&vested); // released = 1, balance = max - 1 // balance + released == max already, so any further deposit overflows. - wallet.deposit(mint(1, &mut ctx)); + wallet.deposit(mint(1)); abort } -// `receive_and_deposit` funnels through `deposit`, so the same overflow guard fires: -// claiming a coin addressed to the wallet that would push `balance + released` past -// u64::MAX aborts with `EOverflow`. (In production the already-transferred coin is +// `receive_and_deposit` shares `deposit`'s balance logic, so the same overflow guard +// fires: claiming a coin addressed to the wallet that would push `balance + released` +// past u64::MAX aborts with `EOverflow`. (In production the already-transferred coin is // then stranded at the wallet's address - see the function's docs; here the abort // just rolls the claim back.) #[test, expected_failure(abort_code = vesting_wallet::EBalanceOverflow)] @@ -268,19 +301,19 @@ fun receive_and_deposit_rejects_overflowing_total() { // Fund to max, release 1 so balance + released == max, then share the wallet. let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); - wallet.deposit(mint(max, scenario.ctx())); + wallet.deposit(mint(max)); let vested = wallet.mint_vested_amount(TestCurve {}, 1); - wallet.release(&vested, scenario.ctx()); // released = 1, balance = max - 1 + wallet.release(&vested); // released = 1, balance = max - 1 let wallet_addr = object::id_address(&wallet); transfer::public_share_object(wallet); // An upstream emitter sends a coin to the wallet's object address. scenario.next_tx(@0x1); - let coin = mint(1, scenario.ctx()); + let coin = coin::mint_for_testing(1, scenario.ctx()); let coin_id = object::id(&coin); transfer::public_transfer(coin, wallet_addr); - // Claiming it would push balance + released to max + 1 -> EOverflow via `deposit`. + // Claiming it would push balance + released to max + 1 -> EOverflow. scenario.next_tx(@0x1); let mut wallet = scenario.take_shared>(); let receiving = test_scenario::receiving_ticket_by_id>(coin_id); @@ -319,7 +352,7 @@ fun release_rejects_vested_from_other_wallet() { let mut wallet_b = new_wallet(BENEFICIARY, &mut ctx); let vested_a = wallet_a.mint_vested_amount(TestCurve {}, 100); - wallet_b.release(&vested_a, &mut ctx); + wallet_b.release(&vested_a); abort } @@ -346,40 +379,22 @@ fun release_pays_releasable_to_beneficiary() { let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); let wallet_id = object::id(&wallet); - wallet.deposit(mint(1000, scenario.ctx())); + wallet.deposit(mint(1000)); let vested = wallet.mint_vested_amount(TestCurve {}, 400); - wallet.release(&vested, scenario.ctx()); + wallet.release(&vested); assert_eq!(wallet.released(), 400); assert_eq!(wallet.balance(), 600); - // The event is emitted with the payout coin's id (read here before `next_tx` - // flushes the event buffer). let released = event::events_by_type>(); assert_eq!(released.length(), 1); - let coin_id = vesting_wallet::test_released_coin_id(&released[0]); assert_eq!( released[0], - vesting_wallet::test_new_released( - wallet_id, - BENEFICIARY, - 400, - coin_id, - ), + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, 400), ); destroy(wallet); - - // The beneficiary owns exactly the released coin, and its id matches the one - // the event carried - so an off-chain consumer can correlate the event with - // the specific payout coin it produced. - scenario.next_tx(BENEFICIARY); - let coin = scenario.take_from_sender>(); - assert_eq!(coin.value(), 400); - assert_eq!(object::id(&coin), coin_id); - - destroy(coin); scenario.end(); } @@ -390,10 +405,10 @@ fun release_is_noop_when_nothing_releasable() { let mut scenario = test_scenario::begin(@0x1); let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); - wallet.deposit(mint(1000, scenario.ctx())); + wallet.deposit(mint(1000)); let vested = wallet.mint_vested_amount(TestCurve {}, 0); - wallet.release(&vested, scenario.ctx()); + wallet.release(&vested); assert_eq!(wallet.released(), 0); assert_eq!(wallet.balance(), 1000); @@ -410,16 +425,16 @@ fun release_again_at_same_total_is_noop() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(1000, &mut ctx)); + wallet.deposit(mint(1000)); let vested = wallet.mint_vested_amount(TestCurve {}, 500); - wallet.release(&vested, &mut ctx); + wallet.release(&vested); assert_eq!(wallet.released(), 500); assert_eq!(wallet.balance(), 500); let again = wallet.mint_vested_amount(TestCurve {}, 500); assert_eq!(wallet.releasable(&again), 0); - wallet.release(&again, &mut ctx); + wallet.release(&again); assert_eq!(wallet.released(), 500); assert_eq!(wallet.balance(), 500); @@ -433,15 +448,15 @@ fun release_is_monotone_across_increasing_totals() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(1000, &mut ctx)); + wallet.deposit(mint(1000)); let first = wallet.mint_vested_amount(TestCurve {}, 100); - wallet.release(&first, &mut ctx); + wallet.release(&first); let after_first = wallet.released(); assert_eq!(after_first, 100); let second = wallet.mint_vested_amount(TestCurve {}, 300); - wallet.release(&second, &mut ctx); + wallet.release(&second); assert_eq!(wallet.released(), 300); // monotone and conserved @@ -458,13 +473,13 @@ fun release_rejects_vested_below_released() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(1000, &mut ctx)); + wallet.deposit(mint(1000)); let high = wallet.mint_vested_amount(TestCurve {}, 200); - wallet.release(&high, &mut ctx); + wallet.release(&high); let regressed = wallet.mint_vested_amount(TestCurve {}, 100); - wallet.release(®ressed, &mut ctx); + wallet.release(®ressed); abort } @@ -474,10 +489,10 @@ fun releasable_rejects_vested_below_released() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(1000, &mut ctx)); + wallet.deposit(mint(1000)); let high = wallet.mint_vested_amount(TestCurve {}, 200); - wallet.release(&high, &mut ctx); + wallet.release(&high); let regressed = wallet.mint_vested_amount(TestCurve {}, 100); wallet.releasable(®ressed); @@ -493,12 +508,12 @@ fun release_aborts_when_vested_exceeds_total() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(100, &mut ctx)); + wallet.deposit(mint(100)); // Attest more than balance + released (= 100). `release` clears the wallet_id and - // `>= released` guards, then `EInsufficientBalance` aborts before any coin is minted. + // `>= released` guards, then `EInsufficientBalance` aborts before funds are sent. let vested = wallet.mint_vested_amount(TestCurve {}, 200); - wallet.release(&vested, &mut ctx); + wallet.release(&vested); abort } @@ -510,15 +525,15 @@ fun release_aborts_when_releasable_exceeds_remaining_balance() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(100, &mut ctx)); + wallet.deposit(mint(100)); // Drain part of the balance: released = 60, balance = 40. let first = wallet.mint_vested_amount(TestCurve {}, 60); - wallet.release(&first, &mut ctx); + wallet.release(&first); // Attest 150 > balance + released (= 100); releasable = 90 > balance (= 40). let second = wallet.mint_vested_amount(TestCurve {}, 150); - wallet.release(&second, &mut ctx); + wallet.release(&second); abort } @@ -529,10 +544,10 @@ fun release_allows_draining_exact_balance() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(100, &mut ctx)); + wallet.deposit(mint(100)); let vested = wallet.mint_vested_amount(TestCurve {}, 100); - wallet.release(&vested, &mut ctx); + wallet.release(&vested); assert_eq!(wallet.released(), 100); assert_eq!(wallet.balance(), 0); @@ -551,7 +566,9 @@ fun destroy_empty_returns_params_and_emits() { let wallet = new_wallet(BENEFICIARY, scenario.ctx()); let wallet_id = object::id(&wallet); - let receipt = wallet.destroy_empty(); + // TODO: use `destroy_empty` with a real `AccumulatorRoot` once + // `accumulator::create_for_testing` ships in the published Sui mainnet framework. + let receipt = wallet.destroy_empty_for_testing(); assert_eq!(vesting_wallet::test_receipt_params(&receipt), TestParams { tag: PARAMS_TAG }); let destroyed = event::events_by_type>(); @@ -571,9 +588,9 @@ fun destroy_empty_rejects_nonempty_balance() { let mut ctx = tx_context::dummy(); let mut wallet = new_wallet(BENEFICIARY, &mut ctx); - wallet.deposit(mint(1, &mut ctx)); + wallet.deposit(mint(1)); - let _receipt = wallet.destroy_empty(); + let _receipt = wallet.destroy_empty_for_testing(); abort } @@ -591,7 +608,7 @@ fun consume_receipt_with_matching_cap_returns_params() { &mut ctx, ); - let receipt = wallet.destroy_empty(); + let receipt = wallet.destroy_empty_for_testing(); let params = receipt.consume_receipt(cap, TestCurve {}); assert_eq!(params, TestParams { tag: PARAMS_TAG }); @@ -614,11 +631,108 @@ fun consume_receipt_rejects_foreign_cap() { ); // Tearing A's receipt down with B's cap aborts: the cap's `wallet_id` does not match. - let receipt_a = wallet_a.destroy_empty(); + let receipt_a = wallet_a.destroy_empty_for_testing(); receipt_a.consume_receipt(cap_b, TestCurve {}); abort } +// `destroy_empty` rejects a wallet whose object address still holds settled funds that +// have not been swept into the on-book balance (the `sweep_settled` source). +// +// TODO: un-ignore (uncomment and add `#[test, expected_failure(...)]`) once +// `accumulator::create_for_testing` ships in the published Sui mainnet framework. It also +// needs the imports `use sui::accumulator::{Self, AccumulatorRoot};` and +// `use sui::test_scenario::Scenario;`, plus the `seed_root` helper below. The unit VM may +// also need to actually settle the funds parked at the address for the gate to fire. +// +// fun seed_root(scenario: &mut Scenario, resume: address) { +// scenario.next_tx(@0x0); +// accumulator::create_for_testing(scenario.ctx()); +// scenario.next_tx(resume); +// } +// +// #[test, expected_failure(abort_code = vesting_wallet::EUnsweptFunds)] +// fun destroy_empty_rejects_unswept_settled_funds() { +// let mut scenario = test_scenario::begin(@0x1); +// seed_root(&mut scenario, @0x1); +// +// let wallet = new_wallet(BENEFICIARY, scenario.ctx()); +// // Park settled funds at the wallet's object address without sweeping them in. +// balance::send_funds(mint(1), object::id(&wallet).to_address()); +// scenario.next_tx(@0x1); +// +// let root = scenario.take_shared(); +// let _receipt = wallet.destroy_empty(&root); +// abort +// } + +// === Sweep === + +// `sweep_settled` pulls settled funds parked at the wallet's object address into its +// on-book `balance`, emits a single `Swept` (not `Deposited`), and conserves value. +// +// TODO: un-ignore once `accumulator::create_for_testing` ships in the published Sui +// mainnet framework. Shares the same harness as the commented teardown tests above: +// the `seed_root` helper, the `use sui::accumulator::{Self, AccumulatorRoot};` and +// `use sui::test_scenario::Scenario;` imports, the `Swept` import from `vesting_wallet`, +// and a VM that actually settles the funds parked at the address so `settled_funds_value` +// returns non-zero. +// +// #[test] +// fun sweep_settled_pulls_in_settled_funds_and_emits_swept() { +// let mut scenario = test_scenario::begin(@0x1); +// seed_root(&mut scenario, @0x1); +// +// let wallet = new_wallet(BENEFICIARY, scenario.ctx()); +// let wallet_id = object::id(&wallet); +// // Settle funds at the wallet's object address, then share it. +// balance::send_funds(mint(1000), wallet_id.to_address()); +// transfer::public_share_object(wallet); +// scenario.next_tx(@0x1); +// +// let mut wallet = scenario.take_shared>(); +// let root = scenario.take_shared(); +// wallet.sweep_settled(&root); +// +// assert_eq!(wallet.balance(), 1000); +// let swept = event::events_by_type>(); +// assert_eq!(swept.length(), 1); +// assert_eq!(swept[0], vesting_wallet::test_new_swept(wallet_id, 1000)); +// // A sweep emits `Swept`, never `Deposited`. +// assert_eq!(event::events_by_type>().length(), 0); +// +// test_scenario::return_shared(root); +// test_scenario::return_shared(wallet); +// scenario.end(); +// } + +// Sweeping a wallet with no settled funds at its address is a no-op: balance and +// ledger are untouched and no `Swept` event is emitted. +// +// TODO: un-ignore once `accumulator::create_for_testing` ships (same harness as above). +// +// #[test] +// fun sweep_settled_no_settled_funds_is_noop() { +// let mut scenario = test_scenario::begin(@0x1); +// seed_root(&mut scenario, @0x1); +// +// let wallet = new_wallet(BENEFICIARY, scenario.ctx()); +// transfer::public_share_object(wallet); +// scenario.next_tx(@0x1); +// +// let mut wallet = scenario.take_shared>(); +// let root = scenario.take_shared(); +// wallet.sweep_settled(&root); +// +// assert_eq!(wallet.balance(), 0); +// assert_eq!(wallet.released(), 0); +// assert_eq!(event::events_by_type>().length(), 0); +// +// test_scenario::return_shared(root); +// test_scenario::return_shared(wallet); +// scenario.end(); +// } + // === State immutability === // The beneficiary, schedule params, and id are all fixed across @@ -630,9 +744,9 @@ fun beneficiary_params_and_id_are_immutable() { let mut wallet = new_wallet(BENEFICIARY, &mut ctx); let id_at_creation = object::id(&wallet); - wallet.deposit(mint(1000, &mut ctx)); + wallet.deposit(mint(1000)); let vested = wallet.mint_vested_amount(TestCurve {}, 300); - wallet.release(&vested, &mut ctx); + wallet.release(&vested); assert_eq!(wallet.beneficiary(), BENEFICIARY); assert_eq!(wallet.schedule_params(), TestParams { tag: PARAMS_TAG }); @@ -641,34 +755,43 @@ fun beneficiary_params_and_id_are_immutable() { destroy(wallet); } -// Released coins belong to the beneficiary and no later wallet operation -// can reduce them - there is no clawback path. +// Released funds belong to the beneficiary and no later wallet operation +// can reduce them - there is no clawback path. The two payouts to the +// beneficiary are attested by their `Released` events, which together sum to +// exactly what was released. #[test] -fun released_coins_stay_with_beneficiary() { +fun released_funds_stay_with_beneficiary() { let mut scenario = test_scenario::begin(@0x1); let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); - wallet.deposit(mint(1000, scenario.ctx())); + let wallet_id = object::id(&wallet); + wallet.deposit(mint(1000)); + // First payout to the beneficiary: 400. let first = wallet.mint_vested_amount(TestCurve {}, 400); - wallet.release(&first, scenario.ctx()); + wallet.release(&first); + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, 400), + ); - // Further wallet activity in a later transaction. + // Further wallet activity in a later transaction pays the newly-vested + // remainder (900 - 400 = 500); the earlier 400 is never reduced. scenario.next_tx(@0x1); let second = wallet.mint_vested_amount(TestCurve {}, 900); - wallet.release(&second, scenario.ctx()); + wallet.release(&second); assert_eq!(wallet.released(), 900); - destroy(wallet); - // Both released coins are intact in the beneficiary's inventory; their total is - // exactly what was released, never reduced. - scenario.next_tx(BENEFICIARY); - let coin_a = scenario.take_from_sender>(); - let coin_b = scenario.take_from_sender>(); - assert_eq!(coin_a.value() + coin_b.value(), 900); + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, 500), + ); - destroy(coin_a); - destroy(coin_b); + destroy(wallet); scenario.end(); } @@ -683,19 +806,22 @@ fun beneficiary_can_be_object_address() { let object_addr = object::id_address(&placeholder); let mut wallet = new_wallet(object_addr, scenario.ctx()); - wallet.deposit(mint(1000, scenario.ctx())); + let wallet_id = object::id(&wallet); + wallet.deposit(mint(1000)); let vested = wallet.mint_vested_amount(TestCurve {}, 1000); - wallet.release(&vested, scenario.ctx()); + wallet.release(&vested); destroy(wallet); destroy(placeholder); - // The released coin landed in the object's address inventory. - scenario.next_tx(@0x1); - let coin = scenario.take_from_address>(object_addr); - assert_eq!(coin.value(), 1000); + // The payout was directed at the object's address. + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, object_addr, 1000), + ); - destroy(coin); scenario.end(); } @@ -711,24 +837,27 @@ fun release_through_third_party_wrapper() { let mut scenario = test_scenario::begin(@0xCAFE); // unrelated sender drives the wrapper let mut wallet = new_wallet(BENEFICIARY, scenario.ctx()); - wallet.deposit(mint(1000, scenario.ctx())); + let wallet_id = object::id(&wallet); + wallet.deposit(mint(1000)); let mut wrapper = wrap(wallet, scenario.ctx()); // Curve-agnostic flow: mint against `&inner`, release through the wrapper. let vested = wrapper.inner().mint_vested_amount(TestCurve {}, 400); - wrapper.release(&vested, scenario.ctx()); + wrapper.release(&vested); assert_eq!(wrapper.inner().released(), 400); assert_eq!(wrapper.inner().balance(), 600); destroy(wrapper); - // The construction-time beneficiary received the released coin. - scenario.next_tx(BENEFICIARY); - let coin = scenario.take_from_sender>(); - assert_eq!(coin.value(), 400); + // The payout went to the construction-time beneficiary, not the driver. + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, BENEFICIARY, 400), + ); - destroy(coin); scenario.end(); } @@ -742,21 +871,24 @@ fun owned_handoff_does_not_redirect_cashflow() { // Alice is the beneficiary; the wallet is funded then handed to Bob. let mut wallet = new_wallet(alice, scenario.ctx()); - wallet.deposit(mint(1000, scenario.ctx())); + let wallet_id = object::id(&wallet); + wallet.deposit(mint(1000)); transfer::public_transfer(wallet, bob); // Bob holds the wallet and pokes release. scenario.next_tx(bob); let mut wallet = scenario.take_from_sender>(); let vested = wallet.mint_vested_amount(TestCurve {}, 400); - wallet.release(&vested, scenario.ctx()); + wallet.release(&vested); destroy(wallet); // Alice - not Bob - received the funds. - scenario.next_tx(alice); - let coin = scenario.take_from_sender>(); - assert_eq!(coin.value(), 400); + let released = event::events_by_type>(); + assert_eq!(released.length(), 1); + assert_eq!( + released[0], + vesting_wallet::test_new_released(wallet_id, alice, 400), + ); - destroy(coin); scenario.end(); }