diff --git a/content/contracts-sui/1.x/allowance.mdx b/content/contracts-sui/1.x/allowance.mdx
new file mode 100644
index 00000000..9c11f836
--- /dev/null
+++ b/content/contracts-sui/1.x/allowance.mdx
@@ -0,0 +1,52 @@
+---
+title: Allowance
+---
+
+
+The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package.
+
+
+The `openzeppelin_allowance` package provides cap-keyed, multi-coin spending allowances for Sui Move protocols. A shared, untyped `Vault` escrows funds for any number of coin types, an `OwnerCap` controls the pool and the budgets, and each `SpenderCap` draws against per-`(cap, coin)` budgets with optional expiry.
+
+Use this package when a protocol or keeper custodies a user's `SpenderCap` and spends on their behalf within the owner's budget, without the user signing each spend; the same primitive also covers subscription pulls and per-partner budgets across coin types.
+
+## Usage
+
+There are two ways to pull the package into your project. Pick whichever fits how much you want to own the code.
+
+### Git dependency
+
+Add the dependency in `Move.toml`, pinned to a git revision (the package is not yet on the Move Registry):
+
+```toml
+[dependencies]
+openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.4.0" }
+```
+
+### Copy the source
+
+Alternatively, copy [`spend_vault.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.4.0/contracts/allowance/sources/spend_vault.move) directly into your package's `sources/` (renaming the module addresses to your own package). You then fully own the code, free to trim, fork, or extend, at the cost of tracking upstream fixes yourself.
+
+### Import
+
+Once the package is available, import the module:
+
+```move
+use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap};
+```
+
+## Modules
+
+
+
+ Cap-keyed, multi-coin allowance ledger over an escrowed shared vault, with per-`(cap, coin)` budgets, expiries, suspension, revocation, and unconditional owner exit.
+
+
+
+## Next steps
+
+- [Spend Vault](/contracts-sui/1.x/spend-vault) for the module guide and key concepts.
+- [Integrating into a protocol](/contracts-sui/1.x/spend-vault#integrating-into-a-protocol) to custody a user's cap and spend on their behalf under the owner's budget.
+- [Security considerations](/contracts-sui/1.x/spend-vault#security-considerations) for bearer-cap custody, budget-ceiling, and teardown caveats.
+- [Allowance API reference](/contracts-sui/1.x/api/allowance) for function signatures, events, and errors.
+- [Delegated Spending](/contracts-sui/1.x/guides/delegated-spending) for the end-to-end tutorial.
diff --git a/content/contracts-sui/1.x/api/allowance.mdx b/content/contracts-sui/1.x/api/allowance.mdx
new file mode 100644
index 00000000..8f8107af
--- /dev/null
+++ b/content/contracts-sui/1.x/api/allowance.mdx
@@ -0,0 +1,615 @@
+---
+title: Allowance API Reference
+---
+
+This page documents the public API of `openzeppelin_allowance` for OpenZeppelin Contracts for Sui `v1.x`.
+
+### `spend_vault` [toc] [#spend_vault]
+
+
+
+```move
+use openzeppelin_allowance::spend_vault;
+```
+
+Cap-keyed, multi-coin allowance primitive. A shared, untyped `Vault` holds funds for any number of coin types and a ledger of per-`(cap, coin)` budgets. The funds are not a struct field: each coin type's pool lives as an object-owned address balance at the vault's id address, deposited permissionlessly and drawn through `spend` (spender) or `withdraw`/`withdraw_all` (owner). The coin dimension of every grant is the `T` supplied at the call site, resolved against a `BudgetKey { cap_id, coin_type }` ledger key.
+
+Because `Vault` has `key` only (no `drop`), the transaction that calls `new` must consume the fresh vault: `share` it as a shared object or `destroy` it, with all same-PTB funding, minting, and granting preceding `share`. A shared vault can still be `destroy`ed later, so the full shape is `new -> destroy` (abandoned at creation) or `new -> share -> ... -> destroy` (shared, then torn down). One `OwnerCap` exists per vault for its whole life (`new` mints it, `destroy` consumes it); transferring it is owner rotation. Budgets are ceilings, not reservations: grants may sum over the pool, and competing spenders are resolved by consensus sequencing.
+
+
+`SpenderCap` is a bearer instrument: `spend` is cap-gated, never sender-gated, so whoever presents the cap draws against every `(cap, coin)` budget it keys; any custody layer must add its own sender gate before borrowing the cap. `destroy` does not drain the pool: it deletes the ledger and both UIDs, and any funds still in the vault's address balances strand permanently. For the full validate-then-sender-gate custody recipe, see the module page's [Integrating into a protocol](/contracts-sui/1.x/spend-vault#integrating-into-a-protocol) section and the [Delegated Spending tutorial](/contracts-sui/1.x/guides/delegated-spending).
+
+
+Types
+
+- [`Vault`](#spend_vault-Vault)
+- [`BudgetKey`](#spend_vault-BudgetKey)
+- [`OwnerCap`](#spend_vault-OwnerCap)
+- [`SpenderCap`](#spend_vault-SpenderCap)
+- [`Allowance`](#spend_vault-Allowance)
+
+Functions
+
+- [`new(ctx)`](#spend_vault-new)
+- [`share(v)`](#spend_vault-share)
+- [`destroy(v, cap, ctx)`](#spend_vault-destroy)
+- [`deposit(v, c, ctx)`](#spend_vault-deposit)
+- [`deposit_balance(v, b, ctx)`](#spend_vault-deposit_balance)
+- [`mint_cap(v, cap, ctx)`](#spend_vault-mint_cap)
+- [`set_allowance(v, cap, cap_id, new_amount, new_expires_at_ms, expected, clock, ctx)`](#spend_vault-set_allowance)
+- [`spend(v, cap, amount, clock, ctx)`](#spend_vault-spend)
+- [`revoke(v, cap, cap_id, ctx)`](#spend_vault-revoke)
+- [`revoke_all(v, cap, cap_id, ctx)`](#spend_vault-revoke_all)
+- [`renounce(v, cap, ctx)`](#spend_vault-renounce)
+- [`delete_orphaned_cap(cap)`](#spend_vault-delete_orphaned_cap)
+- [`squash(v, c, ctx)`](#spend_vault-squash)
+- [`withdraw(v, cap, amount, ctx)`](#spend_vault-withdraw)
+- [`withdraw_all(v, cap, root, ctx)`](#spend_vault-withdraw_all)
+- [`allowance(v, cap_id)`](#spend_vault-allowance)
+- [`spendable_now(v, root, cap_id, clock)`](#spend_vault-spendable_now)
+- [`expiry(v, cap_id)`](#spend_vault-expiry)
+- [`contains(v, cap_id)`](#spend_vault-contains)
+- [`balance_value(v, root)`](#spend_vault-balance_value)
+- [`granted_coin_types(v)`](#spend_vault-granted_coin_types)
+- [`owner_cap_vault_id(cap)`](#spend_vault-owner_cap_vault_id)
+- [`spender_cap_vault_id(cap)`](#spend_vault-spender_cap_vault_id)
+
+Events
+
+- [`VaultCreated`](#spend_vault-VaultCreated)
+- [`Deposited`](#spend_vault-Deposited)
+- [`Squashed`](#spend_vault-Squashed)
+- [`SpenderCapMinted`](#spend_vault-SpenderCapMinted)
+- [`AllowanceSet`](#spend_vault-AllowanceSet)
+- [`Spent`](#spend_vault-Spent)
+- [`Revoked`](#spend_vault-Revoked)
+- [`Renounced`](#spend_vault-Renounced)
+- [`Withdrawn`](#spend_vault-Withdrawn)
+- [`VaultDestroyed`](#spend_vault-VaultDestroyed)
+- [`CapDeleted`](#spend_vault-CapDeleted)
+
+Errors
+
+- [`EWrongOwnerCap`](#spend_vault-EWrongOwnerCap)
+- [`EWrongVault`](#spend_vault-EWrongVault)
+- [`ENoAllowance`](#spend_vault-ENoAllowance)
+- [`EAllowanceExpired`](#spend_vault-EAllowanceExpired)
+- [`EAllowanceExceeded`](#spend_vault-EAllowanceExceeded)
+- [`EZeroAmount`](#spend_vault-EZeroAmount)
+- [`EExpiryInPast`](#spend_vault-EExpiryInPast)
+- [`EUnexpectedAllowance`](#spend_vault-EUnexpectedAllowance)
+
+#### Types [!toc] [#spend_vault-Types]
+
+
+Shared, untyped escrow plus per-`(cap, coin)` allowance ledger. One vault holds any number of coin types. The creating transaction must consume the fresh vault with `share` or `destroy`, and a shared vault can still be `destroy`ed later at teardown; the `key`-only ability set (no `store`, no `drop`) protects `id` (the spend authority over the address balances) and forces teardown through `destroy`.
+
+The pool is not a struct field: per-coin funds live as object-owned address balances at the vault's id address, readable via [`balance_value`](#spend_vault-balance_value). `allowances` is a `LinkedTable` so drains recover per-entry storage rebates.
+
+`granted_coin_types` is the owner-writable enumeration handle iterated by `revoke_all` and `renounce`. It is written only by a `set_allowance` call that creates an entry (permissionless deposits cannot grow it) and it grows only: `revoke` never prunes `T`. It is not the drain-before-destroy list; enumerate pool coin types off-chain with the GraphQL `balances` query.
+
+
+
+Composite ledger key: one entry per `(cap, coin type)`. `coin_type` is always `type_name::with_defining_ids()`.
+
+
+
+Owner authority for exactly one vault. Exactly one `OwnerCap` exists per vault for its whole life: `new` mints it, `destroy` consumes it. `vault_id` is set at `new` and never rewritten; transferring the cap is owner rotation. It gates `mint_cap`, `set_allowance`, `revoke`, `revoke_all`, `withdraw`/`withdraw_all` over every coin, and `destroy`.
+
+
+
+Spend authority. A bearer instrument: whoever presents `&SpenderCap` to `spend` holds the full authority of every `(cap, coin)` budget it keys, so a leaked cap exposes the sum across coins. Untyped: no phantom type, no coin field; the coin dimension is the `T` at the `spend` call site.
+
+`vault_id` is set at `mint_cap` and never rewritten; the binding survives every transfer, wrap, or table embedding. Protocols accepting a user's cap must validate it via [`spender_cap_vault_id`](#spend_vault-spender_cap_vault_id) before taking custody.
+
+
+
+Private ledger entry for one `(cap, coin)` grant, and the single source of truth for its state (the cap carries no budget).
+
+`remaining`: `u64::MAX` is the unlimited sentinel (never decremented); `0` is a live-but-suspended entry; anything else is the raw drawable budget. `expires_at_ms`: `u64::MAX` is the no-expiry sentinel; any finite value must be strictly in the future at `set_allowance` time. Both sentinels are tested by equality only: a deliberate finite grant of exactly `u64::MAX` is unrepresentable, and off-chain volume math must exclude it.
+
+
+#### Functions [!toc] [#spend_vault-Functions]
+
+
+Creates an untyped multi-coin `Vault` and its sole vault-bound `OwnerCap`, both returned by value so the enclosing PTB decides every destination. `Vault` has no `drop`: the transaction fails unless it is consumed by `share` or `destroy` in the same transaction. One PTB can compose full setup: `new` → `deposit` → `mint_cap` → `set_allowance` → `share` → transfer the owner cap.
+
+Emits `VaultCreated` with the vault id, owner cap id, and `ctx.sender()` as `creator`.
+
+
+
+Shares the vault (`transfer::share_object`). Must run in the same transaction as `new`, after every same-PTB funding, minting, and granting step; the vault is only addressable as a shared input in later transactions.
+
+
+
+Terminal owner exit. Drains every ledger entry (recovering each storage rebate), drops `granted_coin_types`, and deletes both the vault and owner cap UIDs. Unconditional on ledger state: no live, suspended, or unlimited grant can block it, and there is no on-chain guard against destroying a still-funded vault.
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Emits `VaultDestroyed`.
+
+
+`destroy` does not drain the pool. Funds still sitting in the vault's address balances strand permanently once the UID is deleted. Drain first, in a prior transaction: enumerate coin types off-chain with the GraphQL `balances` query, `squash` any loose coins, `withdraw_all` each type, wait one checkpoint and re-check, then destroy. See the [Spend Vault guide](/contracts-sui/1.x/spend-vault) for the full drain ritual.
+
+
+
+
+Deposits a `Coin` into the vault's `T` pool. Thin wrapper over `deposit_balance` (`c.into_balance()`). Permissionless, and confers no rights: no ledger entry, no claim, no refund path. Note that permissionless deposits can re-arm live allowances after a `withdraw_all`-as-freeze; the durable kill is `revoke_all` or `destroy`.
+
+Aborts with `EZeroAmount` if `c.value() == 0`.
+
+Emits `Deposited`.
+
+
+
+`Balance`-native ingress, symmetric to the `Balance` egress of `spend`/`withdraw`/`withdraw_all`; sends the balance to the vault's id address. Permissionless and rights-free, like `deposit`.
+
+Aborts with `EZeroAmount` if `b.value() == 0`.
+
+Emits `Deposited` with `ctx.sender()` as `depositor`.
+
+
+Anyone can also fund the vault without this module via `sui::balance::send_funds(bal, object::id_address(v))`. Those funds are identically spendable and withdrawable but emit no `Deposited` event; event-only indexers will miss them.
+
+
+
+
+Mints a bare, untyped `SpenderCap` bound to this vault, returned by value: no budget, no ledger entry, no coin type. Grant budgets afterwards with `set_allowance` keyed by the cap's object id; the id is stable for the cap's whole life, so protocols can embed the cap and owners can re-budget it without re-registration.
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Emits `SpenderCapMinted`.
+
+
+
+Upserts the `(cap_id, T)` budget: creates the entry if absent (recording `T` in `granted_coin_types`), otherwise overwrites `remaining` and `expires_at_ms` in place; the cap object, its id, and any embeddings are untouched. Re-setting overwrites, never adds; two summing budgets require two caps.
+
+`new_amount == 0` suspends the entry without removing it (never aborts `EZeroAmount`); `u64::MAX` is the unlimited sentinel. `new_expires_at_ms` must be `u64::MAX` (no expiry) or strictly in the future; setting a fresh future expiry on an expired entry revives it in place.
+
+`expected` is an optional compare-and-set guard on the raw `remaining`: `Some(e)` proceeds only if the entry exists and `remaining == e` (`0` and `u64::MAX` are legal match targets); `Some` on an absent entry aborts; `None` is unconditional. Derive `e` from an earlier read (off-chain or a previous transaction) so a spend sequenced after that read aborts this write instead of being clobbered. A read in the same transaction cannot serve as a guard: the shared vault is locked for the whole transaction, so a same-transaction `allowance` value trivially matches and the write always proceeds. The CAS guards `remaining` only; the upsert always overwrites `expires_at_ms` too, so a budget-only update must re-read the current expiry via `expiry` and pass it back, or it silently overwrites it.
+
+Checks run in deterministic order: owner gate → expiry validity → CAS.
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Aborts with `EExpiryInPast` if `new_expires_at_ms` is finite and `<= clock.timestamp_ms()`.
+
+Aborts with `EUnexpectedAllowance` if `expected` is `Some(e)` and the entry is absent or its raw `remaining != e`.
+
+Emits `AllowanceSet` with `cas_was_provided` and `was_created` flags.
+
+
+`cap_id` is an unvalidated bare `ID`: it is never checked against a live `SpenderCap`. A mistyped id silently creates a phantom budget (`AllowanceSet { was_created: true }`, success-shaped) and permanently adds `T` to `granted_coin_types` if `T` was not already granted. On a known update, confirm `was_created == false` in the event, or preflight with `contains`.
+
+
+
+
+Draws exactly `amount` of `T` against the presented `&SpenderCap`. Cap-gated, never sender-gated: any transaction context spends identically, and `ctx.sender()` feeds only the `Spent.caller` attribution field. Exact-amount-or-abort: on any abort the pool and ledger are bit-identical to the pre-call state. The `u64::MAX` unlimited sentinel is never decremented. Spending a budget down to `0` leaves the entry in place (suspended); removal happens only via `revoke`, `revoke_all`, `renounce`, or `destroy`.
+
+The commit order is: decrement the budget, then withdraw from the vault's address balance and redeem. The pool is deliberately not pre-checked: a pool-short redeem fails at execution (see the callout below) and Move's atomic revert rolls the decrement back.
+
+The module abort order below is deterministic and is an integrator ABI; match on it: `EWrongVault` → `ENoAllowance` → `EAllowanceExpired` → `EZeroAmount` → `EAllowanceExceeded`.
+
+The returned `Balance` has no `drop`; consume it in the same PTB:
+
+```move
+let bal = vault.spend(&cap, amount, clock, ctx);
+transfer::public_transfer(bal.into_coin(ctx), ctx.sender());
+```
+
+Aborts with `EWrongVault` if `cap.vault_id` does not match the vault.
+
+Aborts with `ENoAllowance` if no `(cap, T)` ledger entry exists.
+
+Aborts with `EAllowanceExpired` if the entry has a finite expiry and `clock.timestamp_ms() >= expires_at_ms` (closed boundary: a spend in the exact expiry millisecond fails).
+
+Aborts with `EZeroAmount` if `amount == 0`.
+
+Aborts with `EAllowanceExceeded` if `remaining` is finite and `amount > remaining` (including suspended-at-zero entries, for any positive amount).
+
+Emits `Spent` strictly after the redeem succeeds, with `remaining` as the raw post-call value (still `u64::MAX` for unlimited grants).
+
+
+Two framework failure modes follow the module aborts: `EObjectFundsWithdrawNotEnabled` (Move abort code 3 in `sui::funds_accumulator`) if the `enable_object_funds_withdraw` protocol feature is off, and the `InsufficientFundsForWithdraw` execution status when the vault's live `T` balance is below `amount` at redeem time. The latter is not a matchable Move abort code; detect it via dry run / effects. The budget decrement is rolled back atomically, and `Spent` is emitted only after the redeem succeeds.
+
+
+
+
+Owner kill-switch for one coin: removes the `(cap_id, T)` ledger entry. Idempotent and ledger-state-independent: a present entry is removed (recovering its storage rebate), an absent one is a no-op. Also time-blind: it removes suspended, expired, and unlimited entries alike. Returns `was_present`; `false` is the typo'd-`cap_id` / wrong-coin signal, never an abort. Not retroactive: a spend sequenced first still stands. The cap object survives as inert non-authority for this coin, and `granted_coin_types` is untouched (grows-only).
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Emits `Revoked` on every non-aborting call, no-op included, with the `was_present` flag.
+
+
+
+Whole-cap kill: iterates a snapshot of `granted_coin_types` and removes every existing `(cap_id, T)` entry. Cost is O(k) in the distinct coin types ever granted on the vault: owner-bounded and un-griefable, since permissionless deposits and squashes cannot grow the set. Never touches another cap's entries.
+
+Emergency-stop idiom: run `revoke_all` first, in its own transaction, then `withdraw_all` per coin in a later transaction; never bundled, since a pool-short `withdraw_all` would revert the `revoke_all` with it.
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Emits one `Revoked { was_present: true }` per removed coin; nothing on a whole-cap miss.
+
+
+`cap_id` is unvalidated: a wrong id is a silent whole-cap no-op that emits no event and leaves the intended cap live. Confirm the kill via the emitted `Revoked` events or a `contains` recheck.
+
+
+
+
+Spender self-revoke against a live vault: consumes the cap by value, removes every `(cap_id, T)` entry (iterating a snapshot of `granted_coin_types`; coins the cap never held are harmless probes), and deletes the cap UID. Total on ledger state; storage rebates route to this transaction's gas payer. Uncallable after vault destruction (it needs `&mut Vault`); use `delete_orphaned_cap` instead.
+
+Aborts with `EWrongVault` if `cap.vault_id` does not match the vault.
+
+Emits one coin-agnostic `Renounced` event (not per-coin `Revoked` events).
+
+
+
+Disposal path for a `SpenderCap` whose vault was already destroyed. Total: never aborts, touches no vault state, deletes exactly the cap UID. The lone `ctx`-free disposal path.
+
+Emits `CapDeleted` (no actor field; there is no `ctx` to read).
+
+
+On a live vault, prefer `renounce`. Deleting a live cap strands all its `(cap, T)` ledger entries as inert garbage: unspendable and not live authority, but still visible via `contains` and forfeiting their storage rebates. The owner's cleanup is `revoke_all` with the `cap_id` from the `CapDeleted` event.
+
+
+
+
+Recovers a stray `Coin` that was `public_transfer`'d to the vault address (a loose owned object, not part of the spendable address balance) by receiving it against the vault's UID and sending its balance into the pool. Permissionless and strictly funds-in: worst case is a donation. Writes no type set. This is the vault's only object-receive path, and it is `Coin`-typed: non-`Coin` objects sent to the vault address cannot be recovered.
+
+Aborts with no module error; the framework `transfer::public_receive` can abort on an invalid or stale `Receiving` ticket. There is no zero-amount guard.
+
+Emits `Squashed` (possibly with `amount: 0`).
+
+
+
+Withdraws exactly `amount` of `T` from the pool as a `Balance`. Consults only the `OwnerCap` binding and the pool, never the ledger: no spender state can block it, and it may leave live allowances unbacked (intended: budgets are ceilings, not reservations). The returned `Balance` has no `drop` and must be consumed in the same PTB.
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Aborts with `EZeroAmount` if `amount == 0`.
+
+Emits `Withdrawn`.
+
+
+The same two framework failure modes as `spend` apply: `EObjectFundsWithdrawNotEnabled` if the `enable_object_funds_withdraw` protocol feature is off, and the `InsufficientFundsForWithdraw` execution status (not a matchable Move abort) if the live `T` balance is below `amount` at redeem time.
+
+
+
+
+Drains the settled `T` pool: reads the start-of-checkpoint balance snapshot from the accumulator root (`0xacc`) and withdraws exactly that. Deliberately takes no caller-supplied amount (a fixed amount would be a stale-amount denial of service). An empty settled pool returns `balance::zero()` without touching the accumulator primitive (the feature-flag abort is unreachable on that path) but still emits `Withdrawn { amount: 0 }`. Never aborts on spender or ledger state. The returned, possibly-zero `Balance` has no `drop`; consume it (`destroy_zero`, join, `into_coin`) in the same PTB.
+
+Settled-vs-live skew: a prior same-checkpoint `spend` or `withdraw` (including an earlier command in the same PTB) lowers the live pool below the snapshot, making the drain over-ask and fail with the `InsufficientFundsForWithdraw` execution status (retry-safe next checkpoint); a same-checkpoint deposit is under-drained (missed). A `withdraw_all`-as-freeze is reversible (deposits are permissionless), so the durable kill is `revoke_all` or `destroy`.
+
+Aborts with `EWrongOwnerCap` if `cap.vault_id` does not match the vault.
+
+Emits `Withdrawn` (amount possibly `0`).
+
+
+On a non-empty settled pool the framework failure modes apply: `EObjectFundsWithdrawNotEnabled` if the `enable_object_funds_withdraw` protocol feature is off, and the `InsufficientFundsForWithdraw` execution status on the settled-vs-live skew described above.
+
+
+
+
+Returns the raw `remaining` of the `(cap_id, T)` entry. Total (never aborts) and advisory (stale the moment a later transaction mutates state). Returns `0` if the entry is absent (ambiguous with a suspended entry); disambiguate with `contains`. `u64::MAX` is the unlimited sentinel, not a volume.
+
+
+
+Returns what a `spend` could draw right now: `0` if the entry is absent, expired, or suspended, otherwise `min(remaining, settled pool)` (unlimited reduces to the settled pool). Uses the same closed expiry boundary as `spend` (`now >= expires_at_ms` is expired). Total; advisory upper bound only: settled-vs-live skew can still make `spend(spendable_now(...))` fail, and a `0` result fed to `spend` aborts `EZeroAmount`, so guard with `> 0`. Returns a non-zero quote even when the `enable_object_funds_withdraw` feature is off.
+
+
+
+Returns the raw `expires_at_ms` of the `(cap_id, T)` entry; `0` if absent. A present entry's value is always non-zero: a timestamp (possibly past; expired entries are not pruned) or the `u64::MAX` no-expiry sentinel. Total; never aborts.
+
+
+
+Returns whether a `(cap_id, T)` ledger entry exists. The absent-vs-suspended disambiguator: `allowance == 0 && contains` means suspended. Total; never aborts.
+
+
+
+Returns the settled `T` pool at the vault's address (start-of-checkpoint snapshot). Total and advisory; independent of the `enable_object_funds_withdraw` feature flag.
+
+
+
+Returns exactly the coin-type set that `revoke_all` and `renounce` iterate. Grows-only, never pruned, and not the drain-before-destroy list (use the GraphQL `balances` query off-chain for that). Total; never aborts.
+
+
+
+Returns the vault id the `OwnerCap` is bound to. Total; never aborts.
+
+
+
+Returns the vault id the `SpenderCap` is bound to. Protocols accepting a user's cap must check this against the expected vault before taking custody. Total; never aborts. For the assembled validate-then-sender-gate sequence this check belongs to, see the module page's [Integrating into a protocol](/contracts-sui/1.x/spend-vault#integrating-into-a-protocol) section.
+
+
+#### Events [!toc] [#spend_vault-Events]
+
+
+Emitted by `new`.
+
+- `vault_id`: the new vault.
+- `owner_cap_id`: the sole owner cap, the vault-to-cap discovery anchor.
+- `creator`: `ctx.sender()`; may differ from the eventual owner.
+
+
+
+Emitted by `deposit` and `deposit_balance`.
+
+- `vault_id`: the funded vault.
+- `coin_type`: `type_name::with_defining_ids()`.
+- `amount`: the deposited value (never `0`; zero deposits abort).
+- `depositor`: `ctx.sender()`; attribution only, depositing confers no rights.
+
+Raw `send_funds` top-ups to the vault address are identically spendable but emit no `Deposited`; event-only indexers miss them.
+
+
+
+Emitted by `squash`. Distinct from `Deposited` so indexers can separate recovered strays from intentional funding.
+
+- `vault_id`: the vault that received the recovered funds.
+- `coin_type`: the recovered coin's type.
+- `amount`: the recovered value; may be `0` (`squash` has no zero-amount guard).
+- `by`: `ctx.sender()`.
+
+
+
+Emitted by `mint_cap`. Bare: no recipient, amount, or expiry; budget data arrives on the subsequent `AllowanceSet { was_created: true }`.
+
+- `vault_id`: the vault the cap is bound to.
+- `cap_id`: the new cap's object id.
+- `by`: `ctx.sender()`.
+
+
+
+Emitted by `set_allowance`.
+
+- `vault_id`, `cap_id`, `coin_type`: the `(cap, coin)` entry written.
+- `new_amount`: the written budget; `0` signals suspension, `u64::MAX` unlimited.
+- `new_expires_at_ms`: the written expiry; `u64::MAX` means no expiry.
+- `cas_was_provided`: whether the CAS guard was engaged.
+- `was_created`: `true` on the create branch, `false` on overwrite (the defense against silent phantom-budget creation from a typo'd `cap_id`).
+- `by`: `ctx.sender()`.
+
+
+
+Emitted on every successful `spend`, strictly after the funds redeem succeeds; a decremented-then-reverted pool-short spend emits nothing.
+
+- `vault_id`, `cap_id`, `coin_type`: the drawn `(cap, coin)` entry.
+- `amount`: the exact amount drawn.
+- `remaining`: the raw post-call budget; stays `u64::MAX` for unlimited grants.
+- `caller`: `ctx.sender()`; attribution only, never a gate. In a custody flow this is the operator who drove the spend, not the budget's beneficiary.
+
+
+
+Emitted by `revoke` on every non-aborting call (no-op included, with `was_present: false` as the typo'd-`cap_id` signal) and by `revoke_all` once per removed coin (a whole-cap miss emits nothing).
+
+- `vault_id`, `cap_id`, `coin_type`: the targeted `(cap, coin)` entry.
+- `was_present`: whether an entry was actually removed. Indexers must gate state changes on `was_present == true`.
+- `by`: `ctx.sender()`.
+
+
+
+Emitted by `renounce`. Coin-agnostic and terminal: it closes every `(cap, *)` entry but carries no coin list; indexers rely on previously indexed `AllowanceSet` state.
+
+- `vault_id`: the vault.
+- `cap_id`: the consumed cap.
+- `by`: `ctx.sender()`.
+
+
+
+Emitted by both `withdraw` and `withdraw_all`, with no discriminant between the two sources.
+
+- `vault_id`: the drained vault.
+- `coin_type`: the withdrawn coin's type.
+- `amount`: the withdrawn value; may be `0` from `withdraw_all` on an empty settled pool.
+- `by`: `ctx.sender()`.
+
+
+
+Emitted by `destroy`. Coin-agnostic terminal event for every `(vault, *)` entry; carries no refunded amount; the owner drains per coin beforehand.
+
+- `vault_id`: the destroyed vault.
+- `by`: `ctx.sender()`.
+
+
+
+Emitted by `delete_orphaned_cap`. Intentionally has no actor field: it is the lone `ctx`-free disposal path, so there is no `ctx.sender()` to record.
+
+- `vault_id`: the (typically already destroyed) vault the cap was bound to.
+- `cap_id`: the deleted cap, the id an owner needs for `revoke_all` cleanup if a live cap was deleted.
+
+
+#### Errors [!toc] [#spend_vault-Errors]
+
+Pool shortfall has no module error by design: on the fund-egress paths (`spend`, `withdraw`, non-empty `withdraw_all`) a short pool surfaces as the framework `InsufficientFundsForWithdraw` execution status (not a matchable Move abort) after the `EObjectFundsWithdrawNotEnabled` feature-flag check. See the callouts under [`spend`](#spend_vault-spend).
+
+
+Raised as the first check of every owner-gated function (`destroy`, `mint_cap`, `set_allowance`, `revoke`, `revoke_all`, `withdraw`, `withdraw_all`) when the `OwnerCap`'s `vault_id` does not match the vault.
+
+
+
+Raised as the first check in `spend` and `renounce` when the `SpenderCap`'s `vault_id` does not match the vault.
+
+
+
+Raised only by `spend` when no `(cap, T)` ledger entry exists (never granted, revoked, renounced, or a different coin type). `set_allowance` is an upsert and never raises it. Distinct from suspended-at-zero, which raises `EAllowanceExceeded`; disambiguate with `contains`.
+
+
+
+Raised by `spend` when the entry has a finite expiry and `clock.timestamp_ms() >= expires_at_ms` (a closed boundary: a spend in the exact expiry millisecond fails). The `u64::MAX` no-expiry sentinel short-circuits by equality before any clock comparison.
+
+
+
+Raised by `spend` when `remaining` is finite and `amount > remaining`, including suspended entries (`remaining == 0`) for any positive amount. Compare-before-decrement; there is no underflow path.
+
+
+
+Raised by `deposit`/`deposit_balance` on a zero-value deposit, by `spend` when `amount == 0` (checked after expiry, before exceeded), and by `withdraw` when `amount == 0`. Deliberately not raised by `set_allowance` (`0` is the suspension idiom), `withdraw_all` (returns a zero `Balance`), or `squash` (no zero guard).
+
+
+
+Raised by `set_allowance` when `new_expires_at_ms` is finite and `<= clock.timestamp_ms()`; a finite expiry must be strictly in the future. Corollary: a fresh future expiry revives an expired entry in place.
+
+
+
+Raised by `set_allowance` when `expected` is `Some(e)` and the entry is absent or its raw `remaining != e`. `Some(0)` on an absent entry aborts; absent is not zero.
+
diff --git a/content/contracts-sui/1.x/api/finance.mdx b/content/contracts-sui/1.x/api/finance.mdx
new file mode 100644
index 00000000..85cef2e3
--- /dev/null
+++ b/content/contracts-sui/1.x/api/finance.mdx
@@ -0,0 +1,556 @@
+---
+title: Finance API Reference
+---
+
+This page documents the public API of `openzeppelin_finance` for OpenZeppelin Contracts for Sui `v1.x`.
+
+The package splits into two modules: `vesting_wallet_linear`, the built-in linear-with-cliff curve most integrators use directly, and `vesting_wallet`, the curve-agnostic core it is built on. All functions that observe time take `&Clock` (the shared Sui `Clock` singleton at `0x6`).
+
+### `vesting_wallet_linear` [toc] [#vesting_wallet_linear]
+
+
+
+```move
+use openzeppelin_finance::vesting_wallet_linear;
+```
+
+The stepped (tranche) schedule for `vesting_wallet`: `1/N` every period, after an optional cliff, with continuous linear vesting available as the `period_ms = 1` limit. This module declares the `Linear` witness and its `Params`, and the full integrator API around them. An integrator who wants standard vesting touches only this module and never constructs a bare wallet or mints a `VestedAmount` by hand.
+
+Types
+
+- [`Linear`](#vesting_wallet_linear-Linear)
+- [`Params`](#vesting_wallet_linear-Params)
+
+Functions
+
+- [`params(start_ms, cliff_ms, period_ms, steps)`](#vesting_wallet_linear-params)
+- [`params_continuous(start_ms, cliff_ms, duration_ms)`](#vesting_wallet_linear-params_continuous)
+- [`new(beneficiary, start_ms, cliff_ms, period_ms, steps, ctx)`](#vesting_wallet_linear-new)
+- [`new_continuous(beneficiary, start_ms, cliff_ms, duration_ms, ctx)`](#vesting_wallet_linear-new_continuous)
+- [`create_and_share(beneficiary, start_ms, cliff_ms, period_ms, steps, ctx)`](#vesting_wallet_linear-create_and_share)
+- [`create_and_share_continuous(beneficiary, start_ms, cliff_ms, duration_ms, ctx)`](#vesting_wallet_linear-create_and_share_continuous)
+- [`vested_amount(wallet, clock)`](#vesting_wallet_linear-vested_amount)
+- [`release(wallet, clock)`](#vesting_wallet_linear-release)
+- [`releasable(wallet, clock)`](#vesting_wallet_linear-releasable)
+- [`destroy(receipt, cap, clock)`](#vesting_wallet_linear-destroy)
+- [`start_ms(wallet)`](#vesting_wallet_linear-start_ms)
+- [`period_ms(wallet)`](#vesting_wallet_linear-period_ms)
+- [`steps(wallet)`](#vesting_wallet_linear-steps)
+- [`duration_ms(wallet)`](#vesting_wallet_linear-duration_ms)
+- [`end_ms(wallet)`](#vesting_wallet_linear-end_ms)
+- [`cliff_ms(wallet)`](#vesting_wallet_linear-cliff_ms)
+
+Errors
+
+- [`EZeroPeriod`](#vesting_wallet_linear-EZeroPeriod)
+- [`EZeroSteps`](#vesting_wallet_linear-EZeroSteps)
+- [`EInvalidCliff`](#vesting_wallet_linear-EInvalidCliff)
+- [`EScheduleOverflow`](#vesting_wallet_linear-EScheduleOverflow)
+- [`ENotEnded`](#vesting_wallet_linear-ENotEnded)
+
+#### Types [!toc] [#vesting_wallet_linear-Types]
+
+
+The schedule witness for the stepped curve. Empty and `drop`-only: it carries no data and exists solely as the authority token `vesting_wallet` requires. Declared here, so only this module can construct a `Linear` and therefore only this module can mint a `VestedAmount` or tear down a `VestingWallet`.
+
+
+
+The stepped-schedule parameters stored in the wallet: `start_ms` (when vesting begins), `period_ms` (length of each tranche), `steps` (number of equal tranches), and `cliff_ms` (`0` for no cliff). The schedule ends at `start_ms + period_ms * steps`. Obtain a validated value via `params` or `params_continuous`.
+
+
+#### Functions [!toc] [#vesting_wallet_linear-Functions]
+
+
+Builds a validated `Params` for the stepped schedule, running the same construction guards as `new`. This is the only way to obtain a `Params` outside this module, so a curve-agnostic protocol that drives `vesting_wallet` directly can mint the wallet itself via `vesting_wallet::new(params(..), beneficiary, ctx)`.
+
+Aborts with `EZeroPeriod` if `period_ms == 0`.
+
+Aborts with `EZeroSteps` if `steps == 0`.
+
+Aborts with `EScheduleOverflow` if `period_ms * steps`, or `start_ms` plus that duration, would overflow `u64`.
+
+Aborts with `EInvalidCliff` if `cliff_ms > period_ms * steps`.
+
+
+
+Builds a validated `Params` for the continuous linear-with-cliff schedule: the stepped curve in the `period_ms = 1` limit (`steps = duration_ms`).
+
+Aborts with `EZeroSteps` if `duration_ms == 0`.
+
+Aborts with `EInvalidCliff` if `cliff_ms > duration_ms`.
+
+Aborts with `EScheduleOverflow` if `start_ms + duration_ms` would overflow `u64`.
+
+
+
+Builds a `VestingWallet` on the stepped schedule and returns it by value, plus the `DestroyCap` that authorizes its teardown, so the caller can chain deposit and topology selection in one PTB. Use `create_and_share` for the common "share immediately" case.
+
+Aborts with `EZeroPeriod`, `EZeroSteps`, `EScheduleOverflow`, or `EInvalidCliff` under the same conditions as `params`.
+
+
+
+Sugar for a continuous linear-with-cliff schedule: the stepped curve in the `period_ms = 1` limit, where every millisecond is its own tranche and the curve rises linearly. Returns the wallet by value plus its `DestroyCap`.
+
+Aborts with `EZeroSteps`, `EInvalidCliff`, or `EScheduleOverflow` under the same conditions as `params_continuous`.
+
+
+
+Builds a stepped wallet and immediately shares it via `transfer::public_share_object`, returning the shared wallet's `ID` and its `DestroyCap`. The cap holder can later tear the wallet down.
+
+Aborts with `EZeroPeriod`, `EZeroSteps`, `EScheduleOverflow`, or `EInvalidCliff` under the same conditions as `params`.
+
+
+
+Builds a continuous wallet and immediately shares it, returning the shared wallet's `ID` and its `DestroyCap`.
+
+Aborts with `EZeroSteps`, `EInvalidCliff`, or `EScheduleOverflow` under the same conditions as `params_continuous`.
+
+
+
+Evaluates the stepped curve at `clock.timestamp_ms()` and mints the resulting cumulative vested total as a `VestedAmount`, ready to pass to `vesting_wallet::release` (or this module's `release`). Used when driving the wallet through a curve-agnostic wrapper.
+
+
+
+Evaluates the curve and releases the not-yet-released portion to the beneficiary in one call - the common path. Permissionless and idempotent: a no-op if nothing new has vested since the last release.
+
+
+
+How much `release` would pay out right now, without the caller minting a `VestedAmount`. The client-friendly "what can I claim?" query.
+
+
+
+Finalizes teardown of a drained wallet by consuming the `DestroyReceipt` that `vesting_wallet::destroy_empty` returns, along with the wallet's `DestroyCap`. Authority comes from the cap, not the caller's address, so a wallet whose `beneficiary` is an object address can still be torn down. The receipt is a hot potato consumed in the same PTB it was produced, so a failed gate here reverts the whole teardown.
+
+Aborts with `EWrongCap` if `cap` was minted for a different wallet.
+
+Aborts with `ENotEnded` if called before the schedule's end (`start_ms + period_ms * steps`).
+
+
+
+Timestamp (ms) at which vesting begins.
+
+
+
+Length of each tranche period (ms). `1` for a continuous schedule.
+
+
+
+Number of equal tranches.
+
+
+
+Length of the vesting period (ms): `period_ms * steps`.
+
+
+
+Timestamp (ms) at which the schedule ends (`start_ms + period_ms * steps`).
+
+
+
+The configured cliff length (ms from `start_ms`). `0` means no cliff.
+
+
+#### Errors [!toc] [#vesting_wallet_linear-Errors]
+
+
+Raised by `params` / `new` when `period_ms == 0`; each tranche must span a positive period.
+
+
+
+Raised when `steps == 0` (or `duration_ms == 0` for the continuous constructors); a schedule must have at least one tranche.
+
+
+
+Raised when `cliff_ms` exceeds the schedule duration (`period_ms * steps`); the cliff must fall within the schedule.
+
+
+
+Raised when `period_ms * steps`, or `start_ms` plus that duration, would overflow `u64`.
+
+
+
+Raised by `destroy` when called before the schedule's end (`start_ms + period_ms * steps`).
+
+
+### `vesting_wallet` [toc] [#vesting_wallet]
+
+
+
+```move
+use openzeppelin_finance::vesting_wallet;
+```
+
+The curve-agnostic core. `VestingWallet` locks a `Balance` for a single `beneficiary` and tracks how much has been paid out. It never interprets the schedule - a curve module evaluates its curve, mints a `VestedAmount`, and `release` pays out the not-yet-released portion. Parameterized by the schedule witness `S` (`drop`), the schedule parameters `P` (`copy + drop + store`), and the coin type `C`, all fixed at construction.
+
+Types
+
+- [`VestingWallet`](#vesting_wallet-VestingWallet)
+- [`VestedAmount`](#vesting_wallet-VestedAmount)
+- [`DestroyReceipt`](#vesting_wallet-DestroyReceipt)
+- [`DestroyCap`](#vesting_wallet-DestroyCap)
+
+Functions
+
+- [`new(schedule_params, beneficiary, ctx)`](#vesting_wallet-new)
+- [`mint_vested_amount(wallet, w, amount)`](#vesting_wallet-mint_vested_amount)
+- [`deposit(wallet, balance)`](#vesting_wallet-deposit)
+- [`sweep_settled(wallet, root)`](#vesting_wallet-sweep_settled)
+- [`receive_and_deposit(wallet, receiving)`](#vesting_wallet-receive_and_deposit)
+- [`release(wallet, vested)`](#vesting_wallet-release)
+- [`destroy_empty(wallet, root)`](#vesting_wallet-destroy_empty)
+- [`consume_receipt(receipt, cap, w)`](#vesting_wallet-consume_receipt)
+- [`releasable(wallet, vested)`](#vesting_wallet-releasable)
+- [`amount(vested)`](#vesting_wallet-amount)
+- [`schedule_params(wallet)`](#vesting_wallet-schedule_params)
+- [`beneficiary(wallet)`](#vesting_wallet-beneficiary)
+- [`released(wallet)`](#vesting_wallet-released)
+- [`balance(wallet)`](#vesting_wallet-balance)
+
+Events
+
+- [`Created`](#vesting_wallet-Created)
+- [`Deposited`](#vesting_wallet-Deposited)
+- [`Swept`](#vesting_wallet-Swept)
+- [`Received`](#vesting_wallet-Received)
+- [`Released`](#vesting_wallet-Released)
+- [`Destroyed`](#vesting_wallet-Destroyed)
+
+Errors
+
+- [`ENotEmpty`](#vesting_wallet-ENotEmpty)
+- [`EWalletMismatch`](#vesting_wallet-EWalletMismatch)
+- [`EVestedBelowReleased`](#vesting_wallet-EVestedBelowReleased)
+- [`EBalanceOverflow`](#vesting_wallet-EBalanceOverflow)
+- [`EInsufficientBalance`](#vesting_wallet-EInsufficientBalance)
+- [`EWrongCap`](#vesting_wallet-EWrongCap)
+- [`EUnsweptFunds`](#vesting_wallet-EUnsweptFunds)
+
+#### Types [!toc] [#vesting_wallet-Types]
+
+
+The vesting wallet. `schedule_params` and `beneficiary` are fixed at construction; only `balance` and `released` change over time. Curve modules read `balance + released` as the wallet's current total when evaluating the schedule, so deposits made after the schedule starts participate retroactively. `key + store`, so the constructor returns it by value and the consumer picks the topology (share or transfer).
+
+
+
+A transient attestation that curve `S` has vested a cumulative `amount` for a specific wallet. `drop`-only - no `copy`, `store`, or `key` - so it cannot be duplicated, stored, or held across transactions; it lives only within the PTB that mints it. Not a hot potato: `release` and `releasable` borrow it. Only the module that declares `S` can mint one (via `mint_vested_amount`), and its `wallet_id` stamp binds it to the wallet it was minted against, so `release` rejects it against any other wallet.
+
+
+
+A hot potato carrying a destroyed wallet's id and schedule params back to its curve. Only `consume_receipt` (witness-gated and cap-gated) can consume it, which drags the curve into the teardown PTB and lets it veto by aborting. Returned by `destroy_empty`.
+
+
+
+Authority to finalize the teardown of one specific wallet. Minted by `new` alongside the wallet and bound to it by id; consumed by `consume_receipt`, which rejects any cap whose wallet id does not match. Teardown authority travels with the cap, not with the wallet's `beneficiary` - this is what makes teardown reachable for a wallet whose beneficiary is an object address. It has no `drop`, so it is retired only by tearing the wallet down.
+
+
+#### Functions [!toc] [#vesting_wallet-Functions]
+
+
+Builds a new wallet around a schedule and returns it by value, together with the `DestroyCap` that authorizes its eventual teardown. Returning by value lets the caller chain creation, funding, and topology selection in one PTB. Emits `Created`. Taking `P` by value is the authority proof - only the module that declares `P` can produce one.
+
+
+
+Mints a `VestedAmount` recording `amount` as the cumulative vested total for `wallet`. Witness-gated: only the module that declares `S` can construct one, so the `amount` is unforgeable elsewhere. The `amount` is **not** validated here - the wallet only stamps it with this wallet's id; the curve module is responsible for keeping `amount` a monotonically non-decreasing, `balance + released`-bounded function of the schedule.
+
+
+
+Adds a `Balance` to the wallet's balance. Permissionless - anyone may fund. A zero-value deposit is a no-op and emits no event; otherwise emits `Deposited`.
+
+Aborts with `EBalanceOverflow` if the deposit would push the wallet's lifetime total (`balance + released`) past `u64::MAX`.
+
+
+
+Sweeps all settled funds from the wallet's own object address balance into its on-book `balance`. Used to pull in `Balance` settled to an owned wallet's address. A wallet with no settled funds is a no-op and emits no event; otherwise emits `Swept`.
+
+Aborts with `EBalanceOverflow` if sweeping would overflow the lifetime total. Can also abort from the underlying Sui accumulator withdrawal if object-funds withdrawal is not enabled or the withdrawal cannot be redeemed.
+
+
+
+Claims a `Coin` that an upstream emitter `public_transfer`'d to this wallet's object address, then adds it to the balance. Used by emission schedules and payroll robots that don't hold a wallet reference. Requires `&mut wallet`; a wrapper that keeps `&mut inner` private must re-expose this or such a coin stays stranded. A zero-value claim is a no-op; otherwise emits `Received`.
+
+Aborts with `EBalanceOverflow` if claiming would overflow the lifetime total - and because the coin was already transferred, an abort here leaves it stranded at the wallet's address.
+
+Aborts with `sui::transfer::EUnableToReceiveObject` (code 3) if `receiving` is no longer receivable (already claimed, wrapped, transferred away, or absent at that version).
+
+
+
+Pays the not-yet-released portion attested by `vested` into the beneficiary's address balance via `balance::send_funds` - no `Coin` object is minted. Permissionless: anyone holding the wallet and a `VestedAmount` can poke it. The recipient is read fresh from `wallet.beneficiary`. `vested` is borrowed, not consumed. A no-op (no event) if nothing new is vested.
+
+Aborts with `EWalletMismatch` if `vested` was not minted for this wallet.
+
+Aborts with `EVestedBelowReleased` if `vested.amount` is below the amount already released.
+
+Aborts with `EInsufficientBalance` if the balance cannot cover the releasable amount (the curve attested more than `balance + released`).
+
+
+
+Consumes a drained wallet to reclaim its storage rebate, emits `Destroyed`, and returns a `DestroyReceipt` for the curve to finalize via `consume_receipt`. Permissionless (no witness, no cap), so a curve-agnostic holder can drain the rebate; but the receipt can only be retired by the witness-and-cap-gated `consume_receipt`, so a `destroy_empty` whose matching consume never runs reverts the whole PTB.
+
+Aborts with `ENotEmpty` if the wallet still holds a balance.
+
+Aborts with `EUnsweptFunds` if the wallet has pending settled funds at its object address (call `sweep_settled` first).
+
+
+
+Unwraps a `DestroyReceipt` to recover the destroyed wallet's schedule parameters, consuming the wallet's `DestroyCap`. Gated two ways: the witness `S` (only the declaring curve can call it, run teardown logic, and abort to veto) and the `DestroyCap` (teardown authority, decoupled from `beneficiary`). The cap is retired here.
+
+Aborts with `EWrongCap` if `cap` was minted for a different wallet than this receipt's.
+
+
+
+What `release` would pay out for the supplied `VestedAmount` right now: `vested.amount - wallet.released`. Borrows `vested`.
+
+Aborts with `EWalletMismatch` if `vested` was not minted for this wallet.
+
+Aborts with `EVestedBelowReleased` if `vested.amount` is below the amount already released.
+
+
+
+Reads the cumulative vested total recorded in a `VestedAmount` without consuming it.
+
+
+
+Reads the wallet's schedule parameters. Ungated - curve parameters are public information.
+
+
+
+Address that receives every `release`.
+
+
+
+Cumulative amount released so far. Monotonically non-decreasing.
+
+
+
+Funds currently held by the wallet and not yet released.
+
+
+#### Events [!toc] [#vesting_wallet-Events]
+
+
+Emitted by `new` when a wallet is created.
+
+
+
+Emitted by `deposit` when a non-zero amount is added. `amount` is the amount added by this deposit.
+
+
+
+Emitted by `sweep_settled` when non-zero settled funds are added.
+
+
+
+Emitted by `receive_and_deposit` when a non-zero coin is claimed.
+
+
+
+Emitted by `release` when a non-zero amount is paid to the beneficiary. `amount` is the incremental portion paid by this release, not the cumulative `released` total.
+
+
+
+Emitted by `destroy_empty` when a drained wallet is torn down. `total_released` is the total released over the wallet's lifetime.
+
+
+#### Errors [!toc] [#vesting_wallet-Errors]
+
+
+Raised by `destroy_empty` when the wallet still holds a balance.
+
+
+
+Raised by `release` / `releasable` when a `VestedAmount` is used against a different wallet than the one it was minted for.
+
+
+
+Raised when a `VestedAmount` attests a cumulative total below what the wallet has already released - a stale attestation or a curve that regressed.
+
+
+
+Raised by `deposit` / `sweep_settled` / `receive_and_deposit` when the addition would push the wallet's lifetime total (`balance + released`) past `u64::MAX`.
+
+
+
+Raised by `release` when the attested amount exceeds the wallet's funded total (`balance + released`), so the current balance cannot cover the releasable amount.
+
+
+
+Raised by `consume_receipt` when the `DestroyCap` was minted for a different wallet than the one being torn down.
+
+
+
+Raised by `destroy_empty` when the wallet still has unswept settled funds at its object address. Call `sweep_settled` first.
+
diff --git a/content/contracts-sui/1.x/finance.mdx b/content/contracts-sui/1.x/finance.mdx
new file mode 100644
index 00000000..a50de4cc
--- /dev/null
+++ b/content/contracts-sui/1.x/finance.mdx
@@ -0,0 +1,56 @@
+---
+title: Finance
+---
+
+
+The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package.
+
+
+The `openzeppelin_finance` package provides vesting primitives for releasing a locked coin to a beneficiary over time. It locks a `Balance` for a single beneficiary and pays it out on a schedule: a curve-agnostic core handles release accounting and conservation of funds, while the curve - linear, stepped, cliff, or a custom shape you write - is a separate, swappable concern. Use it for token grants, team and investor vesting, payroll streams, and emission schedules.
+
+## Usage
+
+There are two ways to pull the package into your project. Pick whichever fits how much you want to own the code.
+
+### Git dependency
+
+Add the dependency in `Move.toml`, pinned to a git revision:
+
+```toml
+[dependencies]
+openzeppelin_finance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.4.0" }
+```
+
+### Copy the source
+
+Alternatively, copy [`vesting_wallet.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.4.0/contracts/finance/sources/vesting_wallet.move) and [`vesting_wallet_linear.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.4.0/contracts/finance/sources/vesting_wallet_linear.move) directly into your package's `sources/` (renaming the module addresses to your own package). You then fully own the code - free to trim, fork, or extend the curve - at the cost of tracking upstream fixes yourself.
+
+### Import
+
+Once the package is available, import the module you want to use:
+
+```move
+use openzeppelin_finance::vesting_wallet_linear; // the built-in linear-with-cliff curve
+use openzeppelin_finance::vesting_wallet; // the curve-agnostic core
+```
+
+## Modules
+
+
+
+ Lock a coin for a single beneficiary and release it on a schedule - linear or stepped vesting with an optional cliff via `vesting_wallet_linear`, or a custom curve on the curve-agnostic `vesting_wallet` core.
+
+
+
+## Choosing a module
+
+| Module | Use it when |
+|---|---|
+| `vesting_wallet_linear` | You want standard token-grant vesting: a linear unlock, or `N` equal tranches, with an optional cliff. **Most integrators only need this module.** |
+| `vesting_wallet` | You're authoring a custom curve (milestone unlocks, oracle-gated release, exotic cliff shapes), or building a protocol that drives vesting wallets **curve-agnostically** - wrapping or gating release without committing to a schedule. |
+
+## Next steps
+
+- [Vesting Wallet](/contracts-sui/1.x/vesting-wallet) for the module guide, the linear curve, topologies, and building custom curves on the core.
+- [Finance API reference](/contracts-sui/1.x/api/finance) for function signatures, events, and errors.
+- [Access](/contracts-sui/1.x/access) for role-based authorization to gate a curve-agnostic wrapper's privileged operations.
diff --git a/content/contracts-sui/1.x/guides/delegated-spending.mdx b/content/contracts-sui/1.x/guides/delegated-spending.mdx
new file mode 100644
index 00000000..7f9e7e59
--- /dev/null
+++ b/content/contracts-sui/1.x/guides/delegated-spending.mdx
@@ -0,0 +1,664 @@
+---
+title: Delegated Spending
+---
+
+
+The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package.
+
+
+This tutorial builds a delegated-spending system on the `spend_vault` module: a shared `Vault` whose owner grants expiring per-coin budgets to a `SpenderCap`, spent first directly by a delegate, then through a sender-gated keeper service that custodies the cap and spends on the user's behalf.
+
+Three parties participate. The owner funds the vault, mints the cap, and sets budgets with the `OwnerCap`. The delegate holds the `SpenderCap` and draws against its budget. The keeper operator later drives spends through a custodied cap without the delegate signing each transaction.
+
+```mermaid
+flowchart LR
+ Owner["Owner
holds OwnerCap"] -->|"set_allowance<T>"| Vault["Shared Vault
multi-coin pool + budget ledger"]
+ Delegate["Delegate
holds SpenderCap"] -->|"spend<T>"| Vault
+ Delegate -->|"register(cap)"| Service["Keeper Service
custodies SpenderCap"]
+ Operator["Keeper operator"] -->|"execute_topup<T>"| Service
+ Service -->|"spend<T> with custodied cap"| Vault
+```
+
+Four ground rules shape everything below:
+
+- A `SpenderCap` is a bearer instrument. The library never checks who calls [`spend`](/contracts-sui/1.x/api/allowance#spend_vault-spend); whoever presents the cap spends with its full authority, across every coin it is budgeted for.
+- A `Vault` has `key` only, so it must be shared or destroyed in the transaction that creates it, and sharing must come last.
+- Budgets are ceilings, not reservations. Grants may sum over the pool; a pool-short spend fails with the `InsufficientFundsForWithdraw` execution status, not a Move abort code.
+- `destroy` deletes the budget ledger but never drains the pool. Undrained funds strand permanently.
+
+The tutorial is linear: one happy path, with a named failure checkpoint at each step. Run every command in order.
+
+## Prerequisites
+
+Before starting, install the Sui CLI and complete the Quickstart on the [Contracts for Sui overview](/contracts-sui/1.x) so you can publish packages and run PTBs against your target network. You need three funded accounts: an owner, a delegate, and a keeper operator. Familiarity with shared objects and programmable transaction blocks (PTBs) helps; the tutorial names the signing account before every transaction.
+
+## Add the dependency
+
+Add the allowance package to `Move.toml`, pinned to a git revision (it is not yet on the Move Registry):
+
+```toml
+[dependencies]
+openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.4.0" }
+```
+
+Then import the module from your Move code:
+
+```move
+use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap};
+```
+
+## Create, Fund, and Share a Vault
+
+The setup module composes the whole vault lifecycle in one function and returns the three objects by value, so the enclosing PTB decides every destination:
+
+```move
+module my_defi::delegation;
+
+use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap};
+use sui::clock::Clock;
+use sui::coin::Coin;
+
+/// Build a funded, budgeted allowance and return its three objects unattached, for
+/// the caller to wire into the surrounding PTB. Creates the vault, deposits the
+/// funding, mints a fresh cap, and grants it `budget` of `T`.
+public fun open_allowance(
+ funding: Coin,
+ budget: u64,
+ expires_at_ms: u64, // pass std::u64::max_value!() for "no expiry"
+ clock: &Clock,
+ ctx: &mut TxContext,
+): (Vault, SpenderCap, OwnerCap) {
+ let (mut vault, owner_cap) = spend_vault::new(ctx);
+
+ // Permissionless top-up. Confers no rights; the funds become the owner's pool.
+ vault.deposit(funding, ctx);
+
+ // Bare cap, no budget yet. Returned by value, so the caller chooses its destination.
+ let cap = vault.mint_cap(&owner_cap, ctx);
+ let cap_id = object::id(&cap);
+
+ // Create the (cap, T) budget. `option::none()` = no CAS guard on a fresh create.
+ vault.set_allowance(&owner_cap, cap_id, budget, expires_at_ms, option::none(), clock, ctx);
+
+ // Hand the objects back; the caller shares the vault and routes the caps.
+ (vault, cap, owner_cap)
+}
+```
+
+This teaches the compose-then-share rule. `Vault` has `key` only and no `drop`: the transaction that calls `new` must either `share` or `destroy` the vault, or execution aborts. Sharing must also come last, because a shared vault is only addressable as a shared input in later transactions: every fund, mint, and grant step in the creating transaction has to precede `share`.
+
+```mermaid
+sequenceDiagram
+ participant Owner
+ participant PTB as Creating PTB
+ participant Vault
+ participant Delegate
+ Owner->>PTB: open_allowance(funding, budget, expiry)
+ PTB->>Vault: new, deposit, mint_cap, set_allowance
+ PTB->>Vault: share (last vault step)
+ PTB-->>Delegate: SpenderCap
+ PTB-->>Owner: OwnerCap
+```
+
+Publishing is a one-time step, so write both modules in full before you run it. The sections below introduce the remaining functions one at a time, and a second `sui client publish` is rejected because the package is already published. Forcing one anyway mints a fresh `spend_vault`, and the vault you create in the next step would no longer match its `Vault` type. Add everything now, then publish once.
+
+
+Complete `sources/delegation.move` and `sources/keeper.move`
+
+Each function is explained in its own section below; paste both files now so the single publish covers them.
+
+```move
+module my_defi::delegation;
+
+use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap};
+use sui::accumulator::AccumulatorRoot;
+use sui::clock::Clock;
+use sui::coin::Coin;
+
+public fun open_allowance(
+ funding: Coin,
+ budget: u64,
+ expires_at_ms: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+): (Vault, SpenderCap, OwnerCap) {
+ let (mut vault, owner_cap) = spend_vault::new(ctx);
+ vault.deposit(funding, ctx);
+ let cap = vault.mint_cap(&owner_cap, ctx);
+ let cap_id = object::id(&cap);
+ vault.set_allowance(&owner_cap, cap_id, budget, expires_at_ms, option::none(), clock, ctx);
+ (vault, cap, owner_cap)
+}
+
+public fun spend_to_address(
+ vault: &mut Vault,
+ cap: &SpenderCap,
+ recipient: address,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ vault.spend(cap, amount, clock, ctx).send_funds(recipient);
+}
+
+public fun change_budget(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ cap_id: ID,
+ expected: u64,
+ new_budget: u64,
+ new_expires_at_ms: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ vault.set_allowance(
+ owner_cap,
+ cap_id,
+ new_budget,
+ new_expires_at_ms,
+ option::some(expected),
+ clock,
+ ctx,
+ );
+}
+
+public fun kill_authority(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ cap_id: ID,
+ ctx: &mut TxContext,
+) {
+ vault.revoke_all(owner_cap, cap_id, ctx);
+}
+
+public fun drain_coin(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ root: &AccumulatorRoot,
+ ctx: &mut TxContext,
+): Coin {
+ vault.withdraw_all(owner_cap, root, ctx).into_coin(ctx)
+}
+```
+
+```move
+module my_defi::keeper;
+
+use openzeppelin_allowance::spend_vault::{Vault, SpenderCap};
+use sui::balance::Balance;
+use sui::clock::Clock;
+use sui::table::{Self, Table};
+
+#[error(code = 0)]
+const ENotOperator: vector = "Caller is not the service operator";
+#[error(code = 1)]
+const EWrongVaultForService: vector =
+ "Cap is bound to a different vault than this service serves";
+#[error(code = 2)]
+const ENotRegistered: vector = "No cap registered under this user address";
+
+public struct Service has key {
+ id: UID,
+ operator: address,
+ vault_id: ID,
+ caps: Table,
+}
+
+public fun create(vault_id: ID, ctx: &mut TxContext): Service {
+ Service {
+ id: object::new(ctx),
+ operator: ctx.sender(),
+ vault_id,
+ caps: table::new(ctx),
+ }
+}
+
+public fun share(service: Service) {
+ transfer::share_object(service);
+}
+
+public fun register(service: &mut Service, cap: SpenderCap, ctx: &mut TxContext) {
+ assert!(cap.spender_cap_vault_id() == service.vault_id, EWrongVaultForService);
+ service.caps.add(ctx.sender(), cap);
+}
+
+public fun execute_topup(
+ service: &mut Service,
+ vault: &mut Vault,
+ user: address,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+): Balance {
+ assert!(ctx.sender() == service.operator, ENotOperator);
+ assert!(service.caps.contains(user), ENotRegistered);
+ let cap = service.caps.borrow(user);
+ vault.spend(cap, amount, clock, ctx)
+}
+
+public fun unregister(service: &mut Service, ctx: &mut TxContext): SpenderCap {
+ assert!(service.caps.contains(ctx.sender()), ENotRegistered);
+ service.caps.remove(ctx.sender())
+}
+```
+
+
+
+`openzeppelin_allowance` is a source dependency with no on-chain address (OpenZeppelin does not publish it on-chain), so pass `--with-unpublished-dependencies` to bundle its modules into yours. The publish emits a single package object holding all three modules, `delegation`, `keeper`, and `spend_vault` together:
+
+```bash
+sui client publish --with-unpublished-dependencies
+```
+
+Record the package ID and account addresses from the output. There is only one: `$PACKAGE` contains `spend_vault` alongside your own modules, so every library call below is addressed as `$PACKAGE::spend_vault`. Bundling gives you your own copy of the library at your own package id rather than an OpenZeppelin-hosted address, which also means your `Vault` type is distinct from any other integrator's:
+
+```bash
+export PACKAGE=0x...
+export OWNER=0x...
+export DELEGATE=0x...
+export OPERATOR=0x...
+export GRAPHQL_URL=https://graphql.testnet.sui.io/graphql
+export EXPIRES_AT_MS=$((($(date +%s) + 30 * 24 * 60 * 60) * 1000))
+```
+
+Run the creating PTB from the owner account. It funds the vault with 1 SUI, grants the cap a 0.4 SUI budget expiring in 30 days, shares the vault last, and routes the caps:
+
+```bash
+sui client ptb \
+ --split-coins gas "[1000000000]" \
+ --assign funding \
+ --move-call $PACKAGE::delegation::open_allowance \
+ "<0x2::sui::SUI>" \
+ funding \
+ 400000000 \
+ $EXPIRES_AT_MS \
+ @0x6 \
+ --assign r \
+ --move-call $PACKAGE::spend_vault::share r.0 \
+ --transfer-objects "[r.1]" @$DELEGATE \
+ --transfer-objects "[r.2]" @$OWNER
+```
+
+Record the created object IDs from the output:
+
+```bash
+export VAULT=0x...
+export OWNER_CAP=0x...
+export SPENDER_CAP=0x...
+```
+
+**Checkpoint.** The transaction emits `VaultCreated`, `Deposited`, `SpenderCapMinted`, and `AllowanceSet { was_created: true }`. If you see anything else, stop and re-check the PTB before continuing.
+
+## Grant a Budget
+
+`open_allowance` already performed the first grant, so this step unpacks what [`set_allowance`](/contracts-sui/1.x/api/allowance#spend_vault-set_allowance) actually did and establishes the habit you need for every later grant.
+
+A budget is an entry keyed by `(cap_id, coin type)`. Two sentinel values use `u64::MAX`: a `new_amount` of `u64::MAX` means unlimited (never decremented by spends), and a `new_expires_at_ms` of `u64::MAX` means no expiry. Any finite expiry must be strictly in the future or the call aborts [`EExpiryInPast`](/contracts-sui/1.x/api/allowance#spend_vault-EExpiryInPast). Setting `new_amount` to `0` is legal and means suspension, not deletion.
+
+`set_allowance` takes the spender cap's id as a bare, unvalidated `ID` and upserts. A mistyped `cap_id` does not abort; it silently provisions a fresh phantom budget. The defense is the `was_created` flag on the emitted `AllowanceSet` event: on a grant you intended as an update, confirm `was_created == false`. If it is `true`, you just created a budget under the wrong id.
+
+The owner grants or updates later budgets by calling the library directly. Run this from the owner account to raise the budget to 0.6 SUI (unconditionally; the race-free variant comes later):
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::spend_vault::set_allowance \
+ "<0x2::sui::SUI>" \
+ @$VAULT \
+ @$OWNER_CAP \
+ @$SPENDER_CAP \
+ 600000000 \
+ $EXPIRES_AT_MS \
+ none \
+ @0x6
+```
+
+**Checkpoint.** Read the entry back with a dev-inspect call to each view function. Use `sui client call --dev-inspect --json`, which reports return values under `command_outputs`; `sui client ptb --dev-inspect` executes the same reads but prints no return values:
+
+```bash
+sui client call --dev-inspect --json \
+ --package $PACKAGE --module spend_vault --function allowance \
+ --type-args 0x2::sui::SUI --args $VAULT $SPENDER_CAP
+
+sui client call --dev-inspect --json \
+ --package $PACKAGE --module spend_vault --function expiry \
+ --type-args 0x2::sui::SUI --args $VAULT $SPENDER_CAP
+```
+
+Each prints its result at `command_outputs[0].returnValues[0].json`: `allowance` must return `600000000` and `expiry` your `$EXPIRES_AT_MS`. The transaction's `AllowanceSet` event must show `was_created: false`.
+
+## Spend as the Delegate
+
+`spend` returns a `Balance` with no `drop`: the caller must consume it in the same PTB. Because the library's pool already lives as [address balances](https://docs.sui.io/onchain-finance/asset-custody/address-balances), the delegate-facing wrapper hands the drawn balance straight to a recipient's address balance with `send_funds`, creating no new object. That wrapper is already in your module:
+
+```move
+/// `spend` returns Balance, which has no `drop`: it must be consumed in the
+/// same PTB. Here we hand it straight to `recipient`'s address balance with
+/// `send_funds`, so the funds land natively with no `Coin` object created.
+public fun spend_to_address(
+ vault: &mut Vault,
+ cap: &SpenderCap,
+ recipient: address,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ vault.spend(cap, amount, clock, ctx).send_funds(recipient);
+}
+```
+
+Reach for a `Coin` only when a downstream call needs a coin object to compose with: return `vault.spend(...).into_coin(ctx)` instead and let the PTB route the object. The [Spend Vault guide](/contracts-sui/1.x/spend-vault#spending) shows both forms.
+
+Run the spend from the delegate account, drawing 0.15 SUI straight into the delegate's address balance:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::delegation::spend_to_address \
+ "<0x2::sui::SUI>" \
+ @$VAULT \
+ @$SPENDER_CAP \
+ @$DELEGATE \
+ 150000000 \
+ @0x6
+```
+
+**Checkpoint.** The transaction emits one `Spent` event: `amount: 150000000`, `remaining: 450000000` (the raw post-call budget), `caller` equal to `$DELEGATE`, and the `coin_type` and `cap_id` you expect. No new coin object is created: the 0.15 SUI merges into `$DELEGATE`'s SUI address balance. Re-running the dev-inspect from the previous step shows `allowance` decremented to `450000000`.
+
+## Build the Keeper Service
+
+Direct spending proves the primitive. The library's primary use case wraps it: a protocol custodies users' caps and spends on their behalf within the owner-set budgets, without the owner or delegate signing each spend.
+
+
+A `SpenderCap` is a bearer instrument and the library never checks who calls `spend`; any code that gets the library to see `&cap` exercises its full authority. A public function that borrows a custodied cap without a sender gate is world-drainable. The operator assert below is the integration's security boundary, not optional hygiene.
+
+
+The service is pinned to exactly one vault id at creation; pinning up front is what makes the register-time binding check meaningful. It is the second module you already published:
+
+```move
+module my_defi::keeper;
+
+use openzeppelin_allowance::spend_vault::{Vault, SpenderCap};
+use sui::balance::Balance;
+use sui::clock::Clock;
+use sui::table::{Self, Table};
+
+#[error(code = 0)]
+const ENotOperator: vector = "Caller is not the service operator";
+#[error(code = 1)]
+const EWrongVaultForService: vector =
+ "Cap is bound to a different vault than this service serves";
+#[error(code = 2)]
+const ENotRegistered: vector = "No cap registered under this user address";
+
+/// Shared keeper service. Serves exactly one `Vault` and custodies at most one cap
+/// per user. Untyped, so one service drives every coin a cap is budgeted for.
+public struct Service has key {
+ id: UID,
+ operator: address,
+ vault_id: ID,
+ caps: Table,
+}
+
+/// Create a service pinned to `vault_id`. The creator becomes the operator, the
+/// only address the cap-borrowing entrypoint accepts. Returned by value for the
+/// caller to `share` (two-step create-then-share, mirroring `spend_vault::share`).
+public fun create(vault_id: ID, ctx: &mut TxContext): Service {
+ Service {
+ id: object::new(ctx),
+ operator: ctx.sender(),
+ vault_id,
+ caps: table::new(ctx),
+ }
+}
+
+/// Share the service so users can register caps against it.
+public fun share(service: Service) {
+ transfer::share_object(service);
+}
+
+/// Validate the cap's vault binding BEFORE taking custody: the rule for ANY
+/// protocol that accepts a SpenderCap.
+public fun register(service: &mut Service, cap: SpenderCap, ctx: &mut TxContext) {
+ assert!(cap.spender_cap_vault_id() == service.vault_id, EWrongVaultForService);
+ service.caps.add(ctx.sender(), cap);
+}
+
+/// SENDER-GATED: the library never checks who calls `spend`, so the custody
+/// layer must. Without this check the function is world-drainable.
+public fun execute_topup(
+ service: &mut Service,
+ vault: &mut Vault,
+ user: address,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+): Balance {
+ assert!(ctx.sender() == service.operator, ENotOperator);
+ assert!(service.caps.contains(user), ENotRegistered);
+ let cap = service.caps.borrow(user);
+ vault.spend(cap, amount, clock, ctx)
+}
+
+/// Take a cap back out of custody. The grant is untouched: it stays live in the
+/// vault; only custody of the cap changes hands.
+public fun unregister(service: &mut Service, ctx: &mut TxContext): SpenderCap {
+ assert!(service.caps.contains(ctx.sender()), ENotRegistered);
+ service.caps.remove(ctx.sender())
+}
+```
+
+The `register` check calls [`spender_cap_vault_id`](/contracts-sui/1.x/api/allowance#spend_vault-spender_cap_vault_id) so a cap bound to a different vault is rejected at custody time, not discovered at spend time. `execute_topup` is generic over `T`, so one custodied cap serves every coin the owner budgeted it for; asking for a coin the owner never granted fails safe inside the library with [`ENoAllowance`](/contracts-sui/1.x/api/allowance#spend_vault-ENoAllowance).
+
+```mermaid
+sequenceDiagram
+ participant User as Delegate (user)
+ participant Service
+ participant Operator
+ participant Vault
+ User->>Service: register(cap)
+ Service->>Service: assert spender_cap_vault_id == vault_id
+ Operator->>Service: execute_topup(user, amount)
+ Service->>Service: assert sender == operator
+ Service->>Vault: spend(&cap, amount)
+ Vault-->>Operator: Balance
+```
+
+The `keeper` module is already part of `$PACKAGE`, so no further publish is needed. Create and share the service from the operator account, pinning it to your vault id:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::keeper::create @$VAULT \
+ --assign service \
+ --move-call $PACKAGE::keeper::share service
+```
+
+Record the shared service id:
+
+```bash
+export SERVICE=0x...
+```
+
+The delegate hands the cap into custody. Run this from the delegate account:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::keeper::register @$SERVICE @$SPENDER_CAP
+```
+
+Now the operator drives a spend. `execute_topup` returns a `Balance`, which the PTB delivers straight to the user's address balance with `send_funds`, creating no coin object. In a real keeper this balance would flow into a position; here it tops up the user. Run this from the operator account:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::keeper::execute_topup \
+ "<0x2::sui::SUI>" \
+ @$SERVICE \
+ @$VAULT \
+ @$DELEGATE \
+ 50000000 \
+ @0x6 \
+ --assign bal \
+ --move-call 0x2::balance::send_funds "<0x2::sui::SUI>" bal @$DELEGATE
+```
+
+**Checkpoint.** The `Spent` event shows `remaining: 400000000` and `caller` equal to `$OPERATOR`: attribution follows whoever drove the spend, not the user who owns the budget. Repeating the PTB from the delegate or owner account aborts with the keeper module's `ENotOperator`, proving the gate holds.
+
+## Owner Maintenance Mid-Custody
+
+The owner keeps full control while the cap sits in custody, because every owner verb keys on the `cap_id` alone and never touches the cap object. `cap_id` is stable across create, raise, lower, suspend, and renew; a custodied cap keeps working with no re-registration.
+
+A plain `set_allowance` computed from an earlier read can clobber a spend sequenced in between. The race-free idiom is compare-and-set: read the current budget with `allowance` off-chain (or in a previous transaction), then pass that value as `expected` when you write. If a spend landed after the read, the entry's `remaining` no longer matches and the write aborts [`EUnexpectedAllowance`](/contracts-sui/1.x/api/allowance#spend_vault-EUnexpectedAllowance) instead of silently overwriting; re-read and retry. The wrapper is already in `my_defi::delegation`:
+
+```move
+/// Raise / lower / renew with the race-free CAS idiom. `expected` is the budget
+/// the caller read via `allowance` off-chain (or in a previous transaction).
+/// If a spend was sequenced between that read and this transaction, the entry's
+/// `remaining` no longer equals `expected` and the call aborts
+/// `EUnexpectedAllowance` instead of clobbering the concurrent spend.
+public fun change_budget(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ cap_id: ID,
+ expected: u64,
+ new_budget: u64,
+ new_expires_at_ms: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ vault.set_allowance(
+ owner_cap,
+ cap_id,
+ new_budget,
+ new_expires_at_ms,
+ option::some(expected),
+ clock,
+ ctx,
+ );
+}
+```
+
+Note that the CAS guard matches `remaining` only, while the upsert always overwrites the expiry too: a budget-only update must re-read the current expiry (via `expiry`) and pass it back, or it silently rewrites the deadline.
+
+Read the current budget first (the last checkpoint's `Spent` event showed `remaining: 400000000`, or re-run the dev-inspect read), then raise it to 1 SUI from the owner account, passing the read value as `expected`:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::delegation::change_budget \
+ "<0x2::sui::SUI>" \
+ @$VAULT \
+ @$OWNER_CAP \
+ @$SPENDER_CAP \
+ 400000000 \
+ 1000000000 \
+ $EXPIRES_AT_MS \
+ @0x6
+```
+
+If a spend lands between your read and this transaction, the PTB aborts `EUnexpectedAllowance`; re-read the budget and retry with the fresh value.
+
+Three conditions stop a spender, and the spender can tell them apart by abort code:
+
+- **Suspend**: `set_allowance` with `new_amount = 0` keeps the entry and cap alive. The next positive spend aborts [`EAllowanceExceeded`](/contracts-sui/1.x/api/allowance#spend_vault-EAllowanceExceeded), the signal to ask for a raise.
+- **Revoke**: [`revoke`](/contracts-sui/1.x/api/allowance#spend_vault-revoke) removes the `(cap, coin)` entry. The next spend aborts [`ENoAllowance`](/contracts-sui/1.x/api/allowance#spend_vault-ENoAllowance), the signal to ask for a new grant. `revoke` is idempotent and returns `was_present`; `false` means a typo'd `cap_id` or wrong coin, never an abort.
+- **Expire**: once the clock reaches a finite `expires_at_ms`, the next spend aborts [`EAllowanceExpired`](/contracts-sui/1.x/api/allowance#spend_vault-EAllowanceExpired) (the boundary is closed: a spend at exactly the expiry millisecond fails). The owner revives the entry in place by re-setting a future expiry via `set_allowance`.
+
+**Checkpoint.** Run the operator's `execute_topup` PTB from the previous section again. It succeeds under the new budget without any re-registration, and the `Spent` event reflects the raised `remaining`.
+
+## Emergency Stop
+
+This section is for *durably* stopping a cap that must not spend again, not for routine exit: to just recover funds the owner withdraws anytime (see Teardown), and with a single funder that is the whole story. When a cap is compromised or a keeper misbehaves and the pool could be re-funded, the order of operations matters. Kill that cap's authority first, then move the funds, in two separate transactions. `revoke_all` acts on the one cap you name; this tutorial has a single delegate, but a vault serving many spenders revokes each compromised cap, or `destroy`s the whole vault to end every cap at once. These two functions are already in `my_defi::delegation`, which imports `use sui::accumulator::AccumulatorRoot;` for the second:
+
+```move
+// Emergency stop, in TWO separate transactions, never one PTB.
+
+/// Tx 1: kill this cap's authority. `revoke_all` acts on one cap and never
+/// touches the pool, so no allowance state can race it into failure. (Withdrawing
+/// first is NOT durable: deposits are permissionless, so anyone can re-arm a live
+/// allowance.)
+public fun kill_authority(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ cap_id: ID,
+ ctx: &mut TxContext,
+) {
+ vault.revoke_all(owner_cap, cap_id, ctx);
+}
+
+/// Tx 2 (a LATER transaction, once per coin type): drain the coin. `withdraw_all`
+/// reads the settled accumulator (root = 0xacc); a same-checkpoint pool change
+/// makes it over-ask and abort (retry-safe). Returns the possibly-zero drained
+/// pool as a wallet `Coin` for the caller to route.
+public fun drain_coin(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ root: &AccumulatorRoot,
+ ctx: &mut TxContext,
+): Coin {
+ vault.withdraw_all(owner_cap, root, ctx).into_coin(ctx)
+}
+```
+
+Never bundle the two into one PTB. A pool-short `withdraw_all` fails with the `InsufficientFundsForWithdraw` execution status, and Move's atomic revert would take the `revoke_all` down with it, leaving the compromised cap live exactly when you need it dead. Withdraw-first is not a substitute either: deposits are permissionless, so anyone can re-fund the pool and re-arm a still-live allowance. Only [`revoke_all`](/contracts-sui/1.x/api/allowance#spend_vault-revoke_all) (or `destroy`) is durable.
+
+Transaction 1, from the owner account. Note `revoke_all` takes the bare `cap_id` unvalidated, and a wrong id is a silent whole no-op with no event, leaving the intended cap live:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::spend_vault::revoke_all @$VAULT @$OWNER_CAP @$SPENDER_CAP
+```
+
+**Checkpoint.** The transaction emits one `Revoked { was_present: true }` event per removed coin: for this vault, exactly one, for `0x2::sui::SUI`. Zero `Revoked` events means the `cap_id` missed; fix it before proceeding.
+
+Transaction 2, later, from the owner account. `withdraw_all` returns a possibly-zero `Balance`, consumed here as a `Coin`:
+
+```bash
+sui client ptb \
+ --move-call $PACKAGE::spend_vault::withdraw_all \
+ "<0x2::sui::SUI>" \
+ @$VAULT \
+ @$OWNER_CAP \
+ @0xacc \
+ --assign bal \
+ --move-call 0x2::coin::from_balance "<0x2::sui::SUI>" bal \
+ --assign recovered \
+ --transfer-objects "[recovered]" @$OWNER
+```
+
+If this fails with `InsufficientFundsForWithdraw`, a same-checkpoint pool change skewed the settled read; retry in the next checkpoint.
+
+## Teardown
+
+
+`destroy` drains only the budget ledger and deletes the vault's UID. It does not touch the pool, and no on-chain guard can stop a premature destroy: any coins still at the vault address strand permanently. Drain every coin type first, in a prior transaction, never in the same PTB.
+
+
+The drain ritual, in order:
+
+1. Enumerate every coin type at the vault address off-chain; the on-chain `granted_coin_types` view is not the drain list, since anyone can deposit coin types that were never granted. The GraphQL `balances` query reports the vault's address balances, including coin types deposited with a raw `send_funds` that no grant ever mentioned:
+
+ ```bash
+ curl -s $GRAPHQL_URL -X POST -H 'Content-Type: application/json' \
+ -d "{\"query\":\"{ address(address: \\\"$VAULT\\\") { balances { nodes { coinType { repr } totalBalance } } } }\"}"
+ ```
+
+2. If any loose `Coin` objects were transferred to the vault address (rather than deposited), recover them into the pool with `squash`, passing the receiving ticket.
+3. Run the `withdraw_all` PTB from the previous section once per coin type reported.
+4. Wait one checkpoint, then re-run the balances query; a same-checkpoint deposit is missed by the settled read, so the re-check is what catches late arrivals.
+5. Only when every balance reads zero, destroy the vault. Run this from the owner account; it consumes both the vault and the `OwnerCap`:
+
+ ```bash
+ sui client ptb \
+ --move-call $PACKAGE::spend_vault::destroy @$VAULT @$OWNER_CAP
+ ```
+
+**Checkpoint.** The transaction emits `VaultDestroyed`. If the delegate's cap was never reclaimed and destroyed, it is now orphaned; its holder can dispose of it with [`delete_orphaned_cap`](/contracts-sui/1.x/api/allowance#spend_vault-delete_orphaned_cap) (ungated, but the cap is consumed by value, so only its owner can call it). In this tutorial the cap is still custodied by the keeper `Service`, so the delegate must first take it back with `keeper::unregister`, then call `delete_orphaned_cap`.
+
+## Operational Checklist
+
+- Validate `spender_cap_vault_id` against the expected vault before taking any `SpenderCap` into custody.
+- Sender-gate every function that borrows a custodied cap; the library never checks the caller, so the custody layer's gate is the security boundary.
+- To recover funds, the owner withdraws anytime, unconditionally. To *durably* stop a compromised cap (so a later deposit cannot re-arm it), `revoke_all` that cap first in its own transaction, then `withdraw_all` per coin in a later one; never bundle them, and repeat per cap (or `destroy` the vault to end all).
+- Drain every coin type (off-chain enumeration via the GraphQL `balances` query, then `squash`, then `withdraw_all`, then wait a checkpoint and re-check) before calling `destroy`.
+- Confirm `AllowanceSet.was_created == false` on every budget update; `true` on an intended update means a mistyped `cap_id` provisioned a phantom budget.
+- On budget-only CAS updates, re-read and re-pass the current expiry; the upsert always overwrites `expires_at_ms`.
+- Treat pool-short failures as execution statuses, not Move aborts: preflight `spend`, `withdraw`, and `withdraw_all` with a dry run instead of matching an abort code.
+
+For function-level signatures, abort codes, and events, see the [Allowance API reference](/contracts-sui/1.x/api/allowance#spend_vault).
diff --git a/content/contracts-sui/1.x/index.mdx b/content/contracts-sui/1.x/index.mdx
index 368b80bc..3e336f93 100644
--- a/content/contracts-sui/1.x/index.mdx
+++ b/content/contracts-sui/1.x/index.mdx
@@ -2,12 +2,14 @@
title: Contracts for Sui 1.x
---
-**OpenZeppelin Contracts for Sui v1.x** ships four core packages:
+**OpenZeppelin Contracts for Sui v1.x** ships these core packages:
- `openzeppelin_math` for deterministic integer arithmetic, configurable rounding, and decimal scaling.
- `openzeppelin_fp_math` for 9-decimal fixed-point arithmetic (`UD30x9`, `SD29x9`) backed by `u128`.
- `openzeppelin_access` for role-based authorization and ownership-transfer wrappers around privileged `key + store` objects.
+- `openzeppelin_finance` for vesting: locking a coin for a beneficiary and releasing it on a schedule, with a built-in linear-with-cliff curve and a curve-agnostic core for custom schedules.
- `openzeppelin_utils` for embeddable building blocks; its first module, `rate_limiter`, provides multi-strategy rate limiting.
+- `openzeppelin_allowance` for cap-keyed spending allowances; its first module, `spend_vault`, escrows multi-coin funds behind owner-set, expiring budgets.
## Quickstart
@@ -35,9 +37,11 @@ mvr add @openzeppelin-move/utils
You only need the dependencies your app actually uses. Add what you need and drop the others.
+Packages not yet on the Move Registry - currently `openzeppelin_allowance` and `openzeppelin_finance` - are added by git revision instead of `mvr add` (see the next step).
+
### 3. Verify `Move.toml`
-`mvr add` updates `Move.toml` automatically. With all three installed it should include:
+`mvr add` updates `Move.toml` automatically. With all four installed it should include:
```toml
[dependencies]
@@ -47,6 +51,15 @@ openzeppelin_fp_math = { r.mvr = "@openzeppelin-move/fixed-point-math" }
openzeppelin_utils = { r.mvr = "@openzeppelin-move/utils" }
```
+Since `openzeppelin_allowance` and `openzeppelin_finance` aren't on MVR yet, add them manually, pinned to a git revision:
+
+```toml
+openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.4.0" }
+openzeppelin_finance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.4.0" }
+```
+
+Alternatively, copy the module sources directly into your package's `sources/` so you fully own the code; see each package guide for the per-package options.
+
### 4. Add a Minimal Module
Create `sources/quickstart.move`:
@@ -80,16 +93,20 @@ sui move test
## Picking a package
- Need role-based authorization for privileged functions or controlled transfer of admin/treasury/upgrade capabilities? Use [Access](/contracts-sui/1.x/access).
+- Need to vest a token grant, team/investor allocation, or payroll stream to a beneficiary over time? Use [Vesting Wallet](/contracts-sui/1.x/vesting-wallet).
- Need fractional values like prices, fees, rates, or signed deltas? Use [Fixed-Point Math](/contracts-sui/1.x/fixed-point).
- Need integer arithmetic with safe overflow and explicit rounding? Use [Integer Math](/contracts-sui/1.x/math).
- Need to throttle withdrawals, meter per-user budgets, gate action reuse, or delay an action? Use [Rate Limiter](/contracts-sui/1.x/rate-limiter).
+- Need to delegate bounded, expiring spending of escrowed coins to a keeper, service, or teammate, without signing each spend? Use [Spend Vault](/contracts-sui/1.x/spend-vault).
The packages compose. A typical protocol module imports `openzeppelin_math` for share math, `openzeppelin_fp_math` for rate and fee math, and `openzeppelin_access` for the admin capability that governs both.
## Next steps
-- Package guides: [Integer Math](/contracts-sui/1.x/math), [Fixed-Point Math](/contracts-sui/1.x/fixed-point), [Access](/contracts-sui/1.x/access), [Utilities](/contracts-sui/1.x/utils).
+- Package guides: [Integer Math](/contracts-sui/1.x/math), [Fixed-Point Math](/contracts-sui/1.x/fixed-point), [Access](/contracts-sui/1.x/access), [Utilities](/contracts-sui/1.x/utils), [Allowance](/contracts-sui/1.x/allowance), [Finance](/contracts-sui/1.x/finance).
- Access modules: [RBAC](/contracts-sui/1.x/access-control), [Two-Step Transfer](/contracts-sui/1.x/two-step-transfer), [Delayed Transfer](/contracts-sui/1.x/delayed-transfer).
- Utilities modules: [Rate Limiter](/contracts-sui/1.x/rate-limiter).
-- Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control).
-- API reference: [Integer Math](/contracts-sui/1.x/api/math), [Fixed-Point Math](/contracts-sui/1.x/api/fixed-point), [Access](/contracts-sui/1.x/api/access), [Utilities](/contracts-sui/1.x/api/utils).
+- Allowance modules: [Spend Vault](/contracts-sui/1.x/spend-vault).
+- Finance modules: [Vesting Wallet](/contracts-sui/1.x/vesting-wallet).
+- Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control), [Delegated Spending](/contracts-sui/1.x/guides/delegated-spending).
+- API reference: [Integer Math](/contracts-sui/1.x/api/math), [Fixed-Point Math](/contracts-sui/1.x/api/fixed-point), [Access](/contracts-sui/1.x/api/access), [Utilities](/contracts-sui/1.x/api/utils), [Allowance](/contracts-sui/1.x/api/allowance), [Finance](/contracts-sui/1.x/api/finance).
diff --git a/content/contracts-sui/1.x/spend-vault.mdx b/content/contracts-sui/1.x/spend-vault.mdx
new file mode 100644
index 00000000..d6e02ee2
--- /dev/null
+++ b/content/contracts-sui/1.x/spend-vault.mdx
@@ -0,0 +1,335 @@
+---
+title: Spend Vault
+---
+
+
+The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package.
+
+
+The `spend_vault` module provides a cap-keyed, multi-coin allowance primitive for Sui Move. A shared, key-only `Vault` escrows any number of coin types untyped: funds are not struct fields but object-owned address balances held at the vault's address, and a [`LinkedTable`](https://docs.sui.io/references/framework/sui/linked_table) ledger maps each `(cap, coin type)` pair to a budget. Each `SpenderCap` is a **bearer instrument**: whoever presents it to `spend` exercises the full authority of every budget keyed by that cap, regardless of who signed the transaction. One vault mints as many spender caps as the owner needs, typically one per delegate or user, each with its own per-`(cap, coin)` budgets. A single `OwnerCap` grants, revokes, and withdraws across all of them; funding is permissionless, and transferring the `OwnerCap` is owner rotation.
+
+## Use cases
+
+Use `spend_vault` when your protocol needs:
+
+- Keeper or automation top-ups drawn from an owner-set budget without the owner signing each spend.
+- Delegated treasury spending, where a teammate or contract draws against a ceiling.
+- Subscription-style pull payments a payee collects on their own schedule.
+- Per-partner budgets across multiple coin types from a single escrow.
+
+## Import
+
+```move
+use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap};
+```
+
+## Object model
+
+| Object | Role | Who holds it |
+|---|---|---|
+| `Vault` | Shared, key-only escrow for N coin types plus the `(cap, coin)` allowance ledger. Funds live as address balances at the vault's address, not in a struct field. | Shared object; everyone references it. |
+| `OwnerCap` | Sole owner authority: mint caps, set budgets, revoke, withdraw, destroy. Exactly one per vault for its whole life. | The vault owner. Transferring it rotates ownership. |
+| `SpenderCap` | Bearer spend authority, untyped: the coin dimension is the `T` at the `spend` call site. Carries no budget itself. One of many: a vault mints a separate cap per grantee. | One per grantee: a delegate, a user, or a protocol custodying it. |
+| Allowance entry | Private ledger record per `(cap, coin type)`: `remaining` budget and `expires_at_ms`. The single source of truth for what a cap may draw. | Inside the vault; managed by the owner via `set_allowance`. |
+
+Two `u64::MAX` sentinels apply, tested by equality only: `remaining == u64::MAX` means unlimited (never decremented) and `expires_at_ms == u64::MAX` means no expiry. A `(cap, coin)` pair is in one of three states: **absent** (never granted, or revoked), **suspended** (entry present with `remaining == 0`), or **live**. Reads alone cannot tell absent from suspended (`allowance` returns `0` for both), so use `contains` to disambiguate.
+
+## Quickstart
+
+Compose the whole setup in one function: create the vault, fund it, mint a cap, and grant a budget, returning all three objects by value so the enclosing PTB decides where each goes:
+
+```move
+module my_protocol::delegation;
+
+use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap};
+use sui::clock::Clock;
+use sui::coin::Coin;
+
+public fun open_allowance(
+ funding: Coin,
+ budget: u64,
+ expires_at_ms: u64, // pass std::u64::max_value!() for "no expiry"
+ clock: &Clock,
+ ctx: &mut TxContext,
+): (Vault, SpenderCap, OwnerCap) {
+ let (mut vault, owner_cap) = spend_vault::new(ctx);
+
+ // Permissionless top-up. Confers no rights; the funds become the owner's pool.
+ vault.deposit(funding, ctx);
+
+ // Bare cap, no budget yet. Returned by value, so the caller chooses its destination.
+ let cap = vault.mint_cap(&owner_cap, ctx);
+ let cap_id = object::id(&cap);
+
+ // Create the (cap, T) budget. `option::none()` = no CAS guard on a fresh create.
+ vault.set_allowance(&owner_cap, cap_id, budget, expires_at_ms, option::none(), clock, ctx);
+
+ // Hand the objects back; the caller shares the vault and routes the caps.
+ (vault, cap, owner_cap)
+}
+```
+
+The caller wires the edges in the same transaction:
+
+```move
+let (vault, cap, owner_cap) = delegation::open_allowance(
+ stake, 400, now_ms + 30 * DAY_MS, &clock, ctx,
+);
+
+// Compose the edges in the SAME tx: share the vault LAST, hand the cap to the delegate.
+vault.share();
+transfer::public_transfer(cap, delegate);
+transfer::public_transfer(owner_cap, ctx.sender());
+```
+
+`Vault` has `key` only and no `drop`, so the creating transaction must consume it with `share` or `destroy`, and `share` must come last, after every fund, mint, and grant step, because a shared vault is only addressable as a shared input in *later* transactions.
+
+## Spending
+
+`spend` draws exactly `amount` of `T` against the presented cap and returns a `Balance`. `Balance` has no `drop`, so it must be consumed in the same PTB. Because the vault's pool already lives as [address balances](https://docs.sui.io/onchain-finance/asset-custody/address-balances), the idiomatic delivery is to hand the drawn balance straight to a recipient's address balance with `balance::send_funds`, which creates no new object:
+
+```move
+/// Deliver the drawn balance directly to `recipient`'s address balance.
+public fun spend_to_address(
+ vault: &mut Vault,
+ cap: &SpenderCap,
+ recipient: address,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ vault.spend(cap, amount, clock, ctx).send_funds(recipient);
+}
+```
+
+Reach for a `Coin` only when you need an object to compose with Coin-based APIs, such as splitting, merging, or routing into another call. `into_coin` mints one from the drawn balance, at the cost of creating a new object:
+
+```move
+/// `spend` returns Balance, which has no `drop`: it must be consumed in the same PTB.
+public fun spend_to_wallet(
+ vault: &mut Vault,
+ cap: &SpenderCap,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+): Coin {
+ let bal = vault.spend(cap, amount, clock, ctx);
+ bal.into_coin(ctx)
+}
+```
+
+Both consume the `Balance` in the same PTB: prefer `send_funds` for native address-balance delivery, where deposits merge into the recipient's balance with no object management, and fall back to `into_coin` when a downstream call specifically needs a `Coin` object.
+
+Spends are exact-amount-or-abort: there is no partial fill, and on any abort the pool and ledger are bit-identical to their pre-call state. The abort order is deterministic and documented as an integrator ABI: `EWrongVault`, then `ENoAllowance`, `EAllowanceExpired`, `EZeroAmount`, `EAllowanceExceeded`, then the framework failure modes; see the [Allowance API reference](/contracts-sui/1.x/api/allowance#spend_vault) for each condition. A pool shortfall is *not* a Move abort at all (see the [FAQ](#faq)).
+
+## Integrating into a protocol
+
+A protocol custodies a user's `SpenderCap` and spends on their behalf, within the owner's budget, without the user signing each spend. This is the pattern the primitive is primarily built for: a custody layer holds the cap, an operator drives the spends, and the vault owner still holds the only authority over the budget.
+
+Two custody rules are load-bearing:
+
+- **The borrow entrypoint MUST be sender-gated.** A `SpenderCap` is a bearer instrument, and the library is cap-gated, never sender-gated: any code that gets the library to see `&cap` exercises its full authority. An ungated public function that borrows a custodied cap is world-drainable, so the operator check is the integration's security boundary, not optional hygiene.
+- **Validate the cap's vault binding before taking custody.** Check `spender_cap_vault_id` against the vault you intend to spend from at register time, so a cap bound to a different vault is rejected on entry rather than discovered at spend time.
+
+A minimal custody module, condensed from the [keeper service](/contracts-sui/1.x/guides/delegated-spending#build-the-keeper-service) built in the Delegated Spending tutorial:
+
+```move
+module my_protocol::keeper;
+
+use openzeppelin_allowance::spend_vault::{Vault, SpenderCap};
+use sui::balance::Balance;
+use sui::clock::Clock;
+use sui::table::{Self, Table};
+
+#[error(code = 0)]
+const ENotOperator: vector = "Caller is not the service operator";
+#[error(code = 1)]
+const EWrongVaultForService: vector =
+ "Cap is bound to a different vault than this service serves";
+#[error(code = 2)]
+const ENotRegistered: vector = "No cap registered under this user address";
+
+/// Shared keeper service. Serves exactly one `Vault` and custodies at most one cap
+/// per user. Untyped, so one service drives every coin a cap is budgeted for.
+public struct Service has key {
+ id: UID,
+ operator: address,
+ vault_id: ID,
+ caps: Table,
+}
+
+/// Create the service pinned to one vault id; the creator becomes the operator.
+/// Key-only, so the caller shares it with this module's `share` (as with the vault).
+public fun create(vault_id: ID, ctx: &mut TxContext): Service {
+ Service { id: object::new(ctx), operator: ctx.sender(), vault_id, caps: table::new(ctx) }
+}
+
+/// Share the service so users can register caps against it.
+public fun share(service: Service) {
+ transfer::share_object(service);
+}
+
+/// Validate the cap's vault binding BEFORE taking custody: the rule for ANY
+/// protocol that accepts a `SpenderCap`. Keyed by the registering sender.
+public fun register(s: &mut Service, cap: SpenderCap, ctx: &mut TxContext) {
+ assert!(cap.spender_cap_vault_id() == s.vault_id, EWrongVaultForService);
+ s.caps.add(ctx.sender(), cap);
+}
+
+/// SENDER-GATED: the library never checks who calls `spend`, so the custody layer
+/// must. Generic over `T`, so one custodied cap serves every budgeted coin; an
+/// ungranted coin fails safe inside the library with `ENoAllowance`.
+public fun execute(
+ s: &mut Service,
+ v: &mut Vault,
+ user: address,
+ amount: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+): Balance {
+ assert!(ctx.sender() == s.operator, ENotOperator);
+ assert!(s.caps.contains(user), ENotRegistered);
+ let cap = s.caps.borrow(user);
+ v.spend(cap, amount, clock, ctx)
+}
+
+/// Take a cap back out of custody, keyed to the registering sender. The grant stays
+/// live in the vault; only custody of the cap changes hands.
+public fun unregister(s: &mut Service, ctx: &mut TxContext): SpenderCap {
+ assert!(s.caps.contains(ctx.sender()), ENotRegistered);
+ s.caps.remove(ctx.sender())
+}
+```
+
+**Design properties to preserve:**
+
+- Keep exit self-service: `unregister` is keyed to the registering sender, with no operator or admin seize path.
+- The `Service` is key-only, so share it through your own module's `share` rather than at creation.
+- Custody at most one cap per user, keyed by address.
+
+**Design tip.** Keep the `Service` untyped and `execute` generic over `T`, so a single custodied cap serves every coin the owner budgeted it for; a request for an ungranted coin fails safe inside the library with `ENoAllowance`.
+
+The owner keeps full control throughout: raising, lowering, suspending, or revoking the grant never changes the cap object, so a custodied cap keeps working with no re-registration, and custody of the cap confers no control over the budget.
+
+For the full worked walkthrough (register, operator-driven spend, owner maintenance mid-custody, and teardown), see [Build the Keeper Service](/contracts-sui/1.x/guides/delegated-spending#build-the-keeper-service) in the Delegated Spending tutorial.
+
+## Managing budgets
+
+`set_allowance` is an **upsert** keyed on a bare `cap_id: ID` that is never validated against a live cap: a mistyped id silently provisions a fresh phantom budget instead of aborting. When you intend an update, confirm the emitted `AllowanceSet` event has `was_created == false`, or check `contains` first.
+
+Setting `new_amount` to `0` is the **suspension** idiom: the entry and cap stay alive, and the spender's next attempt aborts `EAllowanceExceeded`, a signal to ask for a raise. A revoked or never-granted pair aborts `ENoAllowance` instead, a signal to ask for a grant. Re-setting always overwrites, never adds, and setting a fresh future expiry on an expired entry revives it in place. None of these updates changes the cap object, so a protocol holding a custodied cap keeps working across every owner budget change with no re-registration (see [Integrating into a protocol](#integrating-into-a-protocol)).
+
+Concurrent-safe updates use the compare-and-set guard: read the current budget with `allowance` off-chain (or in a previous transaction), then pass that value as `expected` when you write. If a spend was sequenced after the read, the write aborts `EUnexpectedAllowance` instead of silently overwriting it. A read taken in the same transaction as the write trivially matches the guard and protects nothing: the shared vault is locked for the whole transaction, so an in-transaction read/write pair is already atomic without a guard.
+
+```move
+/// Raise / lower / renew with the CAS guard. `expected` is the budget the caller
+/// read via `allowance` off-chain (or in a previous transaction). If a spend was
+/// sequenced between that read and this transaction, the entry's `remaining` no
+/// longer equals `expected` and the call aborts `EUnexpectedAllowance` instead of
+/// clobbering the concurrent spend.
+public fun change_budget(
+ vault: &mut Vault,
+ owner_cap: &OwnerCap,
+ cap_id: ID,
+ expected: u64,
+ new_budget: u64,
+ new_expires_at_ms: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ vault.set_allowance(
+ owner_cap, cap_id, new_budget, new_expires_at_ms, option::some(expected), clock, ctx,
+ );
+}
+```
+
+The CAS guard matches `remaining` **only**; the upsert always overwrites `expires_at_ms` too, and any finite `new_expires_at_ms` must be strictly in the future or the call aborts `EExpiryInPast` (the `u64::MAX` sentinel always passes). A budget-only update must re-read the current expiry via `expiry()` and pass it back, but that works only while the entry is unexpired: expired entries are not pruned, so `expiry()` can return a past timestamp that `set_allowance` rejects. Updating an expired entry means choosing a new future expiry (or `u64::MAX`), which necessarily revives it.
+
+## Revoking and renouncing
+
+Four removal verbs with different scopes:
+
+- **`revoke`** (owner): removes one `(cap, coin)` entry. Idempotent and never blocked by ledger state; returns `was_present`, where `false` is the typo'd-`cap_id` or wrong-coin signal. Strictly per-coin: the cap object survives and remains live authority for any other coins still granted to it; only `revoke_all`/`renounce` zero the whole cap.
+- **`revoke_all`** (owner): removes every entry of one cap across all coin types ever granted; the cap object survives in its holder's wallet as inert non-authority. The `cap_id` is unvalidated: a wrong id is a silent whole no-op that emits nothing and leaves the intended cap live; confirm via the emitted `Revoked` events or a `contains` recheck.
+- **`renounce`** (spender): consumes the `SpenderCap` by value and removes all of its entries. Use it against a live vault.
+- **`delete_orphaned_cap`** (cap holder): consumes the cap by value, disposal only for a cap whose vault was already destroyed. It never aborts and touches no vault state.
+
+
+Calling `delete_orphaned_cap` on a cap whose vault is still live strands every one of its ledger entries as inert garbage: unspendable, but still occupying storage and visible via `contains`. On a live vault, spenders should `renounce` instead; if a live cap was already deleted, the owner cleans up with `revoke_all` using the `cap_id` from the `CapDeleted` event.
+
+
+## Owner exit and teardown
+
+`withdraw` and `withdraw_all` consult only the `OwnerCap` binding and the pool, never the ledger. No spender state can block owner exit, and withdrawing may leave live allowances unbacked (intended: budgets are ceilings, not reservations).
+
+`withdraw_all` drains the **settled** (start-of-checkpoint) pool via the [`AccumulatorRoot`](https://docs.sui.io/references/framework/sui/accumulator) (`0xacc`). A spend or withdraw earlier in the same checkpoint (including an earlier command in the same PTB) lowers the live pool below that snapshot, making the drain over-ask and fail with the `InsufficientFundsForWithdraw` execution status (retry-safe next checkpoint); a same-checkpoint deposit is simply missed.
+
+Getting funds out needs no ceremony: `withdraw` / `withdraw_all` anytime, and you are done. The ordering below matters only when you also need a *durable* kill of a still-live cap. Because deposits are permissionless, a drained pool can be re-funded (by anyone, or by you later) and re-arm still-live budgets, so `withdraw_all` alone is a reversible freeze, not a kill. When a cap is compromised and the pool could be re-funded, revoke it first, then drain, in separate transactions. `revoke_all` ends one cap's authority across all its coins, so repeat it per compromised cap, or `destroy` the vault to end every cap at once:
+
+```move
+// Emergency stop, in TWO separate transactions, never one PTB:
+//
+// Tx 1: kill the compromised cap's authority. revoke_all is per cap (all its
+// coins), so repeat per cap or destroy the vault to end all. It never touches
+// the pool, so no allowance state can race it into failure. (Withdrawing first
+// is NOT durable: deposits are permissionless, so anyone can re-arm a live allowance.)
+vault.revoke_all(&owner_cap, cap_id, ctx);
+
+// Tx 2 (later tx): drain each coin. withdraw_all reads the settled accumulator
+// (root = 0xacc); a prior same-checkpoint spend/withdraw makes it over-ask and
+// abort, while a same-checkpoint deposit is simply missed.
+let bal = vault.withdraw_all(&owner_cap, root, ctx);
+```
+
+Bundling them into one PTB is wrong in both orders: a pool-short `withdraw_all` would revert the `revoke_all` along with it.
+
+To destroy a vault, follow the drain ritual: enumerate the vault address's coin types off-chain with the GraphQL `balances` query, `squash` any stray coins sent as owned objects, `withdraw_all` each type, wait one checkpoint and re-check, then `destroy` in a separate transaction.
+
+
+`destroy` deletes the vault and owner cap and drains the allowance ledger, but it does **not** drain the pool, and no on-chain guard stops a premature destroy. Any funds still sitting in the vault's address balances strand permanently: the UID is gone and there is no recovery path. Drain every coin type first, in a prior transaction.
+
+
+## Security considerations
+
+- **Sender-gate any function that borrows a custodied `&SpenderCap`.** The library is cap-gated, never sender-gated: any code path that gets the library to see `&cap` exercises its full authority. A protocol that custodies caps and exposes an ungated public function borrowing one is world-drainable.
+- **Validate `spender_cap_vault_id` before taking custody.** A protocol accepting a user's cap must check the cap's vault binding against the expected vault at register time, not discover a mismatch at spend time.
+- **A leaked cap drains every budgeted coin.** One `SpenderCap` keys the budgets of all coin types granted to it; its exposure is the sum across coins. Treat caps like private keys, and mint one cap per delegate rather than sharing.
+- **`withdraw_all` is not a durable kill.** To exit, just withdraw. But deposits are permissionless, so a drained pool can be re-funded (by anyone, or by you later) and re-arm still-live budgets: withdrawing alone is a reversible freeze. To stop a live cap for good, use `revoke_all` or `destroy`.
+
+## Common mistakes
+
+| Mistake | What happens | How to fix |
+|---|---|---|
+| `destroy` before draining every coin type | Remaining pool funds strand permanently at the dead vault address | Follow the drain ritual: GraphQL `balances` query, `squash`, `withdraw_all` per type, wait a checkpoint, then destroy. |
+| Relying on `withdraw_all` to *permanently* stop a live cap | Not durable: a permissionless deposit re-arms the still-live budgets (a plain exit is fine, this just is not a kill) | For a durable kill, `revoke_all` (or `destroy`) first, then `withdraw_all` in a later transaction. |
+| Bundling `revoke_all` with `withdraw_all` in one PTB | A pool-short `withdraw_all` reverts the `revoke_all` with it, leaving the cap live | Run `revoke_all` in its own transaction; drain in a later one. |
+| Ungated public function borrowing a custodied cap | World-drainable: anyone can route spends through it | Sender-gate the borrow (assert the operator) before calling `spend`. |
+| `delete_orphaned_cap` on a cap of a live vault | Strands all its ledger entries as inert storage garbage | Spenders `renounce`; the orphan path is only for caps of destroyed vaults. |
+| Treating `u64::MAX` as a finite volume | It is the unlimited sentinel: never decremented, wrong in volume math | Exclude `u64::MAX` from off-chain accounting; it means "unlimited". |
+| Typo'd `cap_id` in `set_allowance` | Silently creates a phantom budget (`was_created: true`) and permanently records the coin type | Confirm `was_created == false` on updates, or preflight with `contains`. |
+
+## FAQ
+
+**How do I tell an absent grant from a suspended one?**
+`allowance` returns `0` for both. `contains` disambiguates: `allowance == 0 && contains == true` is suspended; `contains == false` is absent. The spender-side abort code carries the same signal: `EAllowanceExceeded` means suspended (ask for a raise), `ENoAllowance` means absent (ask for a grant).
+
+**Why doesn't a pool shortfall abort with a module error code?**
+Because the module deliberately does no pool pre-check. `spend` decrements the budget (`withdraw` touches no ledger state), then calls the framework's funds-accumulator withdraw; a shortfall surfaces as the `InsufficientFundsForWithdraw` execution status in effects and dry runs, not as a matchable Move abort code, and Move's atomic revert rolls any budget decrement back. Preflight by dry-running the transaction; don't try to match an abort code that intentionally does not exist.
+
+**Do two caps' budgets on the same coin sum against one pool?**
+Yes. Budgets are ceilings, not reservations: allowances may sum past the pool, and competing spenders are resolved by consensus sequencing (first sequenced, first served). If summing is not intended, size budgets against the pool or fund per-delegate vaults.
+
+## Examples
+
+Complete, runnable integration examples live in the package's [`examples/spend_vault/`](https://github.com/OpenZeppelin/contracts-sui/tree/main/contracts/allowance/examples/spend_vault) directory, one per integration boundary:
+
+- [`direct_delegation`](https://github.com/OpenZeppelin/contracts-sui/blob/main/contracts/allowance/examples/spend_vault/direct_delegation.move): the **direct delegation** pattern. An owner funds a vault and grants a capped, optionally expiring budget straight to a known address, which spends directly; the library API is the whole integration, no wrapper needed.
+- [`defi_keeper`](https://github.com/OpenZeppelin/contracts-sui/blob/main/contracts/allowance/examples/spend_vault/defi_keeper.move): the **protocol custody** pattern. A keeper service holds a user's `SpenderCap` and spends on their behalf within the owner's budget, showing the two custody rules: validate the cap's vault binding before taking custody, and sender-gate the borrowing entrypoint.
+
+
+These are unaudited illustrations of how the primitive can be integrated, not production-ready code.
+
+
+## Learn more
+
+For function-level signatures, parameters, events, and errors, see the [Allowance API reference](/contracts-sui/1.x/api/allowance#spend_vault). For an end-to-end walkthrough of custodied caps and keeper flows, see the [Delegated Spending](/contracts-sui/1.x/guides/delegated-spending) tutorial.
diff --git a/content/contracts-sui/1.x/vesting-wallet.mdx b/content/contracts-sui/1.x/vesting-wallet.mdx
new file mode 100644
index 00000000..d09e641b
--- /dev/null
+++ b/content/contracts-sui/1.x/vesting-wallet.mdx
@@ -0,0 +1,281 @@
+---
+title: Vesting Wallet
+---
+
+
+The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package.
+
+
+The `openzeppelin_finance` package locks a `Balance` for a single beneficiary and pays it out on a schedule. It splits into two modules: a curve-agnostic core, `vesting_wallet`, that owns release accounting and conservation of funds but never interprets time, and `vesting_wallet_linear`, the built-in linear-with-cliff curve that most integrators use directly. The curve - linear, stepped, cliff, or a custom shape you write - is a swappable concern layered on top of the core.
+
+Token grants are otherwise reimplemented ad hoc by every protocol that needs them, each re-deriving the same "how much has unlocked by now" arithmetic and the same release bookkeeping. `vesting_wallet` factors that into a reusable primitive: the core guarantees a beneficiary can never be paid more than was deposited and never the same funds twice, while the curve module decides only the shape of the unlock over time.
+
+## Use cases
+
+Use `openzeppelin_finance` when your protocol needs:
+
+- Employee, team, or advisor token grants that unlock in tranches after a cliff.
+- Investor vesting on a linear or stepped release schedule.
+- Payroll or salary streams that accrue continuously over a period.
+- Emission or reward schedules that pay a fixed total out over time.
+- A custom unlock curve (milestone-gated, oracle-gated, backloaded) built on a safe release-accounting core.
+- A protocol that drives vesting wallets generically - a DAO-gated grant, a treasury, a vesting factory - without committing to one schedule.
+
+## Import
+
+```move
+use openzeppelin_finance::vesting_wallet_linear; // the built-in curve, most integrators
+use openzeppelin_finance::vesting_wallet; // the curve-agnostic core
+```
+
+## Choosing a module
+
+| Module | Use it when |
+|---|---|
+| `vesting_wallet_linear` | You want standard token-grant vesting: a linear unlock, or `N` equal tranches, with an optional cliff. **Most integrators only need this module.** |
+| `vesting_wallet` | You're authoring a custom curve (milestone unlocks, oracle-gated release, exotic cliff shapes), or building a protocol that drives vesting wallets **curve-agnostically** - wrapping or gating release without committing to a schedule. |
+
+The core never ships a curve of its own. `vesting_wallet_linear` is both the curve most grants use and the reference template a custom curve module copies.
+
+## Quickstart
+
+A `VestingWallet` has `key + store`, so the constructor returns it by value and you pick the topology. The common path is to build a linear schedule, fund it, and **share** it - a shared wallet lets anyone poke `release`, and the funds always go to the recorded beneficiary regardless of who triggered the payout.
+
+Here a team grant vests over twelve equal monthly tranches after a three-month cliff, funded up front:
+
+```move
+module my_protocol::grant;
+
+use openzeppelin_finance::vesting_wallet_linear::{Self, Linear, Params};
+use openzeppelin_finance::vesting_wallet::VestingWallet;
+use sui::clock::Clock;
+use sui::coin::Coin;
+
+const DAY_MS: u64 = 86_400_000;
+
+/// Twelve monthly tranches (~30 days each) after a 90-day cliff, funded up front and
+/// shared so anyone can poke `release` on the beneficiary's behalf. The teardown cap
+/// goes to the beneficiary so they can reclaim the storage rebate once drained.
+public fun grant(beneficiary: address, start_ms: u64, funds: Coin, ctx: &mut TxContext) {
+ let (mut wallet, cap) = vesting_wallet_linear::new(
+ beneficiary,
+ start_ms,
+ 90 * DAY_MS, // cliff_ms
+ 30 * DAY_MS, // period_ms: one tranche per ~month
+ 12, // steps
+ ctx,
+ );
+ wallet.deposit(funds.into_balance());
+ transfer::public_share_object(wallet);
+ transfer::public_transfer(cap, beneficiary);
+}
+
+/// Permissionless: anyone can trigger a payout; funds always go to the beneficiary
+/// recorded at construction. Idempotent - a no-op if nothing new has vested.
+public fun claim(wallet: &mut VestingWallet, clock: &Clock) {
+ vesting_wallet_linear::release(wallet, clock);
+}
+
+/// A read-only "what can I claim right now?" query for clients.
+public fun claimable(wallet: &VestingWallet, clock: &Clock): u64 {
+ vesting_wallet_linear::releasable(wallet, clock)
+}
+```
+
+`release` evaluates the curve at the current `Clock`, computes the not-yet-released portion, and pays it into the beneficiary's address balance with `balance::send_funds` - no payout `Coin` object is minted. Nothing vests before the cliff; at the cliff boundary the curve jumps straight to the value for however many periods have already elapsed, then resumes its monthly cadence.
+
+## The linear curve
+
+`vesting_wallet_linear` vests funds in `steps` equal tranches, one every `period_ms`, after an optional cliff. **Continuous** linear vesting - a straight line rather than a staircase - is the same curve in the `period_ms = 1` limit, exposed as `new_continuous` (and `params_continuous`), which takes a single `duration_ms` instead of `period_ms` and `steps`.
+
+The cumulative vested total, piecewise over time:
+
+| Phase | Condition | Vested |
+|---|---|---|
+| Pre-start | `now < start_ms` | `0` |
+| Pre-cliff | `now < start_ms + cliff_ms` | `0` |
+| Mid-schedule | `k` full periods elapsed (`0 <= k < steps`) | `total * k / steps` (flat within a period, stepping up at each boundary) |
+| Ended | `now >= start_ms + period_ms * steps` | `total` (the full amount) |
+
+Two behaviors worth internalizing:
+
+- **The cliff gates the staircase; it does not shift it.** A cliff longer than one period releases several tranches at once as a catch-up the instant it elapses, then the schedule resumes its regular cadence - the end time is unchanged by the cliff.
+- **`total` is re-derived on every call** from `balance + released`. A deposit made after `start_ms` immediately participates at the current step proportion, so funding is not required up front - you can top up a live grant and the new funds vest against the same schedule.
+
+A continuous one-year payroll stream with a 90-day cliff, shared in a single call:
+
+```move
+public fun stream(beneficiary: address, start_ms: u64, ctx: &mut TxContext) {
+ // create_and_share_continuous builds the wallet and shares it in one call,
+ // returning the shared wallet's ID and its teardown cap.
+ let (_wallet_id, cap) = vesting_wallet_linear::create_and_share_continuous(
+ beneficiary,
+ start_ms,
+ 90 * DAY_MS, // cliff_ms
+ 365 * DAY_MS, // duration_ms
+ ctx,
+ );
+ transfer::public_transfer(cap, beneficiary);
+}
+```
+
+## Lifecycle
+
+1. **Create** - `new` (stepped) or `new_continuous` return the wallet by value plus its `DestroyCap`, so you can fund and choose a topology in the same PTB. `create_and_share` / `create_and_share_continuous` build and share in one call, returning the shared wallet's `ID` and the cap.
+2. **Fund** - `deposit` a `Balance`. 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.
+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 (and any settled funds 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.
+
+
+Coins sent to a destroyed wallet are stranded - after teardown the object address has no claim path. Before tearing a wallet down, halt any upstream emissions targeting it, claim any coins already transferred to its address, and sweep settled funds. Teardown authority travels with the `DestroyCap`, not the beneficiary, so route the cap to the party that should own the teardown decision (commonly the beneficiary or its controller); that party bears the strand risk.
+
+
+## Topologies: shared vs. owned
+
+Because the constructor returns the wallet by value, you choose how it lives on-chain:
+
+- **Shared** (recommended) - `transfer::public_share_object(wallet)`, or use `create_and_share`. Anyone can poke `release`, and the beneficiary always receives the funds regardless of who triggered it. This avoids any liveness risk: no single holder can withhold payouts.
+- **Owned** (fast path) - `transfer::public_transfer(wallet, holder)`. Only the holder can pass the wallet by `&mut`, so `release`, `deposit`, `receive_and_deposit`, `sweep_settled`, and `destroy_empty` are 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 claims each with `receive_and_deposit`) or by settling a `Balance` into the address (the holder pulls it in with `sweep_settled`).
+
+
+With an **owned** wallet, a holder who is not the beneficiary and turns uncooperative can withhold every payout - `release` needs `&mut`, which only the holder can produce, and there is no on-chain path for the beneficiary to force one. Prefer the shared topology, whose permissionless `release` sidesteps this.
+
+
+The `beneficiary` is fixed at construction. To rotate the recipient, point `beneficiary` at a consumer-owned object and rotate ownership of that object instead (see the beneficiary-as-object pattern in the [examples](#examples)).
+
+## Building on the core
+
+`vesting_wallet` is the curve-agnostic core. It never interprets the schedule - it only enforces release accounting and conservation of funds - so it serves two kinds of integrator: protocols that **drive vesting wallets without committing to a curve**, and authors of a **new curve**.
+
+`VestingWallet` is parameterized by three types chosen at construction:
+
+- `S` - the **schedule witness**: a `drop`-only struct declared by the curve module. It carries no data; minting a vested attestation or tearing the wallet down requires a value of `S`, so only the declaring module can do either.
+- `P` - the **schedule parameters** (`copy + drop + store`): the curve's stored configuration (start, duration, cliff, ...). Held in the wallet, opaque to it.
+- `C` - the **coin type** being vested.
+
+Because struct fields are module-private in Move, only the module that declares `S` and `P` can build a `VestingWallet` or advance it. This makes "a wallet without a curve" and "a wallet with the wrong parameters" unrepresentable - the type system, not a runtime check, binds every wallet to exactly its curve.
+
+### Curve-agnostic protocols
+
+A protocol that wraps, gates, or routes vesting - a DAO-gated grant, a treasury, an escrow, a vesting factory - should build on `vesting_wallet` and stay **generic over the curve** (`S`, `P`) rather than depend on any single schedule. One integration then works for every present and future curve.
+
+The core is designed for exactly this. Releasing funds needs only `&VestedAmount` and `&mut wallet` - it does **not** need the witness `S` - so a wrapper can nest a `VestingWallet`, hand out an immutable `&inner` (enough for any curve module to mint an attestation), keep `&mut inner` private, and re-expose `release` behind its own checks:
+
+```move
+module my_protocol::gated_vault;
+
+use openzeppelin_finance::vesting_wallet::{Self, VestingWallet, VestedAmount};
+
+/// Adds protocol gating around any vesting curve - note it stays generic over `S`, `P`.
+public struct GatedVault has key {
+ id: UID,
+ inner: VestingWallet,
+ // ... protocol state: pause flag, approval gate, ...
+}
+
+/// Hand out a read-only view so any curve module can mint an attestation against it.
+public fun inner(
+ self: &GatedVault,
+): &VestingWallet {
+ &self.inner
+}
+
+/// Re-expose release behind protocol checks, then delegate. `&mut inner` never escapes.
+public fun release(
+ self: &mut GatedVault,
+ vested: &VestedAmount,
+) {
+ // ... enforce protocol invariants (not paused, caller approved, ...) ...
+ self.inner.release(vested);
+}
+```
+
+The caller picks the curve module at the call site; the vault never knows which curve it holds:
+
+```move
+let v = vesting_wallet_linear::vested_amount(vault.inner(), clock);
+vault.release(&v);
+```
+
+To build the inner wallet without depending on a curve's `new`, take a validated `P` from the curve module's `params` constructor and call the core directly - so the protocol owns topology and nesting while the curve module still owns validation:
+
+```move
+let params = vesting_wallet_linear::params(start_ms, cliff_ms, period_ms, steps);
+let (inner, cap) = vesting_wallet::new(params, beneficiary, ctx);
+// Keep `cap` - it is required to tear the wallet down later.
+```
+
+### Custom schedules
+
+To author a new curve, follow the `vesting_wallet_linear` pattern:
+
+1. Declare a witness `public struct MyCurve has drop {}` and a parameters struct `public struct MyParams has copy, drop, store { /* ... */ }`.
+2. A public `params` constructor that validates and returns `MyParams`, plus a `new` that is sugar over `vesting_wallet::new(params(..), beneficiary, ctx)`. Exposing `params` separately lets a curve-agnostic protocol build the wallet itself.
+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(root)` for a `DestroyReceipt`, then `vesting_wallet::consume_receipt(receipt, cap, MyCurve {})` to recover and destructure the parameters. `destroy_empty` is permissionless; the witness-and-cap-gated `consume_receipt` is what lets the curve run teardown logic or veto.
+
+
+A custom curve **must be monotonically non-decreasing in time and bounded above by `balance + released`.** The wallet trusts the witness and never re-derives the curve, so a curve that mints a dishonest amount against its own wallet can over-release up to the wallet's balance. `release` enforces only the fund-threatening failures: a regression *below* `released` aborts `EVestedBelowReleased`, and exceeding `balance + released` aborts `EInsufficientBalance` - both before any state change. An in-range regression does **not** abort; `release` silently pays the smaller increment. Keep the curve monotone so releases only ever move forward.
+
+
+### The `VestedAmount` attestation
+
+`vested_amount` mints a `VestedAmount` - a transient record that curve `S` has vested a given cumulative total for a specific wallet. It is **not a hot potato**: it has only `drop`, so it cannot be copied, stored, or held across transactions, and `release` borrows it rather than consuming it. It cannot be used to over-release - `release` pays `attested - released` and reads `released` fresh each call - and its `wallet_id` stamp rejects it against any other wallet. That stamp is what lets a curve-agnostic wrapper safely hand out `&inner`: the worst a holder can do is mint an inert attestation; no funds move without `&mut`, which the wrapper keeps private.
+
+## Key concepts
+
+- **Curve and core are split by type.** The core (`vesting_wallet`) enforces accounting; the curve module (`vesting_wallet_linear` or your own) owns the schedule. The witness `S` and parameters `P` bind a wallet to exactly one curve at the type level.
+
+- **Identity is data, not a capability.** Both `deposit` and `release` are permissionless - anyone can fund a wallet or trigger a release, and funds always go to the `beneficiary` recorded at construction. The shared topology leans on this for liveness.
+
+- **Re-derived total.** The vested amount is always computed from `balance + released`, so post-start deposits participate retroactively at the current curve proportion. Funding up front is optional.
+
+- **Teardown authority is a cap, not an address.** `new` mints a `DestroyCap` bound to the wallet. Teardown is gated on that cap (deliberately decoupled from `beneficiary`, which may be an object address that can never be a transaction sender), plus the curve's own gate - `vesting_wallet_linear::destroy` additionally requires the schedule to have ended.
+
+- **No payout coin is minted.** `release` settles into the beneficiary's address balance via `balance::send_funds`; there is no `Coin` object to route.
+
+## Common mistakes
+
+| Mistake | What happens | How to fix |
+|---|---|---|
+| Assuming a cliff delays the whole schedule | The cliff only gates; the end time is unchanged. A long cliff dumps all elapsed tranches at once when it lifts, then resumes cadence | Size the cliff knowing the staircase keeps its original boundaries; the catch-up at the cliff is expected. |
+| Choosing the **owned** topology for a third-party grant | A non-beneficiary holder can withhold every payout - `release` needs `&mut` | Use the **shared** topology so `release` is permissionless, unless the holder is the beneficiary. |
+| Tearing down while funds are still inbound | Coins sent to a destroyed wallet's address are permanently stranded | Halt upstream emissions, claim transferred coins, `sweep_settled`, let a checkpoint elapse, then `destroy` (which also requires the schedule to have ended). |
+| Gating teardown on `ctx.sender() == beneficiary` | An object-address beneficiary is never a sender, so the check can never pass | Gate teardown on the `DestroyCap`; route the cap to whoever should own the decision. |
+| A custom curve that is not monotone or exceeds `balance + released` | Over-release up to the balance (dishonest amount), or an abort (`EVestedBelowReleased` / `EInsufficientBalance`) | Keep the curve monotonically non-decreasing and bounded by `balance + released`. |
+| Expecting `release` to mint a `Coin` | It pays into the beneficiary's address balance via `send_funds`; no coin object appears | Read the payout from the beneficiary's balance, not from a returned coin. |
+| Wrapping a wallet but only re-exposing `release` | A `Coin` sent to the inner wallet's address can't be claimed while wrapped - `receive_and_deposit` needs the private `&mut inner` | Re-expose `receive_and_deposit` alongside `release` if the wrapper supports address-targeted funding. |
+
+## FAQ
+
+**Do most integrators need the `vesting_wallet` core?**
+No. If you want linear or stepped vesting with an optional cliff, `vesting_wallet_linear` is the whole surface - you never construct a bare wallet or mint a `VestedAmount` by hand. Reach for the core only to author a custom curve or to drive wallets curve-agnostically.
+
+**Can I fund a wallet after it has already started vesting?**
+Yes. `deposit` is permissionless and the vested total is re-derived from `balance + released` on every call, so a later deposit vests against the same schedule at the current proportion.
+
+**How do I change the beneficiary after construction?**
+You can't rotate the `beneficiary` field directly - it is fixed at construction. Point it at a consumer-owned object and rotate ownership of that object instead.
+
+**Does the module emit events?**
+Yes. The core emits `Created`, `Deposited`, `Swept`, `Received`, `Released`, and `Destroyed`, each stamped with the wallet's `ID`. Zero-amount operations emit nothing.
+
+**Can I release into a `Coin` object instead of an address balance?**
+No. `release` uses `balance::send_funds` to settle into the beneficiary's address balance; no payout coin is minted.
+
+## Examples
+
+Complete, runnable integration examples live in the package's [`examples/vesting_wallet/`](https://github.com/OpenZeppelin/contracts-sui/tree/main/contracts/finance/examples/vesting_wallet) directory, one per integration boundary:
+
+- [`vesting_quadratic`](https://github.com/OpenZeppelin/contracts-sui/blob/main/contracts/finance/examples/vesting_wallet/vesting_quadratic.move) - the **custom schedule** pattern: a backloaded `vested = total * (elapsed / duration)^2` curve, the minimal shape of a new curve on top of the core.
+- [`pausable_grant`](https://github.com/OpenZeppelin/contracts-sui/blob/main/contracts/finance/examples/vesting_wallet/pausable_grant.move) - the **curve-agnostic wrapper** pattern: a shared grant that nests a wallet, stays generic over `S`/`P`, and re-exposes `release` behind a cap-toggled pause check.
+- [`splitter`](https://github.com/OpenZeppelin/contracts-sui/blob/main/contracts/finance/examples/vesting_wallet/splitter.move) - the **beneficiary-as-object** pattern: point the wallet's `beneficiary` at a shared object so each release settles into an accumulator anyone can `disperse` to many receivers by fixed weights.
+
+
+These are unaudited illustrations of how the primitive can be integrated, not production-ready code.
+
+
+## Learn more
+
+For function-level signatures, parameters, events, and errors, see the [Finance API reference](/contracts-sui/1.x/api/finance).
diff --git a/content/contracts-sui/index.mdx b/content/contracts-sui/index.mdx
index ba9f99a1..83320ff4 100644
--- a/content/contracts-sui/index.mdx
+++ b/content/contracts-sui/index.mdx
@@ -6,7 +6,7 @@ description: OpenZeppelin Contracts for Sui is a library of secure, audited, pro
import { latestStable } from "./latest-versions.js";
-**Using this library with an AI agent?** Start from [`llms.txt`](https://raw.githubusercontent.com/OpenZeppelin/contracts-sui/main/llms.txt) — a machine-readable entry point to every package, its examples, API reference, and MVR install snippet.
+**Using this library with an AI agent?** Start from [`llms.txt`](https://raw.githubusercontent.com/OpenZeppelin/contracts-sui/main/llms.txt) - a machine-readable entry point to every package, its examples, API reference, and MVR install snippet.
## Getting Started
@@ -23,6 +23,9 @@ import { latestStable } from "./latest-versions.js";
Role-based authorization and ownership-transfer policies for privileged protocol operations.
+
+ Cap-keyed, multi-coin spending allowances over an escrowed vault of funds.
+
9-decimal fixed-point types (`UD30x9`, `SD29x9`) for prices, fees, rates, and signed balance deltas.
@@ -40,6 +43,9 @@ import { latestStable } from "./latest-versions.js";
Explore the complete access API, including its functions, types, events, and errors.
+
+ Explore the complete allowance API, including the spend vault types, functions, events, and errors.
+
Explore the complete fixed-point math API, including its types, functions, and errors.
diff --git a/src/navigation/sui/current.json b/src/navigation/sui/current.json
index 55aef69f..33b513af 100644
--- a/src/navigation/sui/current.json
+++ b/src/navigation/sui/current.json
@@ -21,6 +21,11 @@
"type": "page",
"name": "Role Based Access Control",
"url": "/contracts-sui/1.x/guides/access-control"
+ },
+ {
+ "type": "page",
+ "name": "Delegated Spending",
+ "url": "/contracts-sui/1.x/guides/delegated-spending"
}
]
},
@@ -65,6 +70,23 @@
}
]
},
+ {
+ "type": "folder",
+ "name": "Finance",
+ "defaultOpen": true,
+ "index": {
+ "type": "page",
+ "name": "Overview",
+ "url": "/contracts-sui/1.x/finance"
+ },
+ "children": [
+ {
+ "type": "page",
+ "name": "Vesting Wallet",
+ "url": "/contracts-sui/1.x/vesting-wallet"
+ }
+ ]
+ },
{
"type": "folder",
"name": "Utils",
@@ -81,6 +103,23 @@
"url": "/contracts-sui/1.x/rate-limiter"
}
]
+ },
+ {
+ "type": "folder",
+ "name": "Allowance",
+ "defaultOpen": true,
+ "index": {
+ "type": "page",
+ "name": "Overview",
+ "url": "/contracts-sui/1.x/allowance"
+ },
+ "children": [
+ {
+ "type": "page",
+ "name": "Spend Vault",
+ "url": "/contracts-sui/1.x/spend-vault"
+ }
+ ]
}
]
},
@@ -103,10 +142,20 @@
"name": "Access",
"url": "/contracts-sui/1.x/api/access"
},
+ {
+ "type": "page",
+ "name": "Finance",
+ "url": "/contracts-sui/1.x/api/finance"
+ },
{
"type": "page",
"name": "Utils",
"url": "/contracts-sui/1.x/api/utils"
+ },
+ {
+ "type": "page",
+ "name": "Allowance",
+ "url": "/contracts-sui/1.x/api/allowance"
}
]
}