Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions contracts/finance/Move.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
78 changes: 38 additions & 40 deletions contracts/finance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<C>`. Permissionless: anyone may fund, and funds added
after the schedule starts participate retroactively.
2. **Fund** - `deposit` a `Balance<C>`, claim an addressed `Coin<C>` 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<C>`, 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<C>` 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

Expand All @@ -94,7 +94,7 @@ public fun grant<C>(beneficiary: address, start_ms: u64, funds: Coin<C>, ctx: &m
4, // steps
ctx,
);
wallet.deposit(funds);
wallet.deposit(funds.into_balance());
transfer::public_share_object(wallet);
transfer::public_transfer(cap, beneficiary);
}
Expand All @@ -112,8 +112,8 @@ public fun stream<C>(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<C>(wallet: &mut VestingWallet<Linear, Params, C>, clock: &Clock, ctx: &mut TxContext) {
vesting_wallet_linear::release(wallet, clock, ctx);
public fun claim<C>(wallet: &mut VestingWallet<Linear, Params, C>, clock: &Clock) {
vesting_wallet_linear::release(wallet, clock);
}

// A read-only "what can I claim?" query for clients.
Expand All @@ -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<C>`, 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<C>` 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<C>` 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<C>` 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.
Expand Down Expand Up @@ -207,10 +205,9 @@ public fun inner<S: drop, P: copy + drop + store, C>(
public fun release<S: drop, P: copy + drop + store, C>(
self: &mut GatedVault<S, P, C>,
vested: &VestedAmount<S>,
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
Expand All @@ -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
Expand Down Expand Up @@ -265,14 +262,14 @@ To author a new curve, follow the `vesting_wallet_linear` pattern:
through `new`.
3. A `vested_amount(&VestingWallet<MyCurve, MyParams, C>, &Clock): VestedAmount<MyCurve>`
that evaluates the curve and ends in `wallet.mint_vested_amount(MyCurve {}, amount)`.
4. A teardown that calls `wallet.destroy_empty()` for a `DestroyReceipt<MyCurve,
4. A teardown that calls `wallet.destroy_empty(root)` for a `DestroyReceipt<MyCurve,
MyParams>`, 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:
Expand Down Expand Up @@ -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<Coin<C>>` 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

Expand Down
11 changes: 6 additions & 5 deletions contracts/finance/examples/vesting_wallet/pausable_grant.move
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -113,10 +115,9 @@ public fun inner<S: drop, P: copy + drop + store, C>(
public fun release<S: drop, P: copy + drop + store, C>(
self: &mut PausableGrant<S, P, C>,
vested: &VestedAmount<S>,
ctx: &mut TxContext,
) {
assert!(!self.paused, EPaused);
self.inner.release(vested, ctx);
self.inner.release(vested);
}

/// Freeze releases. Idempotent.
Expand Down Expand Up @@ -147,7 +148,7 @@ public fun resume<S: drop, P: copy + drop + store, C>(
/// 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).
///
Expand Down
Loading
Loading