diff --git a/contracts/finance/README.md b/contracts/finance/README.md index 3e127779..eaea08bd 100644 --- a/contracts/finance/README.md +++ b/contracts/finance/README.md @@ -202,6 +202,16 @@ If `release` instead required the witness `S`, this would be impossible: a wrapp that doesn't own `S` could not call it, so it would have to expose `&mut inner` and lose all control over deposits and releases. +**Funding a wrapped wallet.** `release` is the only *outflow*, so it is the only entry +point a wrapper must gate. The inflow entry points - `deposit`, `receive_and_deposit`, +and `sweep_settled` - are permissionless, witness-free, and can only add funds for the +beneficiary, so a wrapper can safely re-expose them ungated, each just delegating to the +private `&mut inner`. Do so whenever the wrapper can be funded after wrapping, and +especially by address: anyone can `public_transfer` a `Coin` or settle a `Balance` +to the inner wallet's object address without the wrapper's cooperation, yet only +`&mut inner` can draw it in (`receive_and_deposit` / `sweep_settled`). A wrapper that omits +those passthroughs leaves such funds unclaimable until the wallet is unwrapped. + **Constructing the wallet.** The wrapper either accepts an already-built `VestingWallet` from the caller, or builds one itself. To build it without depending on a curve's `new`, take a validated `P` from the curve module's `params` @@ -293,6 +303,14 @@ one per integration boundary described above: coin would push the lifetime total (`balance + released`) past `u64::MAX` the call aborts, leaving the already-transferred coin parked at the wallet address. High volume emitters should track headroom before transferring. +- **A wrapper must pass through inflow to accept address-targeted funding.** A + curve-agnostic wrapper that nests a wallet and keeps `&mut inner` private can only claim + funds sent to the inner wallet's address - `receive_and_deposit` for a `Coin`, + `sweep_settled` for a settled `Balance` - if it re-exposes those entry points. + Without them, funds a third party sends to the address stay unclaimable until the wallet + is unwrapped (claim before teardown; funds left at a destroyed wallet's address are + lost). The inflow passthroughs are safe to expose - they only add funds - so a wrapper + that can be funded by address should include them. ## Learn More diff --git a/contracts/finance/examples/vesting_wallet/pausable_grant.move b/contracts/finance/examples/vesting_wallet/pausable_grant.move index 67a9f821..ec529586 100644 --- a/contracts/finance/examples/vesting_wallet/pausable_grant.move +++ b/contracts/finance/examples/vesting_wallet/pausable_grant.move @@ -36,9 +36,21 @@ /// witness-gated `destroy`) against it. See the tests for the end-to-end flow. /// /// The wallet must be funded *before* it is wrapped, since `new` consumes it and the -/// wrapper never re-exposes `&mut inner`. Re-enabling top-ups would mean adding a -/// `deposit` passthrough (safe, as deposit is permissionless); it is left out here to -/// keep the surface minimal. +/// wrapper never re-exposes `&mut inner`. Re-enabling funding would mean adding +/// passthroughs for the wallet's inflow entry points (`deposit`, `receive_and_deposit`, +/// `sweep_settled`); all three are safe to expose ungated - permissionless, witness-free, +/// and inflow-only, so they can only add funds for the beneficiary. They are left out +/// here to keep the surface minimal, which is safe only because this grant is funded once +/// up front and is not meant to be funded by address. +/// +/// Note the asymmetry: omitting `deposit` merely blocks top-ups (a would-be funder just +/// keeps their coin), but omitting `receive_and_deposit`/`sweep_settled` is a liveness +/// hazard. Anyone can `public_transfer` a `Coin` (or settle a `Balance`) to the +/// inner wallet's object address without the wrapper's cooperation, and only `&mut inner` +/// can claim it - so with no passthrough those funds are unclaimable until `unwrap` +/// restores `&mut` (claim them *before* any teardown; a coin left at a destroyed wallet's +/// address is stranded for good). A wrapper that expects address-targeted funding should +/// expose these passthroughs. /// /// # Disclaimer /// diff --git a/contracts/finance/sources/vesting_wallet.move b/contracts/finance/sources/vesting_wallet.move index bd057ba4..63045105 100644 --- a/contracts/finance/sources/vesting_wallet.move +++ b/contracts/finance/sources/vesting_wallet.move @@ -371,6 +371,10 @@ public fun deposit( /// A wallet with no settled funds at its address is a no-op: nothing is swept and /// no `Swept` event is emitted. /// +/// Requires `&mut wallet`: a wrapper that nests the wallet and keeps `&mut inner` private +/// must re-expose this entry point, or settled funds parked at the address stay unswept +/// until the wallet is unwrapped. +/// /// #### Parameters /// - `wallet`: The wallet to sweep into. /// - `root`: The shared `AccumulatorRoot`, read to find the wallet's settled funds. @@ -396,6 +400,10 @@ public fun sweep_settled( /// object address, then add it to the balance. Used by emission schedules and /// payroll robots that don't hold a wallet reference. /// +/// Requires `&mut wallet`: a wrapper that nests the wallet and keeps `&mut inner` private +/// must re-expose this entry point, or coins parked at the address stay unclaimable until +/// the wallet is unwrapped. +/// /// 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. ///