From bd978476820d1356f3eeae7ae17d4457fb8faa04 Mon Sep 17 00:00:00 2001 From: kosedogus Date: Fri, 19 Jun 2026 18:13:07 +0200 Subject: [PATCH] feat: add allowance spend_vault module Add the openzeppelin_allowance package: a shared, untyped Vault that escrows multiple coin types as object-owned address balances and grants per-(cap, coin) spending allowances to capability holders. An owner funds the vault and issues SpenderCaps with capped, optionally expiring, revocable budgets that delegates draw on demand via spend, without surrendering custody. Includes capability-gated spend, owner-side set/revoke/revoke_all/suspend controls, CAS-guarded updates, and unconditional owner withdraw/teardown. Ships the module, unit tests, integration examples under examples/spend_vault, and a package README; registers the package in the CI matrix and adds a CHANGELOG entry and the contracts index row. --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 6 + contracts/README.md | 1 + contracts/allowance/Move.lock | 23 + contracts/allowance/Move.toml | 6 + contracts/allowance/README.md | 129 ++ .../examples/spend_vault/defi_keeper.move | 128 ++ .../spend_vault/direct_delegation.move | 167 ++ .../examples/spend_vault/example_coin.move | 50 + .../spend_vault/tests/defi_keeper_tests.move | 286 +++ .../tests/direct_delegation_tests.move | 187 ++ .../spend_vault/tests/example_coin_tests.move | 26 + contracts/allowance/sources/spend_vault.move | 1547 +++++++++++++++++ .../tests/spend_vault/cap_budget_tests.move | 1098 ++++++++++++ .../spend_vault/composability_tests.move | 287 +++ .../tests/spend_vault/fund_tests.move | 294 ++++ .../tests/spend_vault/isolation_tests.move | 524 ++++++ .../tests/spend_vault/lifecycle_tests.move | 482 +++++ .../tests/spend_vault/reads_tests.move | 369 ++++ .../tests/spend_vault/revoke_tests.move | 861 +++++++++ .../tests/spend_vault/spend_tests.move | 538 ++++++ .../tests/spend_vault/sv_test_utils.move | 122 ++ .../tests/spend_vault/withdraw_tests.move | 239 +++ 23 files changed, 7371 insertions(+), 1 deletion(-) create mode 100644 contracts/allowance/Move.lock create mode 100644 contracts/allowance/Move.toml create mode 100644 contracts/allowance/README.md create mode 100644 contracts/allowance/examples/spend_vault/defi_keeper.move create mode 100644 contracts/allowance/examples/spend_vault/direct_delegation.move create mode 100644 contracts/allowance/examples/spend_vault/example_coin.move create mode 100644 contracts/allowance/examples/spend_vault/tests/defi_keeper_tests.move create mode 100644 contracts/allowance/examples/spend_vault/tests/direct_delegation_tests.move create mode 100644 contracts/allowance/examples/spend_vault/tests/example_coin_tests.move create mode 100644 contracts/allowance/sources/spend_vault.move create mode 100644 contracts/allowance/tests/spend_vault/cap_budget_tests.move create mode 100644 contracts/allowance/tests/spend_vault/composability_tests.move create mode 100644 contracts/allowance/tests/spend_vault/fund_tests.move create mode 100644 contracts/allowance/tests/spend_vault/isolation_tests.move create mode 100644 contracts/allowance/tests/spend_vault/lifecycle_tests.move create mode 100644 contracts/allowance/tests/spend_vault/reads_tests.move create mode 100644 contracts/allowance/tests/spend_vault/revoke_tests.move create mode 100644 contracts/allowance/tests/spend_vault/spend_tests.move create mode 100644 contracts/allowance/tests/spend_vault/sv_test_utils.move create mode 100644 contracts/allowance/tests/spend_vault/withdraw_tests.move diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 359303d..9dac801 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: - name: Set package matrix id: set-matrix run: | - PACKAGES='["contracts/access", "contracts/utils", "math/core", "math/fixed_point"]' + PACKAGES='["contracts/allowance", "contracts/access", "contracts/utils", "math/core", "math/fixed_point"]' echo "packages=$PACKAGES" >> $GITHUB_OUTPUT - name: Cache Sui binary diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f22c8f..d3ff1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## Unreleased +### `openzeppelin_allowance` + +#### Added + +- New `spend_vault` module: a shared vault that escrows multiple coin types and grants per-`(cap, coin)` spending allowances to capability holders, with capped, optionally expiring, revocable delegated spend and owner-side revoke / suspend controls. (#402) + ## 1.3.0 (15-06-2026) ### `openzeppelin_math` diff --git a/contracts/README.md b/contracts/README.md index 0596227..3e9dff8 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -8,5 +8,6 @@ OpenZeppelin building blocks for Sui smart contracts development. | Package | MVR | Move package | Docs | Highlights | |---------|----------|--------------|------|-----------| +| [`allowance/`](allowance/) | [`@openzeppelin-move/allowance`](https://www.moveregistry.com/package/@openzeppelin-move/allowance) | `openzeppelin_allowance` | [docs](https://docs.openzeppelin.com/contracts-sui/1.x/allowance) | Capability-keyed, multi-coin spending allowances: an owner funds a shared vault and grants capped, optionally expiring, revocable spend rights that delegates draw on demand. See [`allowance/examples/spend_vault/`](allowance/examples/spend_vault) for integration examples. | | [`access/`](access/) | [`@openzeppelin-move/access`](https://www.moveregistry.com/package/@openzeppelin-move/access) | `openzeppelin_access` | [docs](https://docs.openzeppelin.com/contracts-sui/1.x/access) | Transfer policies that wrap privileged capabilities and guard ownership handoffs (two-step approvals and time-locked transfers). | | [`utils/`](utils/) | [`@openzeppelin-move/utils`](https://www.moveregistry.com/package/@openzeppelin-move/utils) | `openzeppelin_utils` | [docs](https://docs.openzeppelin.com/contracts-sui/1.x/utils) | Embeddable primitives for everyday module logic, starting with a unified rate-limiter (token bucket, fixed window, cooldown). See [`utils/examples/rate_limiter/`](utils/examples/rate_limiter) for integration examples. | diff --git a/contracts/allowance/Move.lock b/contracts/allowance/Move.lock new file mode 100644 index 0000000..cbf5d30 --- /dev/null +++ b/contracts/allowance/Move.lock @@ -0,0 +1,23 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "718ae563a42fb4ba0d055588f81c704dcef58c25" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.openzeppelin_allowance] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/contracts/allowance/Move.toml b/contracts/allowance/Move.toml new file mode 100644 index 0000000..f5f1077 --- /dev/null +++ b/contracts/allowance/Move.toml @@ -0,0 +1,6 @@ +[package] +name = "openzeppelin_allowance" +edition = "2024" + +[addresses] +openzeppelin_allowance = "0x0" diff --git a/contracts/allowance/README.md b/contracts/allowance/README.md new file mode 100644 index 0000000..3e738c5 --- /dev/null +++ b/contracts/allowance/README.md @@ -0,0 +1,129 @@ +# `openzeppelin_allowance` + +Capability-keyed, multi-coin spending allowances for Sui: an owner funds a shared vault and grants bounded, optionally expiring, revocable spend authority that delegates draw on demand, without giving up custody and without signing each spend. + +The `openzeppelin_allowance` package lets a treasury, protocol, or wallet delegate "you may spend up to X of coin T" to another party (an address, a keeper service, an embedded protocol record) while keeping the funds, the ability to raise or lower the budget at any time, and a one-call kill switch. + +> [!WARNING] +> A `SpenderCap` is a **bearer instrument**: whoever can present it to `spend` exercises the full authority of every budget it keys, up to each budget's limit. The library never checks who holds or presents a cap. Any protocol that custodies a cap MUST sender-gate the function that borrows it, and MUST validate the cap's vault binding before accepting it. See [Security Notes](#security-notes). + +## Install + +```toml +[dependencies] +openzeppelin_allowance = { r.mvr = "@openzeppelin-move/allowance" } +``` + +## Module Snapshot + +| Module | Summary | +|--------|---------| +| `spend_vault` | A shared, untyped vault that escrows many coin types and grants per-`(cap, coin)` spend budgets to capability holders. | + +--- + +## Spend Vault + +One shared `Vault` holds N coin types at once. Funds are not a struct field: each coin lives as an object-owned address balance at the vault's own address. Authority is split across two transferable capabilities, and every budget lives in a ledger keyed by `(cap_id, coin_type)`, never in the cap itself. + +| Object | Role | +|--------|------| +| `Vault` (shared) | The escrow and the per-`(cap, coin)` budget ledger. Created and shared once. | +| `OwnerCap` (owned, transferable) | Full control: mint spender caps, set / raise / lower / suspend / revoke budgets, withdraw funds, destroy the vault. Exactly one per vault; transferring it is owner rotation. | +| `SpenderCap` (owned, bearer) | Spend authority. Carries no budget; the owner grants per-coin budgets against its id. Whoever holds it can spend every budget it keys. | +| `Balance` (returned) | `spend`, `withdraw`, and `withdraw_all` hand back a `Balance` with no `drop`, so the caller must consume it in the same transaction. | +| `Clock` (`0x6`), `AccumulatorRoot` (`0xacc`) | Shared system objects that the time-checked and pool-reading calls take by reference. | + +### When to use it + +| Use it when | +| --- | +| A protocol or keeper should spend from a user's funds on their behalf, repeatedly, within a budget the user sets and can revoke, without the user signing each spend. | +| A treasury or DAO issues bounded, expiring, auditable spend grants to contributors or sub-agents and wants to raise, lower, or cut them at any time. | +| A wallet or app offers an "approve up to X" allowance UX over real custody rather than a per-transaction signature. | + +### Lifecycle + +1. **Create and fund** - `new` returns the `Vault` and its `OwnerCap` by value; `deposit` (or a raw address-balance top-up) funds the pool; `share` makes the vault usable. Compose all of this in one PTB before `share`. +2. **Grant** - `mint_cap` returns a bare `SpenderCap`; `set_allowance` creates or overwrites the `(cap, coin)` budget. `0` suspends (keeps the cap), `u64::MAX` means unlimited / no expiry, and an optional CAS guard makes read-then-write races safe. +3. **Spend** - the cap holder calls `spend` for exactly `amount`, receiving a `Balance` to route onward. Spending is cap-gated, never sender-gated. +4. **Manage** - the owner raises, lowers, or suspends a live grant in place with `set_allowance` (the cap object is never invalidated), ends one coin with `revoke`, or kills an entire cap with `revoke_all`. A spender can self-revoke with `renounce`. +5. **Exit and teardown** - the owner withdraws funds at any time with `withdraw` / `withdraw_all`, then `destroy`s the drained vault. Owner exit is never blocked by spender state. + +### Usage + +```move +use openzeppelin_allowance::spend_vault; +use sui::clock::Clock; +use sui::coin::Coin; + +// Owner: create a vault, fund it, mint a cap for a delegate, grant a capped budget, then share. +public fun open( + funding: Coin, + delegate: address, + budget: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + let (mut vault, owner_cap) = spend_vault::new(ctx); + + vault.deposit(funding, ctx); // permissionless top-up; confers no rights + + let cap = vault.mint_cap(&owner_cap, ctx); // bare cap, no budget yet + let cap_id = object::id(&cap); + transfer::public_transfer(cap, delegate); + + // Grant the (cap, T) budget. u64::MAX expiry = no expiry; option::none() = no CAS guard on create. + vault.set_allowance( + &owner_cap, cap_id, budget, std::u64::max_value!(), option::none(), clock, ctx, + ); + + vault.share(); // every fund / mint / grant step must precede share + transfer::public_transfer(owner_cap, ctx.sender()); +} +``` + +```move +use openzeppelin_allowance::spend_vault::{Self, Vault, SpenderCap}; +use sui::clock::Clock; +use sui::coin; + +// Spender: draw `amount` of T against the held cap and take it as a wallet coin. +// `spend` returns a Balance with no `drop`, so it must be consumed in the same PTB. +public fun draw( + vault: &mut Vault, + cap: &SpenderCap, + amount: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + let bal = vault.spend(cap, amount, clock, ctx); + transfer::public_transfer(coin::from_balance(bal, ctx), ctx.sender()); +} +``` + +### Examples + +> [!Warning] +> These are **unaudited illustrations** of how the primitive can be integrated, not production-ready code. + +Complete integration examples live in [`examples/spend_vault/`](examples/spend_vault): + +- [`direct_delegation`](examples/spend_vault/direct_delegation.move) - an owner funds a vault and delegates a capped, optionally expiring budget straight to a known address; the delegate spends directly and the owner manages and tears down the grant. The library API is the whole integration, no wrapper needed. +- [`defi_keeper`](examples/spend_vault/defi_keeper.move) - a protocol custodies a user's `SpenderCap` and spends on the user's behalf within the owner's budget. Shows the two custody rules every cap-holding protocol must follow: validate the cap's vault binding before accepting it, and sender-gate the cap-borrowing entrypoint, because a bearer cap is otherwise world-drainable. + +## Security Notes + +- **Bearer model.** A `SpenderCap` confers authority by possession; the library never inspects who holds or presents it. A protocol that custodies a cap MUST sender-gate the function that borrows it (an ungated public borrow is world-drainable), and MUST validate `spender_cap_vault_id` against the intended vault before accepting a cap. +- **Budgets are ceilings, not reservations.** Allowances may sum to more than the pool by design. A live, unexpired, within-budget `spend` can still fail when the pool is short; competing spenders are resolved by consensus sequencing (first sequenced, first served). The pool-short failure is the Sui execution status `InsufficientFundsForWithdraw` (a funds-accumulator `ExecutionFailureStatus`), raised at `redeem_funds` when the object's settled balance is below the amount and surfaced via the SDK / a dry run, NOT one of this module's abort codes and NOT a matchable Move `#[error]` code, so integrator preflight must dry-run the withdraw path rather than match a code. +- **Sentinels.** `remaining == u64::MAX` means unlimited (never decremented) and `expires_at_ms == u64::MAX` means no expiry. Off-chain tooling must exclude `u64::MAX` from volume math. +- **Owner exit is unconditional.** `withdraw`, `withdraw_all`, and `destroy` consult only the owner-cap binding and the pool, never the ledger, so no spender or ledger state can block defunding or teardown. +- **Drain before destroy.** `destroy` deletes the vault and its budget ledger but does NOT drain the pool. Withdraw every coin first (enumerate the vault address's coin types off-chain), or any remaining funds strand permanently at the dead vault address. Drain in a transaction prior to `destroy`, never the same PTB. +- **Emergency stop.** Funds withdrawal is reversible (deposits are permissionless, so anyone can re-arm a live allowance by topping up the pool). The durable kill is `revoke_all` (or `destroy`). For an emergency, run `revoke_all` first in its own transaction (it never touches the pool, so it cannot be raced into failure), then `withdraw_all` per coin in a later transaction. Do not bundle the two in one PTB. +- **Consume the returned balance.** `spend` / `withdraw` / `withdraw_all` return a `Balance` with no `drop`. Route it in the same PTB (into a `Coin`, back into escrow, or a downstream call). + +## Learn More + +- [Allowance package overview](https://docs.openzeppelin.com/contracts-sui/1.x/allowance) +- [Allowance API reference](https://docs.openzeppelin.com/contracts-sui/1.x/api/allowance) +- [OpenZeppelin Contracts for Sui](https://docs.openzeppelin.com/contracts-sui) diff --git a/contracts/allowance/examples/spend_vault/defi_keeper.move b/contracts/allowance/examples/spend_vault/defi_keeper.move new file mode 100644 index 0000000..3a87d44 --- /dev/null +++ b/contracts/allowance/examples/spend_vault/defi_keeper.move @@ -0,0 +1,128 @@ +/// Advanced usage: a protocol custodies a user's `SpenderCap` and spends on their +/// behalf. This is the library's primary use case. +/// +/// A keeper service holds users' caps and draws from their vaults within the +/// per-coin budget and expiry the vault owner set, without the owner signing each +/// spend. The service is untyped and `execute_topup` is generic, so one custodied +/// cap can be driven for every coin the owner budgeted it for. +/// +/// #### The flow an integrator must get right +/// +/// 1. Create a `Service` pinned to exactly ONE vault id and share it. Pinning up +/// front is what makes the step-2 binding check meaningful. +/// 2. The user mints a cap (`mint_cap` returns it by value) and hands it into +/// custody via `register`, which validates the cap's vault binding +/// (`spender_cap_vault_id`) BEFORE accepting it. This is the custody-boundary +/// rule for ANY protocol that takes a `SpenderCap`. +/// 3. The operator calls `execute_topup` to draw coin `T`. **This is +/// sender-gated, and the gate is the point of this module:** a `SpenderCap` is a +/// bearer instrument, so 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. +/// 4. The user reclaims the cap any time with `unregister`. +/// +/// The vault owner keeps full control throughout: raising, lowering, suspending, or +/// revoking the grant (`set_allowance` / `revoke` / `revoke_all`) never changes the +/// cap object, so a cap embedded here keeps working and is never re-registered. +/// +/// # Disclaimer +/// +/// This module is an **unaudited example**, provided purely to illustrate ways the +/// `spend_vault` allowance primitive can be integrated. It is not production-ready and +/// must not be deployed as-is. +module openzeppelin_allowance::defi_keeper; + +use openzeppelin_allowance::spend_vault::{Vault, SpenderCap}; +use sui::balance::Balance; +use sui::clock::Clock; +use sui::table::{Self, Table}; + +// === Errors === + +#[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"; + +// === Structs === + +/// 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, +} + +// === Public Functions === + +/// Create and share a service pinned to `vault_id`. The creator becomes the +/// operator, the only address the cap-borrowing entrypoint accepts. Returns the +/// service's object id so callers can address the shared object. +public fun create(vault_id: ID, ctx: &mut TxContext): ID { + let service = Service { + id: object::new(ctx), + operator: ctx.sender(), + vault_id, + caps: table::new(ctx), + }; + let service_id = object::id(&service); + transfer::share_object(service); + service_id +} + +/// Hand a cap into the service's custody, keyed by the registering sender. +/// +/// The binding check is the custody-boundary rule for ANY protocol that accepts a +/// `SpenderCap`: validate `spender_cap_vault_id` against the vault you intend to +/// spend from, on-chain, BEFORE taking the cap. +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); +} + +/// Draw `amount` of coin `T` from `user`'s allowance and return the funds for the +/// caller to route (into a position, a `Coin`, ...). Generic over `T`, so the same +/// custodied cap serves every coin the owner budgeted it for; asking for a coin the +/// owner never granted aborts inside the library (`ENoAllowance`), so this fails +/// safe. +/// +/// SENDER-GATED: the operator check below is the security boundary. The library +/// never checks who calls `spend`, so the custody layer must. +public fun execute_topup( + 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. The grant is untouched: it 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()) +} + +// === View helpers === + +/// The vault this service is pinned to. +public fun vault_id(s: &Service): ID { + s.vault_id +} + +/// Whether `user` currently has a cap in custody. +public fun is_registered(s: &Service, user: address): bool { + s.caps.contains(user) +} diff --git a/contracts/allowance/examples/spend_vault/direct_delegation.move b/contracts/allowance/examples/spend_vault/direct_delegation.move new file mode 100644 index 0000000..94bd368 --- /dev/null +++ b/contracts/allowance/examples/spend_vault/direct_delegation.move @@ -0,0 +1,167 @@ +/// Basic usage: direct delegation. +/// +/// The simplest end-to-end integration of `openzeppelin_allowance::spend_vault`: +/// an owner opens a funded, budgeted allowance for a known delegate address, the +/// delegate spends directly, the owner manages the grant, and finally the vault is +/// torn down. Everything here is generic over the coin type `T`, so the same code +/// serves any coin. +/// +/// Each step is a separate `public fun` so you can see exactly which objects and +/// capabilities each call needs. +/// +/// # Disclaimer +/// +/// This module is an **unaudited example**, provided purely to illustrate ways the +/// `spend_vault` allowance primitive can be integrated. It is not production-ready and +/// must not be deployed as-is. +module openzeppelin_allowance::direct_delegation; + +use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap}; +use sui::accumulator::AccumulatorRoot; +use sui::balance::Balance; +use sui::clock::Clock; +use sui::coin::Coin; + +// === Owner setup === + +/// One PTB that creates the vault, funds it, mints a cap for `delegate`, grants a +/// budget, shares the vault, and returns the OwnerCap for the caller to keep or route. +/// +/// Order matters: every fund / mint / grant step must precede `share`, because the +/// Vault is only addressable as a shared input in LATER transactions. The Vault has +/// no `drop`, so the tx fails unless it is consumed by `share` (or `destroy`). +/// +/// Returns the `OwnerCap` by value rather than self-transferring it, so the caller +/// (or the enclosing PTB) decides its destination: composable. +public fun open_allowance( + funding: Coin, + delegate: address, + budget: u64, + expires_at_ms: u64, // pass std::u64::max_value!() for "no expiry" + clock: &Clock, + ctx: &mut TxContext, +): 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 we choose its destination. + let cap = vault.mint_cap(&owner_cap, ctx); + let cap_id = object::id(&cap); + transfer::public_transfer(cap, delegate); + + // 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); + + // Make the vault spendable, then hand owner authority back to the caller. + vault.share(); + owner_cap +} + +// === Delegate spends === + +/// The delegate draws `amount` of `T` and gets it back as a wallet `Coin`. +/// +/// `spend` returns `Balance`, which has no `drop`: it must be consumed in the +/// same PTB. Here we turn it into a `Coin` and return it, so the caller (or the +/// enclosing PTB) decides where the coin goes: composable. A spend aborts (with a +/// distinct, deterministic code) if the cap is wrong, the (cap, T) entry is missing, +/// the grant expired, or the amount is zero or over budget. It can also fail with the +/// `InsufficientFundsForWithdraw` execution status at `redeem_funds` if the pool is +/// short (surfaced via the SDK / a dry run, not a Move abort code). +/// +/// Note `ctx: &mut TxContext`: both `into_coin` (to mint the Coin) and `spend` take +/// `&mut TxContext`, so one parameter serves both. +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) +} + +// === Owner manages the grant === + +/// Raise / lower / renew with the race-free CAS idiom: read, then write with +/// `expected = Some(current)` in the SAME PTB. If a spend was sequenced between the +/// read and the write, the call aborts `EUnexpectedAllowance` instead of clobbering. +public fun change_budget( + vault: &mut Vault, + owner_cap: &OwnerCap, + cap_id: ID, + new_budget: u64, + new_expires_at_ms: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + let current = vault.allowance(cap_id); + vault.set_allowance( + owner_cap, + cap_id, + new_budget, + new_expires_at_ms, + option::some(current), + clock, + ctx, + ); +} + +/// Suspend a grant without removing it: zero the budget but keep the entry + cap +/// alive. The next `spend` aborts `EAllowanceExceeded` (NOT `ENoAllowance`), so +/// the spender knows to ask the owner to raise rather than to ask for a new grant. +public fun suspend( + vault: &mut Vault, + owner_cap: &OwnerCap, + cap_id: ID, + clock: &Clock, + ctx: &mut TxContext, +) { + // new_amount = 0 = suspend. A finite expiry must still be future, so reuse "no expiry". + vault.set_allowance( + owner_cap, + cap_id, + 0, + std::u64::max_value!(), + option::none(), + clock, + ctx, + ); +} + +/// Owner kill-switch for one coin. Idempotent: returns whether anything was removed +/// (`false` is the typo'd-cap_id / wrong-coin signal). Cannot be raced into failure. +public fun revoke_one_coin( + vault: &mut Vault, + owner_cap: &OwnerCap, + cap_id: ID, + ctx: &mut TxContext, +): bool { + vault.revoke(owner_cap, cap_id, ctx) +} + +// === Owner exit + teardown === + +/// Drain one coin's settled pool to the owner. Needs the AccumulatorRoot (0xacc). +/// Run this in its OWN tx, never in the same PTB as a `spend` / `withdraw` on this +/// vault: a same-checkpoint pool drop makes the settled read over-ask and abort (the +/// settled-vs-live pool skew), retry-safe next checkpoint. +public fun drain_one_coin( + vault: &mut Vault, + owner_cap: &OwnerCap, + root: &AccumulatorRoot, + ctx: &mut TxContext, +): Balance { + vault.withdraw_all(owner_cap, root, ctx) +} + +/// Tear down the vault. PRECONDITION: every coin already drained via +/// `withdraw_all` (enumerate types off-chain with `suix_getAllBalances`), or any +/// remaining funds strand permanently at the dead vault address. `destroy` drains +/// only the budget ledger and deletes the UIDs; it does not touch the pool. +public fun tear_down(vault: Vault, owner_cap: OwnerCap, ctx: &mut TxContext) { + vault.destroy(owner_cap, ctx); +} diff --git a/contracts/allowance/examples/spend_vault/example_coin.move b/contracts/allowance/examples/spend_vault/example_coin.move new file mode 100644 index 0000000..76c9def --- /dev/null +++ b/contracts/allowance/examples/spend_vault/example_coin.move @@ -0,0 +1,50 @@ +/// A throwaway fixed-supply coin used solely to give the `spend_vault` examples a +/// concrete coin type to fund a vault with. Not part of the allowance primitive +/// itself: it just supplies the `Coin` / `Balance` the examples escrow and spend. +/// +/// # Disclaimer +/// +/// This module is an **unaudited example**, provided purely to illustrate ways the +/// `spend_vault` allowance primitive can be integrated. It is not production-ready and +/// must not be deployed as-is. +module openzeppelin_allowance::example_coin; + +// === Constants === + +const SUPPLY: u64 = 1_000_000; + +// === Structs === + +/// One-time witness for the coin. The all-caps name matching the module is the Sui +/// convention that lets `init` register this as a currency. +public struct EXAMPLE_COIN has drop {} + +// === Init === + +/// Mints a fixed supply of 1,000,000 units to the publisher, freezes the supply, and +/// freezes the metadata. Runs once at publish. +fun init(otw: EXAMPLE_COIN, ctx: &mut TxContext) { + let (mut currency, mut treasury_cap) = sui::coin_registry::new_currency_with_otw( + otw, + 0, + "EXAMPLE_COIN", + "Example Coin", + "", + "", + ctx, + ); + + let coins = treasury_cap.mint(SUPPLY, ctx); + currency.make_supply_fixed(treasury_cap); + currency.finalize_and_delete_metadata_cap(ctx); + transfer::public_transfer(coins, ctx.sender()); +} + +// === Test-Only Helpers === + +/// Run `init` under test, minting the fixed supply to the sender so an example vault +/// can be funded. +#[test_only] +public fun init_for_testing(ctx: &mut TxContext) { + init(EXAMPLE_COIN {}, ctx) +} diff --git a/contracts/allowance/examples/spend_vault/tests/defi_keeper_tests.move b/contracts/allowance/examples/spend_vault/tests/defi_keeper_tests.move new file mode 100644 index 0000000..7c4fcd4 --- /dev/null +++ b/contracts/allowance/examples/spend_vault/tests/defi_keeper_tests.move @@ -0,0 +1,286 @@ +module openzeppelin_allowance::defi_keeper_tests; + +use openzeppelin_allowance::defi_keeper::{Self, Service}; +use openzeppelin_allowance::example_coin::{Self, EXAMPLE_COIN}; +use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap}; +use std::type_name; +use std::unit_test::{assert_eq, destroy}; +use sui::clock::{Self, Clock}; +use sui::coin::Coin; +use sui::event; +use sui::test_scenario::{Self as ts, Scenario}; + +const USER: address = @0xACE; // owns the vault AND registers with the keeper +const OPERATOR: address = @0xCAFE; // runs the keeper service +const MALLORY: address = @0xBAD; // not the operator + +const NOW_MS: u64 = 1_700_000_000_000; +const NO_EXPIRY: u64 = 18_446_744_073_709_551_615; // u64::MAX sentinel + +// Shared setup over EXAMPLE_COIN: USER mints the coin supply and creates + funds a +// vault, keeping the OwnerCap; OPERATOR creates a service pinned to it; USER mints +// one cap, grants it an EXAMPLE_COIN budget, and hands it into custody. Returns the +// scenario, the vault's id, and the cap's id. +fun setup(): (Scenario, ID, ID) { + let mut scenario = ts::begin(USER); + + // Tx 1 (USER): mint the example coin supply to the user. + example_coin::init_for_testing(scenario.ctx()); + + // Tx 2 (USER): create + fund + share the vault; keep the OwnerCap; share a clock. + scenario.next_tx(USER); + let vault_id = { + let mut funding = scenario.take_from_sender>(); + let mut clk = clock::create_for_testing(scenario.ctx()); + clk.set_for_testing(NOW_MS); + + let (vault, owner_cap) = spend_vault::new(scenario.ctx()); + let vault_id = object::id(&vault); + vault.deposit(funding.split(1_000, scenario.ctx()), scenario.ctx()); + vault.share(); + transfer::public_transfer(owner_cap, USER); + transfer::public_transfer(funding, USER); + clk.share_for_testing(); + vault_id + }; + + // Tx 3 (OPERATOR): create the keeper service pinned to that vault. + scenario.next_tx(OPERATOR); + { + defi_keeper::create(vault_id, scenario.ctx()); + }; + + // Tx 4 (USER): mint a cap, grant an EXAMPLE_COIN budget, and hand it straight + // into custody by value, so the cap never has to touch the user's wallet. + scenario.next_tx(USER); + let cap_id = { + let mut vault = scenario.take_shared(); + let mut service = scenario.take_shared(); + let clk = scenario.take_shared(); + let owner_cap = scenario.take_from_sender(); + + let cap = vault.mint_cap(&owner_cap, scenario.ctx()); + let cap_id = object::id(&cap); + vault.set_allowance( + &owner_cap, + cap_id, + 300, + NO_EXPIRY, + option::none(), + &clk, + scenario.ctx(), + ); + service.register(cap, scenario.ctx()); + assert!(service.is_registered(USER)); + + scenario.return_to_sender(owner_cap); + ts::return_shared(vault); + ts::return_shared(service); + ts::return_shared(clk); + cap_id + }; + + (scenario, vault_id, cap_id) +} + +// Happy path: the operator draws funds through the user's custodied cap; the owner +// raises the budget mid-custody and the same embedded cap keeps working. +#[test] +fun keeper_executes_topup_and_cap_survives_owner_update() { + let (mut scenario, vault_id, cap_id) = setup(); + + // Tx 5 (OPERATOR): draw 100 through the custodied cap and route it to the user. + scenario.next_tx(OPERATOR); + { + let mut vault = scenario.take_shared(); + let mut service = scenario.take_shared(); + let clk = scenario.take_shared(); + + let funds = service.execute_topup(&mut vault, USER, 100, &clk, scenario.ctx()); + assert_eq!(funds.value(), 100); + transfer::public_transfer(funds.into_coin(scenario.ctx()), USER); + assert_eq!(vault.allowance(cap_id), 200); + + // Spent.caller is ctx.sender(), which in a keeper flow is the OPERATOR who + // drove the spend, NOT the USER who owns the cap and its budget. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_spent( + vault_id, + cap_id, + type_name::with_defining_ids(), + 100, + 200, + OPERATOR, + ), + ); + + ts::return_shared(vault); + ts::return_shared(service); + ts::return_shared(clk); + }; + + // Tx 6 (USER, as vault owner): raise the budget using only the cap's id while the + // cap sits inside the service. The embedded cap is untouched. + scenario.next_tx(USER); + { + let mut vault = scenario.take_shared(); + let clk = scenario.take_shared(); + let owner_cap = scenario.take_from_sender(); + + let current = vault.allowance(cap_id); + vault.set_allowance( + &owner_cap, + cap_id, + 500, + NO_EXPIRY, + option::some(current), // CAS on a read-derived update, always + &clk, + scenario.ctx(), + ); + + scenario.return_to_sender(owner_cap); + ts::return_shared(vault); + ts::return_shared(clk); + }; + + // Tx 7 (OPERATOR): spend again under the new budget with the same embedded cap. + // No re-registration needed: owner maintenance never breaks an integration + // holding the cap. + scenario.next_tx(OPERATOR); + { + let mut vault = scenario.take_shared(); + let mut service = scenario.take_shared(); + let clk = scenario.take_shared(); + + let funds = service.execute_topup(&mut vault, USER, 400, &clk, scenario.ctx()); + transfer::public_transfer(funds.into_coin(scenario.ctx()), USER); + assert_eq!(vault.allowance(cap_id), 100); + + ts::return_shared(vault); + ts::return_shared(service); + ts::return_shared(clk); + }; + + scenario.end(); +} + +// The sender gate is the integration's security boundary: a `SpenderCap` is a bearer +// instrument, so without this operator check `execute_topup` would be world-drainable +// across every coin the cap is budgeted for. +#[test, expected_failure(abort_code = defi_keeper::ENotOperator)] +fun topup_by_non_operator_is_rejected() { + let (mut scenario, _vault_id, _cap_id) = setup(); + + // Tx 5 (MALLORY): tries to drive the keeper's custodied cap. + scenario.next_tx(MALLORY); + { + let mut vault = scenario.take_shared(); + let mut service = scenario.take_shared(); + let clk = scenario.take_shared(); + + let funds = service.execute_topup(&mut vault, USER, 100, &clk, scenario.ctx()); + destroy(funds); // unreachable, the gate aborts first + + ts::return_shared(vault); + ts::return_shared(service); + ts::return_shared(clk); + }; + + abort +} + +// The custody boundary: validate a cap's vault binding before accepting it. A cap +// minted against some other vault is rejected at register time, not discovered at +// spend time. +#[test, expected_failure(abort_code = defi_keeper::EWrongVaultForService)] +fun register_cap_from_wrong_vault_is_rejected() { + let (mut scenario, _vault_id, _cap_id) = setup(); + + // Tx 5 (USER): create a SECOND vault, mint a cap against it, and try to register + // that cap with the service pinned to the first vault. + scenario.next_tx(USER); + { + let mut service = scenario.take_shared(); + + let (other_vault, other_owner_cap) = spend_vault::new(scenario.ctx()); + let foreign_cap = other_vault.mint_cap(&other_owner_cap, scenario.ctx()); + + service.register(foreign_cap, scenario.ctx()); // aborts here + + // Unreachable cleanup to keep the type checker satisfied. + other_vault.share(); + transfer::public_transfer(other_owner_cap, USER); + ts::return_shared(service); + }; + + abort +} + +// Driving the keeper for a user who never registered a cap aborts ENotRegistered: +// there is no cap in custody to borrow, so the lookup fails before any spend. +#[test, expected_failure(abort_code = defi_keeper::ENotRegistered)] +fun topup_for_unregistered_user_is_rejected() { + let (mut scenario, _vault_id, _cap_id) = setup(); + + // Tx 5 (OPERATOR): MALLORY never registered, so there is no custodied cap. + scenario.next_tx(OPERATOR); + { + let mut vault = scenario.take_shared(); + let mut service = scenario.take_shared(); + let clk = scenario.take_shared(); + + // Aborts at the custody lookup before any cap is borrowed. + let funds = service.execute_topup(&mut vault, MALLORY, 100, &clk, scenario.ctx()); + destroy(funds); // unreachable + + ts::return_shared(vault); + ts::return_shared(service); + ts::return_shared(clk); + }; + + abort +} + +// Reclaiming custody with `unregister` returns the SpenderCap and leaves the +// underlying grant live: the holder can spend directly with the recovered cap. +#[test] +fun unregister_returns_cap_and_grant_stays_live() { + let (mut scenario, _vault_id, cap_id) = setup(); + + // Tx 5 (USER): pull the cap back out of custody and keep it in the wallet. + scenario.next_tx(USER); + { + let mut service = scenario.take_shared(); + + assert!(service.is_registered(USER)); + let cap = service.unregister(scenario.ctx()); + assert_eq!(object::id(&cap), cap_id); // the same cap comes back + assert!(!service.is_registered(USER)); // custody is now empty + + transfer::public_transfer(cap, USER); + ts::return_shared(service); + }; + + // Tx 6 (USER): the grant is untouched by the custody change; the holder spends + // the recovered cap directly against the vault. + scenario.next_tx(USER); + { + let mut vault = scenario.take_shared(); + let clk = scenario.take_shared(); + let cap = scenario.take_from_sender(); + + let bal = vault.spend(&cap, 100, &clk, scenario.ctx()); + assert_eq!(bal.value(), 100); + assert_eq!(vault.allowance(cap_id), 200); + destroy(bal); + + scenario.return_to_sender(cap); + ts::return_shared(vault); + ts::return_shared(clk); + }; + + scenario.end(); +} diff --git a/contracts/allowance/examples/spend_vault/tests/direct_delegation_tests.move b/contracts/allowance/examples/spend_vault/tests/direct_delegation_tests.move new file mode 100644 index 0000000..115af6f --- /dev/null +++ b/contracts/allowance/examples/spend_vault/tests/direct_delegation_tests.move @@ -0,0 +1,187 @@ +module openzeppelin_allowance::direct_delegation_tests; + +use openzeppelin_allowance::direct_delegation; +use openzeppelin_allowance::example_coin::{Self, EXAMPLE_COIN}; +use openzeppelin_allowance::spend_vault::{Self, Vault}; +use std::unit_test::{assert_eq, destroy}; +use sui::clock::{Self, Clock}; +use sui::coin::Coin; +use sui::test_scenario as ts; + +const OWNER: address = @0xACE; +const DELEGATE: address = @0xB0B; + +const DAY_MS: u64 = 86_400_000; +const NOW_MS: u64 = 1_000 * DAY_MS; // arbitrary test "now" +const NO_EXPIRY: u64 = 18_446_744_073_709_551_615; // u64::MAX sentinel + +// Happy path: the owner opens a funded, budgeted allowance for a known delegate, the +// delegate spends, the owner raises then suspends then revokes the grant, and the +// owner drains the pool and tears the vault down. +// +// Funding: the example coin's `init_for_testing` mints the fixed supply to the +// publisher; the owner deposits a slice of it through `open_allowance`. +// +// Teardown uses the root-free `withdraw` + `destroy` rather than the +// `drain_one_coin` / `tear_down` wrappers: `withdraw_all` reads the accumulator, +// which the unit-test VM cannot construct. +#[test] +fun direct_delegation_full_lifecycle() { + let mut scenario = ts::begin(OWNER); + + // Tx 1 (OWNER): mint the example coin supply to the owner. + example_coin::init_for_testing(scenario.ctx()); + + // Tx 2 (OWNER): open a funded, budgeted allowance for the delegate. The wrapper + // creates the vault, deposits the funding, mints a cap to the delegate, grants a + // budget, shares the vault, and returns the OwnerCap. A clock is created + shared + // so the later txs can reach it as a shared input. + scenario.next_tx(OWNER); + let (cap_id, owner_cap) = { + let mut funding = scenario.take_from_sender>(); + let mut clk = clock::create_for_testing(scenario.ctx()); + clk.set_for_testing(NOW_MS); + + // Fund the allowance with 1_000 units; keep the rest in the owner's wallet. + let stake = funding.split(1_000, scenario.ctx()); + let owner_cap = direct_delegation::open_allowance( + stake, + DELEGATE, + 400, + NOW_MS + 30 * DAY_MS, + &clk, + scenario.ctx(), + ); + + transfer::public_transfer(funding, OWNER); + clk.share_for_testing(); + + // Recover the cap id from the share / transfer the wrapper performed. + scenario.next_tx(OWNER); + (ts::most_recent_id_for_address(DELEGATE).destroy_some(), owner_cap) + }; + + // Tx 3 (DELEGATE): spend 150 to the delegate's wallet through the cap. + scenario.next_tx(DELEGATE); + { + let mut vault = scenario.take_shared(); + let clk = scenario.take_shared(); + let cap = scenario.take_from_sender(); + + let coin = direct_delegation::spend_to_wallet( + &mut vault, + &cap, + 150, + &clk, + scenario.ctx(), + ); + assert_eq!(vault.allowance(cap_id), 250); + destroy(coin); + + scenario.return_to_sender(cap); + ts::return_shared(vault); + ts::return_shared(clk); + }; + + // Tx 4 (OWNER): raise the budget (CAS idiom), then suspend, then revoke the coin. + scenario.next_tx(OWNER); + { + let mut vault = scenario.take_shared(); + let clk = scenario.take_shared(); + + direct_delegation::change_budget( + &mut vault, + &owner_cap, + cap_id, + 500, + NOW_MS + 60 * DAY_MS, + &clk, + scenario.ctx(), + ); + assert_eq!(vault.allowance(cap_id), 500); + + direct_delegation::suspend(&mut vault, &owner_cap, cap_id, &clk, scenario.ctx()); + assert_eq!(vault.allowance(cap_id), 0); + assert!(vault.contains(cap_id)); // suspended, entry still present + + let was_present = direct_delegation::revoke_one_coin( + &mut vault, + &owner_cap, + cap_id, + scenario.ctx(), + ); + assert!(was_present); + assert!(!vault.contains(cap_id)); // entry gone + + ts::return_shared(vault); + ts::return_shared(clk); + }; + + // Tx 5 (OWNER): drain the remaining pool and tear the vault down. Pool after the + // single 150 spend: 1_000 - 150 = 850. The root-free partial `withdraw` drains + // it, then `destroy` removes the (now empty) ledger and the UIDs. + scenario.next_tx(OWNER); + { + let mut vault = scenario.take_shared(); + + let bal = vault.withdraw(&owner_cap, 850, scenario.ctx()); + assert_eq!(bal.value(), 850); + destroy(bal); + + vault.destroy(owner_cap, scenario.ctx()); + }; + + scenario.end(); +} + +// An over-budget spend aborts `EAllowanceExceeded`: the delegate's 400-unit grant +// cannot draw 500. +#[test, expected_failure(abort_code = spend_vault::EAllowanceExceeded)] +fun spend_over_budget_aborts() { + let mut scenario = ts::begin(OWNER); + + example_coin::init_for_testing(scenario.ctx()); + + scenario.next_tx(OWNER); + { + let funding = scenario.take_from_sender>(); + let mut clk = clock::create_for_testing(scenario.ctx()); + clk.set_for_testing(NOW_MS); + + let owner_cap = direct_delegation::open_allowance( + funding, // fund with the whole supply: the pool is not the limit here + DELEGATE, + 400, + NO_EXPIRY, + &clk, + scenario.ctx(), + ); + transfer::public_transfer(owner_cap, OWNER); + clk.share_for_testing(); + }; + + // Tx (DELEGATE): try to draw 500 against a 400 budget. Aborts before any funds move. + scenario.next_tx(DELEGATE); + { + let mut vault = scenario.take_shared(); + let clk = scenario.take_shared(); + let cap = scenario.take_from_sender(); + + // Aborts inside `spend_to_wallet` before any Coin is produced; the binding + // exists only so the over-budget abort path type-checks. + let coin = direct_delegation::spend_to_wallet( + &mut vault, + &cap, + 500, + &clk, + scenario.ctx(), + ); + destroy(coin); + + scenario.return_to_sender(cap); + ts::return_shared(vault); + ts::return_shared(clk); + }; + + abort +} diff --git a/contracts/allowance/examples/spend_vault/tests/example_coin_tests.move b/contracts/allowance/examples/spend_vault/tests/example_coin_tests.move new file mode 100644 index 0000000..5448154 --- /dev/null +++ b/contracts/allowance/examples/spend_vault/tests/example_coin_tests.move @@ -0,0 +1,26 @@ +module openzeppelin_allowance::example_coin_tests; + +use openzeppelin_allowance::example_coin::{Self, EXAMPLE_COIN}; +use std::unit_test::{destroy, assert_eq}; +use sui::coin::Coin; +use sui::test_scenario as ts; + +// Verifies the example's `init`: a fixed supply of 1,000,000 units is minted to the +// publisher, and exactly one coin object is delivered. +#[test] +fun init_mints_fixed_supply_to_publisher() { + let publisher = @0xA; + + let mut scenario = ts::begin(publisher); + example_coin::init_for_testing(scenario.ctx()); + + scenario.next_tx(publisher); + + let coins = scenario.take_from_sender>(); + assert_eq!(coins.value(), 1_000_000); + // The whole supply lands in a single coin object: nothing else was minted to the publisher. + assert!(!scenario.has_most_recent_for_sender>()); + + destroy(coins); + scenario.end(); +} diff --git a/contracts/allowance/sources/spend_vault.move b/contracts/allowance/sources/spend_vault.move new file mode 100644 index 0000000..37d25d3 --- /dev/null +++ b/contracts/allowance/sources/spend_vault.move @@ -0,0 +1,1547 @@ +/// Cap-keyed, multi-coin allowance / approval primitive for Sui. +/// +/// > **BEARER-CAP WARNING: read this first.** A `SpenderCap` is a bearer +/// > instrument: whoever can present `&SpenderCap` to `spend` exercises the +/// > FULL spend authority of EVERY per-coin budget that cap holds, up to each +/// > budget's limits: holder, borrower, custodian protocol, or thief alike. +/// > One untyped cap now spans N coin budgets, so a leaked cap +/// > exposes the SUM across all coins the owner granted it. The library never +/// > inspects holder identity, provenance, or intent; transfer of the cap is +/// > transfer of authority, and mis-delivery is mis-authorization. There is no +/// > recipient binding in this module. Owner-side mitigations: small per-coin +/// > budgets, finite expiry per `(cap, coin)`, suspension +/// > (`set_allowance(..., 0, ...)`), and `revoke_all` (bounds a leaked cap's +/// > exposure to zero in one call). Integrators custodying a cap MUST +/// > sender-gate any function that borrows it: an ungated public borrow is +/// > world-drainable authority. +/// +/// A `Vault` is a single UNTYPED shared escrow that holds N coin types at once. +/// Its pool is NOT a struct field: per-coin funds live as object-owned +/// **address balances** at the vault's own object address +/// (`object::id(v).to_address()`). Authority to spend the pool is possession of +/// `&mut v.id`, which only this module produces and, for any fund egress, only +/// ever behind a cap gate: there is no EOA signer. (The one ungated `&mut v.id` +/// use is `squash`, which is permissionless but strictly funds-in.) Owner +/// authority is a transferable +/// `OwnerCap`; spend authority is a transferable, embeddable `SpenderCap`. Each +/// cap carries its per-coin budgets in a ledger keyed by `(cap_id, coin_type)`, +/// never in the cap. `spend`, `withdraw`, and `withdraw_all` all return +/// `Balance`. +/// +/// #### When to use which +/// +/// ```text +/// You want to... Call +/// ------------------------------------------ ----------------------------- +/// issue a cap now, set the budget later mint_cap (bare; transfer/embed it) +/// create OR change a (cap, coin) budget set_allowance (upsert; 0 = suspend) +/// suspend a grant (keep cap valid) set_allowance(new_amount = 0) +/// end one coin of a grant (owner side) revoke (idempotent; returns was_present) +/// end an entire cap (owner side) revoke_all (whole-cap kill) +/// end a grant (spender side) renounce (consumes cap, all coins) +/// dispose an orphaned cap (vault destroyed) delete_orphaned_cap (prefer renounce if vault still live) +/// recover a stray Coin sent to the vault squash (permissionless) +/// emergency stop revoke_all (tx1), then withdraw_all (tx2, retry-safe) +/// tear down the vault withdraw_all every coin, THEN destroy +/// ``` +/// +/// #### Core semantics +/// +/// - **Untyped, multi-coin.** No phantom type. Cross-coin +/// safety is a RUNTIME gate: the ledger is keyed by +/// `BudgetKey{cap_id, coin_type}` and `spend` resolves the `(cap, T)` +/// entry by that key. The coin type is always +/// `type_name::with_defining_ids()`: never the deprecated `get` (mixing +/// the two fragments the ledger). +/// - **Mixed error model.** This module's own aborts are dense codes 0..7. The +/// pool-short case is NOT among them: it surfaces as the Sui execution status +/// `InsufficientFundsForWithdraw` (a funds-accumulator `ExecutionFailureStatus`) +/// raised at `redeem_funds` when the object's settled balance is below the +/// amount, recognized by status in transaction effects / a dry run, NOT a +/// matchable Move `#[error]` code. Integrator preflight must account for it, +/// not only this module's codes. +/// - **Ceiling, not guarantee.** The sum of `remaining` across live entries may +/// exceed the pool, by design (over-subscription is sound, now across coins). +/// A live, unexpired, within-budget `spend` can still fail with the +/// `InsufficientFundsForWithdraw` execution status if the owner withdrew first +/// or sibling spenders drained the pool. No funds are reserved per entry; +/// competing spenders are resolved purely by consensus sequencing: first +/// sequenced, first served. +/// - **Exact-amount-or-abort `spend`.** Success delivers exactly +/// `amount` and decrements `remaining` by exactly `amount`, unless +/// `remaining == u64::MAX`, the UNLIMITED sentinel, which is never +/// decremented. On any abort, every entry and the pool are +/// bit-identical to pre-call (the pre-decrement is rolled back by Move's +/// atomic revert). +/// - **`u64::MAX` sentinels.** `remaining == u64::MAX` => unlimited; +/// `expires_at_ms == u64::MAX` => no expiry. Tested by equality only; no +/// arithmetic ever touches them. Cost: a deliberate finite grant of exactly +/// `u64::MAX` is unrepresentable, and SDKs must exclude `remaining == +/// u64::MAX` from volume math. +/// - **Bare mint, upsert set.** `mint_cap` creates NO ledger +/// entry: it returns a budgetless, untyped cap by value for the caller to +/// transfer or embed. `set_allowance` is a per-`(cap, coin)` UPSERT: +/// creates if absent (recording the coin in `granted_coin_types`), else +/// overwrites in place. Re-setting a key OVERWRITES; it never adds. The only +/// way to get two summing budgets for one person is two caps. +/// - **`cap_id` stable across `set_allowance`.** Owner-side changes +/// mutate the entry IN PLACE, keyed by `cap_id`; the cap object, its ID, and +/// every downstream embedding survive unlimited owner updates. This is the +/// load-bearing composition property of the cap-keyed architecture. +/// - **Suspension idiom.** `set_allowance(..., 0, ...)` zeroes the +/// budget but keeps the entry and cap alive; `spend` aborts +/// `EAllowanceExceeded` (not `ENoAllowance`). Spend-to-zero is equally lazy: +/// entries are removed only by `revoke`, `revoke_all`, `renounce`, or +/// `destroy`. +/// - **Opt-in CAS on `set_allowance`.** Pass `expected = Some(e)` on +/// ANY read-derived update. The race-free idiom is `allowance()` -> +/// `set_allowance(..., expected = Some(result), ...)` in one PTB: the shared +/// Vault is locked for the tx, so the pair is atomic. `Some(e)` on an absent +/// entry aborts (you cannot CAS-match a value that does not exist). +/// - **Unconditional owner exit.** `withdraw`, `withdraw_all`, and +/// `destroy` consult only the OwnerCap binding and the pool, never the ledger, +/// so no spender or ledger state can block defunding or teardown. +/// `withdraw_all` drains the SETTLED (start-of-checkpoint) pool via +/// `settled_funds_value` (a self-tracked counter would desync against +/// permissionless top-ups). It CAN fail with the `InsufficientFundsForWithdraw` +/// execution status if the live pool fell earlier in the SAME checkpoint (the +/// settled-vs-live skew; retry-safe next checkpoint), but never on +/// spender/ledger state. +/// - **Owner-enumerated teardown.** `destroy` drains the ledger and +/// UIDs and returns NOTHING: it cannot iterate runtime coin types to drain +/// heterogeneous address balances. The owner MUST `withdraw_all` every +/// coin first, enumerating types OFF-CHAIN via +/// `suix_getAllBalances(vault_address)` (complete: it lists every +/// address-balance type plus loose coins), or those funds strand at the dead +/// vault address. +module openzeppelin_allowance::spend_vault; + +use std::type_name::{Self, TypeName}; +use sui::accumulator::AccumulatorRoot; +use sui::balance::{Self, Balance}; +use sui::clock::Clock; +use sui::coin::Coin; +use sui::event; +use sui::linked_table::{Self, LinkedTable}; +use sui::transfer::Receiving; +use sui::vec_set::{Self, VecSet}; + +// === Errors === +// +// Dense, first-publication ABI: codes 0..7, no reserved gaps. There is +// deliberately NO `EInsufficientVault`: the pool-short case is covered by the +// Sui execution status `InsufficientFundsForWithdraw` (a funds-accumulator +// `ExecutionFailureStatus`), raised at `redeem_funds` when the object's settled +// balance is below the amount, the last failure on the `spend`/`withdraw` hot +// path. It is recognized by status in transaction effects / a dry run, not a +// matchable Move `#[error]` code. + +/// Presented `OwnerCap` is bound to a different Vault. First check on every +/// owner-gated function. +#[error(code = 0)] +const EWrongOwnerCap: vector = "OwnerCap does not match this Vault"; + +/// Presented `SpenderCap` is bound to a different Vault. First check in `spend` +/// and `renounce`. +#[error(code = 1)] +const EWrongVault: vector = "SpenderCap does not match this Vault"; + +/// No `(cap, coin)` allowance entry: never granted, owner-revoked, or +/// spender-renounced. Remedy: a new grant. **Spend-only:** +/// `set_allowance` is an upsert and never aborts here. Distinct from +/// `EAllowanceExceeded` on a suspended entry (`remaining == 0`), whose remedy +/// is asking the owner to raise; `contains` is the absent-vs-suspended +/// disambiguator, called as an on-chain public view. +#[error(code = 2)] +const ENoAllowance: vector = "No allowance entry for this cap"; + +/// Entry exists with finite expiry and `now >= expires_at_ms` (closed +/// boundary: a spend in the exact millisecond of expiry fails). The `u64::MAX` +/// sentinel never expires. +#[error(code = 3)] +const EAllowanceExpired: vector = "Allowance has expired"; + +/// `amount` exceeds the entry's `remaining`. Also fires on suspended entries +/// (`remaining == 0`) for any positive amount: the suspension-vs-revocation +/// discriminator. +#[error(code = 4)] +const EAllowanceExceeded: vector = "Amount exceeds remaining allowance"; + +/// Zero amount where zero is meaningless: `deposit`, `deposit_balance`, +/// `spend`, partial `withdraw`. `set_allowance` deliberately accepts 0 +/// (suspension idiom); `withdraw_all`/`destroy`/`squash` permit zero-value +/// outcomes; `mint_cap` is bare (no amount). +#[error(code = 5)] +const EZeroAmount: vector = "Amount must be greater than zero"; + +/// Finite `new_expires_at_ms` was at or before `clock.timestamp_ms()` on +/// `set_allowance`. The `u64::MAX` sentinel is "no expiry" and always passes. +/// Corollary: a future expiry REVIVES an expired entry in place. +#[error(code = 6)] +const EExpiryInPast: vector = "Expiry must be in the future"; + +/// CAS guard failed on `set_allowance`: the entry is absent, or its current +/// `remaining` does not equal `expected`. A spend was sequenced between your +/// read and this write, or you CAS'd a `(cap, coin)` that does not exist; +/// re-read and retry. +#[error(code = 7)] +const EUnexpectedAllowance: vector = "Current allowance does not match expected"; + +// === Structs === + +/// Shared, UNTYPED escrow + per-`(cap, coin)` allowance ledger. One vault holds +/// N coin types at once. +/// +/// `key`-only by design: a Vault returned by `new` cannot be silently +/// discarded (no `drop`), and external modules cannot wrap or re-share it (no +/// `store`). Its lifecycle is exactly `new -> share` or `new -> destroy`, +/// controlled solely by this module. +/// +/// The pool is NOT a field here: per-coin funds live as +/// object-owned address balances at `object::id(v).to_address()`. +/// Key-only therefore no longer conserves the escrow directly: it protects +/// `id` (the `&mut v.id` spend authority) and the ledger, and forces +/// every teardown through `destroy` (the one path where the drain discipline +/// can be required). +/// +/// - `allowances`: a `LinkedTable` (not `Table`) so `destroy`/`revoke_all`/ +/// `renounce` can drain entries and recover each per-entry storage rebate; +/// the cost is O(n) drains and ~66 B of neighbour links per entry. +/// - `granted_coin_types`: the OWNER-WRITABLE enumeration handle that +/// `revoke_all`/`renounce` iterate on-chain. Written ONLY by +/// `set_allowance`-that-creates, so permissionless `deposit`/`squash` cannot +/// inflate it (un-griefable); complete because a `(cap, T)` entry can exist +/// only for a granted `T`. **GROWS-ONLY, never pruned:** `revoke` +/// reclaims an entry's rebate but does NOT remove its `T` here, and a +/// phantom/typo'd `cap_id` on a new `T` adds that `T` permanently. So the O(k) +/// `revoke_all`/`renounce` loops are bounded by the distinct types the owner +/// has EVER granted on this vault (not the live entry count): keep that modest +/// and shard long-lived, many-type vaults. It is NOT the drain-before-destroy +/// list: that is off-chain `getAllBalances`, which also surfaces untracked +/// `send_funds` types and loose coins. +public struct Vault has key { + id: UID, + allowances: LinkedTable, + granted_coin_types: VecSet, +} + +/// Composite ledger key: one entry per `(cap, coin type)`. Must be +/// `copy + drop + store` to be a `LinkedTable` key: `store` to live in the +/// table, `copy` to rebuild the same key for lookup without consuming a held +/// value, `drop` to discard a lookup key that is not inserted. Well-formed +/// because both components (`ID`, `TypeName`) are themselves copy+drop+store. +/// `coin_type` is always `type_name::with_defining_ids()`. +public struct BudgetKey has copy, drop, store { + cap_id: ID, + coin_type: TypeName, +} + +/// Owner authority for exactly one Vault. Transferable + +/// custody-composable (`store` enables multisig/DAO embedding and +/// two-step-transfer wrapping). Exactly ONE OwnerCap exists per Vault for its +/// whole life: `new` mints it and `destroy` consumes it. `vault_id` is set at +/// `new` and never rewritten; transfer of the cap IS owner rotation. It gates a +/// wide blast radius: `withdraw`/`withdraw_all` over +/// every coin and `revoke_all` over every `(cap, coin)` entry. +public struct OwnerCap has key, store { + id: UID, + vault_id: ID, +} + +/// Spend authority. **BEARER INSTRUMENT** (see the module-level warning): +/// whoever presents `&SpenderCap` to `spend` holds the full spend authority +/// of every `(cap, coin)` budget it keys, so a leaked cap exposes the SUM of +/// its per-coin budgets. +/// +/// UNTYPED: no phantom, no coin-type field. The coin dimension is supplied by +/// the `T` argument at the `spend` call site and resolved against the ledger +/// key. `vault_id` is set at `mint_cap` and never rewritten; the binding +/// survives every transfer, wrap, or table embedding. On-chain custodians +/// should validate it via `spender_cap_vault_id` before accepting a cap. No +/// `copy`: spend authority cannot be duplicated. +public struct SpenderCap has key, store { + id: UID, + vault_id: ID, +} + +/// Private ledger entry for one `(cap, coin)` grant. Not an object: reachable +/// exclusively through this module's functions on the owning Vault, and the +/// single source of truth for the grant's state (the cap carries no budget). +/// The coin type is in the `BudgetKey`, not here, so the entry holds +/// exactly two scalars and one cap has N independent `Allowance` values. +/// +/// `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 future at `set_allowance` time. `store` lets it live +/// as a `LinkedTable` value; `drop` allows clean disposal during `pop_front` +/// drains. +public struct Allowance has drop, store { + remaining: u64, + expires_at_ms: u64, +} + +// === Events === +// +// One canonical event per state change; reads and `share` emit +// nothing. Events are UNTYPED (no phantom) and carry a runtime `coin_type: +// TypeName` on coin-specific events, none on coin-agnostic ones. Unspoofable: +// these structs are module-private, so only this module can construct and emit +// them. Each gets a `#[test_only]` constructor at the foot +// of the module. +// +// Actor-field naming: `by` is the actor (`ctx.sender()`) of an administrative or +// terminal action; `creator` / `depositor` / `caller` are role-specific synonyms +// for the same `ctx.sender()` at `new` / `deposit` / `spend`. `CapDeleted` carries +// no actor field (see its doc). + +/// Emitted by `new`. `owner_cap_id` is the vault->cap discovery anchor: +/// indexers resolve current owner custody by following object-ownership changes +/// of this cap. `creator` is `ctx.sender()` at `new` and may differ from the +/// eventual owner. +public struct VaultCreated has copy, drop { + vault_id: ID, + owner_cap_id: ID, + creator: address, +} + +/// Emitted by `deposit` and `deposit_balance`. `depositor` is indexer +/// attribution only; depositing confers no rights. +public struct Deposited has copy, drop { + vault_id: ID, + coin_type: TypeName, + amount: u64, + depositor: address, +} + +/// Emitted by `squash`. DISTINCT from `Deposited` so indexers can separate +/// recovered strays from real deposits. Can carry `amount: 0` (because `squash` +/// has no `EZeroAmount` guard), mirroring the zero-amount note on `Withdrawn`. +public struct Squashed has copy, drop { + vault_id: ID, + coin_type: TypeName, + amount: u64, + by: address, +} + +/// Emitted by `mint_cap`. BARE: the cap carries no budget yet, so there is no +/// recipient / amount / expiry here: budget data rides on the subsequent +/// `AllowanceSet { was_created: true }`. `by` is `ctx.sender()`. +public struct SpenderCapMinted has copy, drop { + vault_id: ID, + cap_id: ID, + by: address, +} + +/// Emitted by `set_allowance`. `new_amount == 0` signals the suspension idiom +/// (entry and cap stay alive). `cas_was_provided` records whether the CAS guard +/// was engaged, so off-chain tooling can spot CAS-less read-derived updates; +/// `was_created` is `true` on the create branch, `false` on overwrite. +public struct AllowanceSet has copy, drop { + vault_id: ID, + cap_id: ID, + coin_type: TypeName, + new_amount: u64, + new_expires_at_ms: u64, + cas_was_provided: bool, + was_created: bool, + by: address, +} + +/// Emitted on every successful `spend`, strictly AFTER `redeem_funds` succeeds +/// (so a decremented-then-reverted pool-short spend emits nothing). +/// `remaining` is the entry's RAW value after the call; for an unlimited grant +/// it stays `u64::MAX`. `caller` is `ctx.sender()`: attribution, never a gate, +/// in wrapper flows it is the wrapper's caller, not necessarily the cap holder. +public struct Spent has copy, drop { + vault_id: ID, + cap_id: ID, + coin_type: TypeName, + amount: u64, + remaining: u64, + caller: address, +} + +/// Emitted by `revoke` on every non-aborting call (including the idempotent +/// no-op), and by `revoke_all` once per removed coin. For single-coin `revoke`, +/// `was_present == false` is the typo'd-cap_id signal: nothing was actually +/// removed. For `revoke_all`, a whole-cap miss emits NOTHING (event absence is +/// the signal), so it has no `was_present == false` record. +public struct Revoked has copy, drop { + vault_id: ID, + cap_id: ID, + coin_type: TypeName, + was_present: bool, + by: address, +} + +/// Emitted by `renounce` (spender self-revoke). Coin-agnostic TERMINAL event: +/// it removes every `(cap, *)` entry, so an indexer closes all of the cap's +/// open entries on it. It carries no coin list or count, so an indexer closing +/// the cap's open entries relies on previously indexed `AllowanceSet` (grant) +/// state, not data in this event. `by` is `ctx.sender()`. +public struct Renounced has copy, drop { + vault_id: ID, + cap_id: ID, + by: address, +} + +/// Emitted by both `withdraw` and `withdraw_all`. `amount` is the actual value +/// extracted, possibly 0 from `withdraw_all` on an empty pool. +public struct Withdrawn has copy, drop { + vault_id: ID, + coin_type: TypeName, + amount: u64, + by: address, +} + +/// Emitted by `destroy`. Coin-agnostic TERMINAL event for every `(vault, *)` +/// entry; indexers close all open entries under `vault_id` on it. It carries no +/// coin list or count, so an indexer closing the vault's open entries relies on +/// previously indexed `AllowanceSet` (grant) state, not data in this event. No +/// `refunded` field: the owner drained each coin via `withdraw_all` +/// beforehand (the vault holds N coin types, so there is no single refund to +/// report). +public struct VaultDestroyed has copy, drop { + vault_id: ID, + by: address, +} + +/// Emitted by `delete_orphaned_cap`. Non-generic (a bare cap has no coin type in scope): +/// lets event-only indexers follow a cap deletion. Without it, deleting a cap +/// whose entries are still live would leave them looking like live authority. +/// Intentionally carries no actor field: `delete_orphaned_cap` is the lone +/// ctx-free disposal path (callable after the vault is gone), so there is no +/// `ctx.sender()` to record. +public struct CapDeleted has copy, drop { + vault_id: ID, + cap_id: ID, +} + +// === Public Functions === + +// === Lifecycle === + +/// Create an UNTYPED, multi-coin Vault and its sole, vault-bound `OwnerCap`, +/// both returned BY VALUE. +/// +/// One PTB composes the full setup atomically: `new -> deposit (xN) -> +/// mint_cap -> set_allowance (xM) -> share -> transfer(owner_cap)`. Creator and +/// owner can differ: transfer the cap anywhere. The Vault has no `drop`, so the +/// tx fails unless it is consumed by `share` or `destroy` in the same tx. +/// +/// #### Parameters +/// - `ctx`: Transaction context, used to allocate the Vault and OwnerCap UIDs +/// and to record the creator. +/// +/// #### Returns +/// - The new `Vault` (consume it with `share` or `destroy`) and its sole +/// `OwnerCap`, both by value. +/// +/// #### Aborts +/// Never. Emits `VaultCreated { vault_id, owner_cap_id, creator }`. +public fun new(ctx: &mut TxContext): (Vault, OwnerCap) { + let vault_uid = object::new(ctx); + let vault_id = vault_uid.to_inner(); + + let vault = Vault { + id: vault_uid, + allowances: linked_table::new(ctx), + granted_coin_types: vec_set::empty(), + }; + + let owner_cap = OwnerCap { + id: object::new(ctx), + vault_id, + }; + + event::emit(VaultCreated { + vault_id, + owner_cap_id: object::id(&owner_cap), + creator: ctx.sender(), + }); + + (vault, owner_cap) +} + +/// Share the Vault. Module-only entry point: `Vault` omits `store`, so external +/// modules cannot share it another way. +/// +/// Must run in the same tx as `new`; there is no deferred-share path. After +/// `share`, the Vault is addressable as a shared input only in subsequent +/// transactions, so all same-PTB fund / grant / embed steps must precede it. No +/// event: sharing is platform-visible. +/// +/// #### Aborts +/// Never. +public fun share(v: Vault) { + transfer::share_object(v); +} + +/// Terminal owner exit: tear the vault down and reclaim its storage rebates. +/// +/// > **DANGER: DRAIN THE POOL FIRST, OR FUNDS ARE LOST FOREVER.** `destroy` +/// > deletes the vault and the owner cap and drains the budget ledger, but it +/// > does NOT drain the pool. Any coin still held in the vault's address +/// > balances strands permanently at the dead vault address: with the UID gone, +/// > no cap and no transaction can ever reach it again. The vault cannot drain +/// > itself (Move cannot iterate runtime coin types to dispatch +/// > `withdraw_all` per type), and NO on-chain guard can stop a premature +/// > `destroy` (you cannot enumerate the pool's coin types on-chain), so the +/// > safe teardown is owner discipline: +/// > +/// > // 1. list EVERY coin type at the vault address (off-chain, complete): +/// > // suix_getAllBalances(vault_address) +/// > // 2. fold in any loose Coins (shown as totalBalance > fundsInAddressBalance): +/// > // squash(&mut vault, receiving, ctx) +/// > // 3. drain every listed type, one call each: +/// > // let bal = withdraw_all(&mut vault, &owner_cap, &root, ctx) +/// > // 4. WAIT one checkpoint, then re-run getAllBalances; if non-empty, GOTO 2 +/// > // (a same-checkpoint deposit is invisible to step 3's settled read, so +/// > // you cannot catch it by draining harder in one checkpoint) +/// > // 5. ONLY when getAllBalances reads empty across a settled checkpoint: +/// > // destroy(vault, owner_cap, ctx) +/// > +/// > Drain in a PRIOR transaction, never the same PTB as `destroy`: a same-tx +/// > `send_funds` credit settles AFTER the drain read and strands. +/// > Residual: a permissionless deposit landing between step 4's check and step 5 +/// > strands, so time `destroy` when no deposits are expected. To merely stop a +/// > spender or freeze the vault, use `revoke_all` then `withdraw_all` (separate +/// > txs), which do NOT delete the vault. +/// +/// Mechanics: consumes the Vault and OwnerCap by value, `pop_front`-drains +/// EVERY ledger entry (recovering each per-entry storage rebate), deletes +/// both UIDs, and returns NOTHING (it cannot return N heterogeneous per-coin +/// balances). The drain is O(n) in live entries; for a very large ledger, +/// batch-`revoke` first to spread gas across txs. Teardown is never blockable +/// by spender state. `VaultDestroyed` is the terminal event for every entry +/// under this vault_id. +/// +/// #### Parameters +/// - `v`: The vault to tear down (consumed by value). +/// - `cap`: The OwnerCap bound to `v` (consumed by value). +/// - `ctx`: Transaction context, used to attribute the `VaultDestroyed` event. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: cap bound to a different Vault. Only abort. +public fun destroy(v: Vault, cap: OwnerCap, ctx: &mut TxContext) { + assert!(cap.vault_id == object::id(&v), EWrongOwnerCap); + + // `granted_coin_types` is deliberately dropped, not drained: the ledger drain + // below works off the `allowances` table, not the type set. + let Vault { id: vault_uid, mut allowances, granted_coin_types: _ } = v; + let vault_id = vault_uid.to_inner(); + + // Full drain. `destroy_empty` is the backstop; the loop makes a non-empty + // table unreachable there. Each pop drops a (BudgetKey, Allowance) and + // recovers the entry's storage rebate. + while (!allowances.is_empty()) { + let (_key, _entry) = allowances.pop_front(); + }; + allowances.destroy_empty(); + + let OwnerCap { id: owner_cap_uid, vault_id: _ } = cap; + vault_uid.delete(); + owner_cap_uid.delete(); + + event::emit(VaultDestroyed { vault_id, by: ctx.sender() }); +} + +// === Fund (permissionless, confers no rights) === + +// NOTE (direct address-balance funding is a valid alternative). Because the pool +// IS the vault's object-owned address balance, anyone can fund it WITHOUT this module by +// calling `sui::balance::send_funds(bal, object::id(v).to_address())` directly (a +// `Coin` via `c.into_balance()` first). Such funds are spendable by `spend` and +// withdrawable by the owner identically to a `deposit` (the accumulator is the +// single source of truth). The ONLY difference: a raw `send_funds` emits no +// typed `Deposited` event, so an event-only indexer will not see it (the balance is +// still visible on-chain via `getBalance`/`getAllBalances`). Use `deposit` / +// `deposit_balance` when the typed event matters; a raw `send_funds` is a lighter +// permissionless top-up. + +/// Add a `Coin` to the vault's per-coin pool. PERMISSIONLESS: anyone may +/// deposit, and depositing confers NO rights (no entry, no claim, no refund +/// path); the funds become the owner's pool. Only fund a vault whose owner you +/// trust. +/// +/// Takes `&Vault`, not `&mut`: a deposit writes no on-chain type +/// set, so it mutates nothing. `send_funds` needs only the vault's address, +/// derived from `object::id(v)`; the funds land directly in the address +/// balance at that address. +/// +/// CAVEAT: because deposits are permissionless and allowances are ceilings on +/// the pool, a deposit by anyone (including a spender) re-arms live allowances +/// after a `withdraw_all`-as-freeze. The durable kill-all is `revoke_all` or +/// `destroy`, not draining the pool. +/// +/// #### Parameters +/// - `v`: The vault whose pool receives the funds. +/// - `c`: The `Coin` to deposit (consumed by value). +/// - `ctx`: Transaction context, used to attribute the `Deposited` event. +/// +/// #### Aborts (in order) +/// 1. `EZeroAmount`: `c.value() == 0`. +public fun deposit(v: &Vault, c: Coin, ctx: &mut TxContext) { + let amount = c.value(); + assert!(amount > 0, EZeroAmount); + + c.into_balance().send_funds(object::id(v).to_address()); + + event::emit(Deposited { + vault_id: object::id(v), + coin_type: type_name::with_defining_ids(), + amount, + depositor: ctx.sender(), + }); +} + +/// `Balance`-native deposit: the symmetric ingress to the `Balance` +/// egress of `spend`/`withdraw`/`withdraw_all`. The natural sink for a +/// `spend` output routed back into escrow, or for funding from any address +/// balance the caller controls (`redeem_funds(...)` then `deposit_balance`). +/// Same permissionless, rights-free, `&Vault` semantics as `deposit`. +/// +/// #### Parameters +/// - `v`: The vault whose pool receives the funds. +/// - `b`: The `Balance` to deposit (consumed by value). +/// - `ctx`: Transaction context, used to attribute the `Deposited` event. +/// +/// #### Aborts (in order) +/// 1. `EZeroAmount`: `b.value() == 0`. +public fun deposit_balance(v: &Vault, b: Balance, ctx: &mut TxContext) { + let amount = b.value(); + assert!(amount > 0, EZeroAmount); + + b.send_funds(object::id(v).to_address()); + + event::emit(Deposited { + vault_id: object::id(v), + coin_type: type_name::with_defining_ids(), + amount, + depositor: ctx.sender(), + }); +} + +// === Cap + budgets (two owner verbs: mint_cap, set_allowance) === + +/// Owner-only. Mint a BARE, untyped `SpenderCap` and return it BY VALUE: no +/// budget, no ledger entry, no coin type yet. The caller decides the +/// cap's destination in the same PTB: `public_transfer` it to a delegate, or +/// embed it by value in a wrapper object / protocol record. Per-coin budgets +/// are added separately via `set_allowance`. +/// +/// Takes `&Vault` (it creates no ledger entry). The cap's `vault_id` binds it +/// to this vault for life and is never rewritten. +/// +/// #### Parameters +/// - `v`: The vault the minted cap is bound to. +/// - `cap`: The OwnerCap bound to `v`. +/// - `ctx`: Transaction context, used to allocate the cap's UID and attribute +/// the `SpenderCapMinted` event. +/// +/// #### Returns +/// - A new, budgetless `SpenderCap` bound to `v`, by value. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: cap bound to a different Vault. Only abort. +public fun mint_cap(v: &Vault, cap: &OwnerCap, ctx: &mut TxContext): SpenderCap { + assert!(cap.vault_id == object::id(v), EWrongOwnerCap); + + let spender_cap = SpenderCap { + id: object::new(ctx), + vault_id: object::id(v), + }; + let cap_id = object::id(&spender_cap); + + event::emit(SpenderCapMinted { + vault_id: object::id(v), + cap_id, + by: ctx.sender(), + }); + + spender_cap +} + +/// Owner-only. UPSERT the `(cap_id, T)` budget: create it if absent, else +/// overwrite `remaining` and `expires_at_ms` IN PLACE. The primary +/// create-or-change path; one cap accrues N independent per-coin budgets via N +/// `set_allowance` calls. +/// +/// Takes `cap_id: ID`, not `&SpenderCap`: the owner manages budgets without +/// holding the cap. The cap object, its ID, and every downstream embedding are +/// untouched by any change here, so a cap embedded in a protocol table +/// survives unlimited owner updates and re-granting is never required. +/// +/// **`cap_id` is the SPENDER cap's object id** (`object::id(&spender_cap)`, the +/// id `mint_cap` minted), NOT the OwnerCap's id. It is UNVALIDATED by design +/// (kept a bare ID) and the owner gate only checks the OwnerCap, so a +/// mistyped or mis-sourced `cap_id` silently targets a different budget. The +/// CREATE branch gives NO error signal: a wrong `cap_id` provisions a +/// fresh budget with `was_created == true`, shaped exactly like success (and adds +/// its `T` to the never-pruned `granted_coin_types`). Confirm before +/// granting: preflight `contains(cap_id)` / `granted_coin_types()`, read +/// `AllowanceSet.was_created` to tell create from update, and derive `cap_id` +/// from a held `&SpenderCap` off-chain rather than copying a literal id. +/// +/// - **Create vs overwrite.** Absent: create, recording `T` in +/// `granted_coin_types` (the owner-only revoke-iteration handle). +/// Present: overwrite. Re-setting a key OVERWRITES, it never adds. Two summing +/// budgets for one person require two caps. +/// - **Suspension.** `new_amount == 0` zeroes the budget but keeps the +/// entry and cap alive; the next `spend` aborts `EAllowanceExceeded`. There +/// is deliberately no `EZeroAmount` here. +/// - **Revival.** A future `new_expires_at_ms` revives an expired entry +/// in place. Suspending an already-expired entry necessarily restates a valid +/// future expiry (or `u64::MAX`), time-reviving it while zeroing the budget. +/// - **CAS.** `expected = Some(e)` proceeds only if the entry exists +/// AND its current `remaining == e`; on an absent entry or a mismatch it +/// aborts `EUnexpectedAllowance`. The race-free idiom is `allowance()` then +/// `set_allowance(..., Some(result), ...)` in one PTB (the shared Vault is +/// locked for the tx). `None` is the unconditional create-or-overwrite. CAS +/// compares the RAW `remaining` (0 for suspended and `u64::MAX` for unlimited +/// included). CAS guards `remaining` ONLY; the upsert always overwrites +/// `expires_at_ms` too. A read-then-CAS-write that means to change only the +/// budget MUST re-read and re-pass the current expiry (via `expiry()`), or +/// it will silently overwrite it. +/// +/// #### Parameters +/// - `v`: The vault whose ledger is updated. +/// - `cap`: The OwnerCap bound to `v`. +/// - `cap_id`: The SpenderCap object id whose `(cap_id, T)` budget is upserted. +/// - `new_amount`: New `remaining` budget; `0` suspends, `u64::MAX` is unlimited. +/// - `new_expires_at_ms`: New expiry in ms; `u64::MAX` is the no-expiry sentinel, +/// any finite value must be strictly in the future. +/// - `expected`: Optional CAS guard; `Some(e)` proceeds only if the entry exists +/// and its current `remaining == e`, `None` is unconditional. +/// - `clock`: Reference to the Sui `Clock`, used to validate `new_expires_at_ms`. +/// - `ctx`: Transaction context, used to attribute the `AllowanceSet` event. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: cap bound to a different Vault. +/// 2. `EExpiryInPast`: finite `new_expires_at_ms <= now`. +/// 3. `EUnexpectedAllowance`: CAS provided and the entry is absent or its +/// current `remaining` differs. +public fun set_allowance( + v: &mut Vault, + cap: &OwnerCap, + cap_id: ID, + new_amount: u64, + new_expires_at_ms: u64, + expected: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + let vault_id = object::id(v); + + // Precedence: owner gate, then expiry validity, then CAS. No ENoAllowance + // (upsert), no EZeroAmount (0 = suspend). + assert!(cap.vault_id == vault_id, EWrongOwnerCap); + assert!( + new_expires_at_ms == std::u64::max_value!() + || new_expires_at_ms > clock.timestamp_ms(), + EExpiryInPast, // the u64::MAX no-expiry sentinel always passes + ); + + let coin_type = type_name::with_defining_ids(); + let key = BudgetKey { cap_id, coin_type }; + + // CAS: compare-without-consuming against the raw `remaining`. Under the + // upsert, `Some(e)` on an ABSENT entry must abort (you cannot CAS-match a + // value that does not exist); the `contains` short-circuits the `&&`. + let cas_was_provided = expected.is_some(); + if (cas_was_provided) { + let e = expected.destroy_some(); + assert!( + v.allowances.contains(key) && v.allowances.borrow(key).remaining == e, + EUnexpectedAllowance, + ); + }; + + // Upsert: overwrite in place if present (cap_id + embeddings untouched), + // else create. + let was_created = if (v.allowances.contains(key)) { + let entry = v.allowances.borrow_mut(key); + entry.remaining = new_amount; + entry.expires_at_ms = new_expires_at_ms; + false + } else { + v.allowances.push_back( + key, + Allowance { remaining: new_amount, expires_at_ms: new_expires_at_ms }, + ); + // `granted_coin_types` is written ONLY here (set_allowance-create, + // owner-gated), so permissionless funding can never inflate the set the + // revoke paths iterate. Guard the insert: `vec_set::insert` aborts on a + // duplicate. + if (!v.granted_coin_types.contains(&coin_type)) { + v.granted_coin_types.insert(coin_type); + }; + true + }; + + event::emit(AllowanceSet { + vault_id, + cap_id, + coin_type, + new_amount, + new_expires_at_ms, + cas_was_provided, + was_created, + by: ctx.sender(), + }); +} + +// === Spend (cap-gated, never sender-gated; exact-amount-or-abort) === + +/// Draw exactly `amount` of coin `T` against the presented `&SpenderCap`. +/// CAP-GATED, never sender-gated: any transaction context (an EOA, a +/// protocol module borrowing an embedded cap, a sponsored tx) spends +/// identically. `ctx.sender()` feeds `Spent.caller` only. +/// +/// **Runtime coin-type gate.** The `(cap, T)` budget is resolved by +/// `BudgetKey{cap_id, with_defining_ids()}`. A cap budgeted only for another +/// coin aborts `ENoAllowance` for this `T`: cross-coin safety is a runtime +/// check, not a compile-time phantom type. +/// +/// EXACT-AMOUNT-OR-ABORT: success extracts exactly `amount` from the pool and +/// decrements `remaining` by exactly `amount`, unless `remaining == u64::MAX`, +/// which is never decremented. On ANY abort, the pool and every entry +/// are bit-identical to pre-call: the five library checks precede the +/// first mutation, and a pool-short failure rolls back the pre-decrement via +/// Move's atomic revert. +/// +/// Returns `Balance` with no `drop`, so the caller MUST consume it: plumb it +/// onward in the same PTB (`into_coin`, `send_funds`, `deposit_balance`, a +/// downstream protocol call). Spend-to-zero leaves the entry in place; removal +/// is `revoke`/`revoke_all`/`renounce`/`destroy`. +/// +/// **Ceiling, not guarantee + mixed error model.** An allowance is a +/// ceiling on the pool, not a reservation: a live, unexpired, within-budget +/// spend can still fail when the pool is short, and that failure is the Sui +/// execution status `InsufficientFundsForWithdraw` (a funds-accumulator +/// `ExecutionFailureStatus`), raised at `redeem_funds` when the object's settled +/// balance is below the amount, NOT one of this module's codes. It is not a +/// matchable Move `#[error]` code, so detect it with a dry run (it is surfaced +/// in transaction effects / the SDK) rather than by matching an abort code. +/// Integrator preflight must handle the framework conditions too. The status is +/// deterministic and dry-run-visible. +/// +/// #### Parameters +/// - `v`: The vault to spend against. +/// - `cap`: The SpenderCap bound to `v` whose `(cap, T)` budget is charged. +/// - `amount`: Units of coin `T` to draw; must be positive. +/// - `clock`: Reference to the Sui `Clock`, used to evaluate expiry. +/// - `ctx`: Transaction context, used to attribute the `Spent` event. +/// +/// #### Returns +/// - A `Balance` of exactly `amount`; the caller must consume it. +/// +/// #### Aborts (in order; deterministic integrator ABI) +/// 1. `EWrongVault`: cap bound to a different Vault. +/// 2. `ENoAllowance`: no `(cap, T)` entry (never granted, revoked, or a +/// different coin). +/// 3. `EAllowanceExpired`: finite expiry and `now >= expires_at_ms`. +/// 4. `EZeroAmount`: `amount == 0`. +/// 5. `EAllowanceExceeded`: finite `remaining` and `amount > remaining`; +/// includes suspended-at-zero. +/// 6. *(execution status)* `InsufficientFundsForWithdraw`: the object's settled +/// balance is below `amount`. A funds-accumulator execution status raised at +/// `redeem_funds` (surfaced in effects / dry run / SDK), NOT a Move `#[error]` +/// code you can match with `expected_failure(abort_code = ...)`, and not one +/// of this module's codes. +public fun spend( + v: &mut Vault, + cap: &SpenderCap, + amount: u64, + clock: &Clock, + ctx: &mut TxContext, +): Balance { + let vault_id = object::id(v); + let cap_id = object::id(cap); + + // 1. Binding gate, before any ledger access. + assert!(cap.vault_id == vault_id, EWrongVault); + + let coin_type = type_name::with_defining_ids(); + let key = BudgetKey { cap_id, coin_type }; + + // 2. Existence. Absent (never granted / revoked / a different coin) is + // deliberately distinct from suspended-at-zero (check 5). This is the + // runtime coin-type gate. + assert!(v.allowances.contains(key), ENoAllowance); + + // Read phase: copy the two scalars; the immutable borrow ends here. + let (remaining, expires_at_ms) = { + let entry = v.allowances.borrow(key); + (entry.remaining, entry.expires_at_ms) + }; + + // 3. Closed boundary: a spend in the exact millisecond of expiry fails. The + // no-expiry sentinel short-circuits by equality. + assert!( + expires_at_ms == std::u64::max_value!() || clock.timestamp_ms() < expires_at_ms, + EAllowanceExpired, + ); + + // 4. No zero-value draws. + assert!(amount > 0, EZeroAmount); + + // 5. Compare-before-decrement (no underflow path exists). The unlimited + // sentinel short-circuits by equality, no arithmetic. + assert!( + remaining == std::u64::max_value!() || amount <= remaining, + EAllowanceExceeded, + ); + + // === Commit (all five library checks passed; no library abort below) === + // + // Order is load-bearing: decrement the budget, THEN draw from the + // pool. The pool is deliberately NOT pre-checked against the root; + // if it is short, `redeem_funds` fails with the `InsufficientFundsForWithdraw` + // execution status (when the object's settled balance is below the amount) + // and Move's atomic revert rolls the decrement back. No external call runs + // between the decrement and the withdraw, so there is no observable window + // where the budget shrank but no funds moved. + + // Exact decrement; the unlimited sentinel is never decremented. + let remaining_after = if (remaining == std::u64::max_value!()) { + remaining + } else { + remaining - amount + }; + v.allowances.borrow_mut(key).remaining = remaining_after; + + // Draw exactly `amount` from the per-coin address balance via `&mut v.id`: + // no signer, only this module's cap-gated `&mut UID`. + // `withdraw_funds_from_object` only builds the Withdrawal; the fund movement + // and the pool-short check both happen at `redeem_funds`. + let w = balance::withdraw_funds_from_object(&mut v.id, amount); + let bal = balance::redeem_funds(w); + + // Emit AFTER redeem succeeds: a reverted pool-short spend emits + // nothing. + event::emit(Spent { + vault_id, + cap_id, + coin_type, + amount, + remaining: remaining_after, + caller: ctx.sender(), + }); + + bal +} + +// === Revoke / renounce / cap disposal === + +/// Owner kill-switch for ONE coin: remove the `(cap_id, T)` entry. IDEMPOTENT +/// and ledger-state-independent: a present entry is removed, an absent one is +/// a no-op, and the return says which (`was_present == false` is the typo'd +/// cap_id / wrong-coin signal, never a success-shaped lie). No allowance state +/// (absent, suspended, expired, unlimited) can make it abort: the kill-switch +/// cannot be raced into failure. +/// +/// Strictly per-coin: revoking `(cap, USDC)` leaves `(cap, SUI)` and +/// every other coin of the cap untouched. The coin type stays in +/// `granted_coin_types` (grows-only); a later `revoke_all`/`renounce` probe of +/// it is a harmless no-op. +/// +/// NOT retroactive: a spend sequenced before the owner's tx still succeeds. +/// Pair `revoke`/`revoke_all` (durably kills authority) with `withdraw_all` +/// (sweeps funds, but reversible by permissionless deposit) for emergencies. +/// The cap OBJECT survives in its holder's wallet as inert non-authority; +/// dispose of it via `renounce` (live vault) or `delete_orphaned_cap`. +/// +/// #### Parameters +/// - `v`: The vault whose ledger entry is removed. +/// - `cap`: The OwnerCap bound to `v`. +/// - `cap_id`: The SpenderCap object id whose `(cap_id, T)` entry is targeted. +/// - `ctx`: Transaction context, used to attribute the `Revoked` event. +/// +/// #### Returns +/// - `true` if an entry was present and removed; `false` if there was none. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: only abort. +public fun revoke(v: &mut Vault, cap: &OwnerCap, cap_id: ID, ctx: &mut TxContext): bool { + // Owner gate: the ONLY check, so no state can race this into failure. + assert!(cap.vault_id == object::id(v), EWrongOwnerCap); + + let coin_type = type_name::with_defining_ids(); + let key = BudgetKey { cap_id, coin_type }; + + let was_present = if (v.allowances.contains(key)) { + // Allowance has `drop`; removal recovers the entry's storage rebate to + // this tx's gas payer. `granted_coin_types` is grows-only and untouched. + v.allowances.remove(key); + true + } else { + false + }; + + // Emitted on EVERY non-aborting call, no-op included. + event::emit(Revoked { + vault_id: object::id(v), + cap_id, + coin_type, + was_present, + by: ctx.sender(), + }); + + was_present +} + +/// Owner whole-cap kill: remove EVERY `(cap_id, T)` entry the cap holds, in one +/// call: the blast-radius answer for a leaked cap spanning N budgets. +/// Iterates the vault's `granted_coin_types` and emits one `Revoked` per removed +/// coin. A cap with no entries emits nothing and still succeeds; total on ledger +/// state, it cannot be raced into failure. +/// +/// **Un-griefable.** It iterates `granted_coin_types`, written +/// ONLY by `set_allowance`-create (owner action), so permissionless +/// `deposit`/`squash` can never inflate the loop toward the gas ceiling. +/// Owner-bounded by construction. It never touches another cap's entries (it +/// only builds keys with this `cap_id`); a coin this cap never held is a +/// harmless no-op probe. +/// +/// **`cap_id` is the SPENDER cap's id, UNVALIDATED:** a wrong id is a +/// silent whole no-op (no `Revoked` emitted), leaving the intended cap LIVE. +/// During an incident, confirm the kill landed via the emitted `Revoked` events +/// (one per removed coin) or a `contains` recheck. +/// +/// NOT retroactive (see `revoke`). For an emergency stop, `revoke_all` is the +/// PRIMARY action: run it FIRST in its own tx (it never touches the pool, so it +/// cannot be raced into failure), THEN `withdraw_all` per coin in a later tx +/// (retry-safe). Do NOT bundle them in one PTB: a same-checkpoint pool-short in +/// `withdraw_all` would revert the `revoke_all` with it (the settled-vs-live skew). +/// +/// #### Parameters +/// - `v`: The vault whose ledger entries are removed. +/// - `cap`: The OwnerCap bound to `v`. +/// - `cap_id`: The SpenderCap object id whose entries are all removed. +/// - `ctx`: Transaction context, used to attribute each `Revoked` event. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: only abort. +public fun revoke_all(v: &mut Vault, cap: &OwnerCap, cap_id: ID, ctx: &mut TxContext) { + assert!(cap.vault_id == object::id(v), EWrongOwnerCap); + + let vault_id = object::id(v); + let by = ctx.sender(); + + // Snapshot the owner-written type set (a copy) so the loop can mutate the + // ledger with no outstanding immutable borrow of the vault. O(k) in the + // owner-granted distinct coin types (owner-bounded, un-griefable). + let types = *v.granted_coin_types.keys(); + let n = types.length(); + let mut i = 0; + while (i < n) { + let coin_type = *types.borrow(i); + let key = BudgetKey { cap_id, coin_type }; + if (v.allowances.contains(key)) { + v.allowances.remove(key); + event::emit(Revoked { vault_id, cap_id, coin_type, was_present: true, by }); + }; + i = i + 1; + }; +} + +/// Spender self-revoke against a LIVE vault, whole-cap. Consumes the cap by +/// value, removes EVERY `(cap_id, T)` entry it holds, deletes the cap object: +/// the only path that removes both sides atomically. No inert authority-shaped +/// garbage survives, and each entry's storage rebate routes to this tx's gas +/// payer. +/// +/// Total on ledger state: a cap whose entries were already revoked +/// still renounces successfully (absent coins are harmless probes); the cap is +/// always deleted. Un-griefable: it iterates the owner-bounded +/// `granted_coin_types`. Emits one coin-agnostic `Renounced` (the terminal +/// event closes every `(cap, *)` from an indexer's view). +/// +/// If the vault is already destroyed this is uncallable (no `&mut Vault` +/// exists); use `delete_orphaned_cap` for orphaned caps. +/// +/// #### Parameters +/// - `v`: The live vault whose entries for this cap are removed. +/// - `cap`: The SpenderCap to renounce, bound to `v` (consumed by value). +/// - `ctx`: Transaction context, used to attribute the `Renounced` event. +/// +/// #### Aborts (in order) +/// 1. `EWrongVault`: cap bound to a different Vault. Only abort: an +/// already-revoked entry is fine. +public fun renounce(v: &mut Vault, cap: SpenderCap, ctx: &mut TxContext) { + let vault_id = object::id(v); + assert!(cap.vault_id == vault_id, EWrongVault); + + let SpenderCap { id, vault_id: _ } = cap; + let cap_id = id.to_inner(); + + // Remove every (cap, T) entry the cap holds. Snapshot the type set (copy) + // so the loop can mutate the ledger; absent coins are harmless no-op probes. + let types = *v.granted_coin_types.keys(); + let n = types.length(); + let mut i = 0; + while (i < n) { + let key = BudgetKey { cap_id, coin_type: *types.borrow(i) }; + if (v.allowances.contains(key)) { + v.allowances.remove(key); + }; + i = i + 1; + }; + + id.delete(); + + event::emit(Renounced { vault_id, cap_id, by: ctx.sender() }); +} + +/// Dispose of an ORPHANED cap, one whose vault was already `destroy`ed. +/// `renounce` is the live-vault path (it needs `&mut Vault`, gone after +/// teardown); this is its vault-less counterpart. Total: never aborts, touches +/// no vault state, deletes exactly the cap's UID, and emits +/// `CapDeleted { vault_id, cap_id }` so event-only indexers can follow it. +/// +/// **On a LIVE vault, prefer `renounce`.** Deleting a live cap STRANDS ALL of +/// its `(cap, T)` entries at once (one cap spans N coins). Each becomes inert +/// (the cap is gone, so it is unspendable, NOT live authority) but lingers in +/// the ledger, still visible via `contains`, and you forfeit the storage +/// rebates `renounce` would have recovered. +/// +/// **Owner cleanup of a stranded cap:** take the `cap_id` from the `CapDeleted` +/// event (or your issuance records) and call +/// `revoke_all(&mut vault, &owner_cap, cap_id, ctx)` to remove the entries and +/// reclaim their rebate. Optional, though: the entries are inert, and `destroy` +/// drains the whole ledger regardless. +/// +/// #### Parameters +/// - `cap`: The orphaned SpenderCap to delete (consumed by value). +/// +/// #### Aborts +/// Never. +public fun delete_orphaned_cap(cap: SpenderCap) { + let SpenderCap { id, vault_id } = cap; + let cap_id = id.to_inner(); + id.delete(); + + event::emit(CapDeleted { vault_id, cap_id }); +} + +// === Recovery === + +/// Recover a stray `Coin` that was `public_transfer`'d to the vault address +/// (it lands as a loose owned object, counted in the address's totals but NOT +/// in the spendable address balance) by folding it back into the per-coin pool. +/// PERMISSIONLESS and STRICTLY FUNDS-IN: it can only move value INTO +/// the pool, never out or elsewhere, so exposing it to the world has no griefing +/// or extraction vector (the worst a caller can do is donate). Writes no type +/// set; the squashed type is still enumerable for teardown via the +/// off-chain `getAllBalances`. Emits `Squashed` (distinct from `Deposited` so +/// indexers separate recovered strays). +/// +/// Recovers only strays sent to THIS vault: a generic cross-address squash is +/// unbuildable (you cannot consume a coin you do not control). It needs +/// `&mut v.id` only for `public_receive`. +/// +/// #### Parameters +/// - `v`: The vault whose pool receives the recovered stray. +/// - `c`: The `Receiving>` ticket for the stray coin sent to `v`. +/// - `ctx`: Transaction context, used to attribute the `Squashed` event. +/// +/// #### Aborts +/// Never on pool/ledger state. (The framework `public_receive` can abort on an +/// invalid or stale `Receiving` ticket: a framework guarantee, not a library +/// abort.) +public fun squash(v: &mut Vault, c: Receiving>, ctx: &mut TxContext) { + let vault_id = object::id(v); + + let coin = transfer::public_receive(&mut v.id, c); + let amount = coin.value(); + coin.into_balance().send_funds(vault_id.to_address()); + + event::emit(Squashed { + vault_id, + coin_type: type_name::with_defining_ids(), + amount, + by: ctx.sender(), + }); +} + +// === Owner exit (consults only the cap binding + pool, never the ledger) === + +/// Owner-only. Withdraw exactly `amount` of coin `T` from the pool as +/// `Balance`. May leave live allowances unbacked: intended (allowances are +/// ceilings; the next over-pool spend fails with the `InsufficientFundsForWithdraw` +/// execution status with a live budget). +/// +/// Consults only the OwnerCap binding and the pool, never the ledger, +/// so no spender state can block it. Pool-short is the Sui execution status +/// `InsufficientFundsForWithdraw` (a funds-accumulator `ExecutionFailureStatus`), +/// raised at `redeem_funds` when the object's settled balance is below the +/// amount, consistent with `spend` (no root, no pre-check). +/// +/// #### Parameters +/// - `v`: The vault whose pool is drawn down. +/// - `cap`: The OwnerCap bound to `v`. +/// - `amount`: Units of coin `T` to withdraw; must be positive. +/// - `ctx`: Transaction context, used to attribute the `Withdrawn` event. +/// +/// #### Returns +/// - A `Balance` of exactly `amount`; the caller must consume it. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: cap bound to a different Vault. +/// 2. `EZeroAmount`: `amount == 0`. +/// 3. *(execution status)* `InsufficientFundsForWithdraw`: the object's settled +/// balance is below `amount`. A funds-accumulator execution status raised at +/// `redeem_funds` (surfaced in effects / dry run / SDK), NOT a Move `#[error]` +/// code you can match with `expected_failure(abort_code = ...)`, and not one +/// of this module's codes. +public fun withdraw( + v: &mut Vault, + cap: &OwnerCap, + amount: u64, + ctx: &mut TxContext, +): Balance { + let vault_id = object::id(v); + assert!(cap.vault_id == vault_id, EWrongOwnerCap); + assert!(amount > 0, EZeroAmount); + + let w = balance::withdraw_funds_from_object(&mut v.id, amount); + let bal = balance::redeem_funds(w); + + event::emit(Withdrawn { + vault_id, + coin_type: type_name::with_defining_ids(), + amount, + by: ctx.sender(), + }); + + bal +} + +/// Owner-only. Drain the SETTLED `T` pool as a possibly-zero `Balance`. It +/// reads `settled_funds_value(root, vault_address)` (the START-OF-CHECKPOINT +/// snapshot) and withdraws exactly that. There is deliberately no +/// caller-supplied amount: a fixed amount would be a stale-amount DoS (a racing +/// spend or top-up between read and call would over-withdraw and abort, or +/// under-withdraw and strand). An empty settled pool returns a zero `Balance` +/// (consume it via `destroy_zero` or a join) without touching the accumulator. +/// +/// **The settled-vs-live skew: NOT abort-free against the pool.** The +/// settled read disagrees with `redeem_funds`'s LIVE check whenever the pool +/// moved earlier in the SAME consensus checkpoint (a prior `spend`/`withdraw` on +/// this vault, including an earlier command in the same PTB): +/// - over-ask -> abort: a prior same-checkpoint `spend` lowers the live pool below +/// the settled snapshot, so the withdraw fails with the `InsufficientFundsForWithdraw` +/// execution status (a funds-accumulator `ExecutionFailureStatus` at +/// `redeem_funds`, when the object's settled balance is below the amount) (e.g. +/// settled 1000, a prior `spend(600)` +/// leaves live 400, draining 1000 against 400 aborts; even `spend(1)` trips it); +/// - under-drain: a same-checkpoint `deposit` is not yet in the snapshot, so the +/// drain misses it. +/// Both are RETRY-SAFE: the next checkpoint settles and a retry succeeds. It still +/// NEVER aborts on spender/ledger state. +/// +/// Call once per coin type in the drain-before-`destroy` ritual, +/// enumerating types off-chain via `getAllBalances`. Do NOT sequence it after a +/// `spend`/`withdraw` on this vault in the same PTB (deterministic abort). +/// +/// CAVEAT: `withdraw_all`-as-freeze is REVERSIBLE: `deposit` is permissionless, +/// so anyone can re-arm live allowances by topping up the pool. The durable +/// kill-all is `revoke_all` or `destroy`. For an emergency stop, run `revoke_all` +/// FIRST in its own tx (pool-independent, cannot be raced), THEN `withdraw_all` +/// in a later tx; do NOT bundle them, or a front-run `spend(1)` reverts the whole +/// PTB and rolls back the `revoke_all` with it. +/// +/// #### Parameters +/// - `v`: The vault whose settled `T` pool is drained. +/// - `cap`: The OwnerCap bound to `v`. +/// - `root`: The `AccumulatorRoot`, read for the settled (start-of-checkpoint) +/// pool value. +/// - `ctx`: Transaction context, used to attribute the `Withdrawn` event. +/// +/// #### Returns +/// - A possibly-zero `Balance` holding the drained settled pool; the caller +/// must consume it. +/// +/// #### Aborts (in order) +/// 1. `EWrongOwnerCap`: cap bound to a different Vault. +/// 2. *(execution status)* `InsufficientFundsForWithdraw`: the live pool fell +/// below the settled snapshot earlier in this checkpoint (the settled-vs-live +/// skew; retry-safe), so the object's settled balance is below the amount. A +/// funds-accumulator execution status raised at `redeem_funds` (surfaced in +/// effects / dry run / SDK), NOT a Move `#[error]` code you can match with +/// `expected_failure(abort_code = ...)`, and not one of this module's codes. +/// Never aborts on spender/ledger state. +public fun withdraw_all( + v: &mut Vault, + cap: &OwnerCap, + root: &AccumulatorRoot, + ctx: &mut TxContext, +): Balance { + let vault_id = object::id(v); + assert!(cap.vault_id == vault_id, EWrongOwnerCap); + + // Drain-exact: read the SETTLED (start-of-checkpoint) pool (a self-tracked + // counter would desync under permissionless top-ups) and + // withdraw exactly it. The settled-vs-live skew: this settled value can over-ask + // the LIVE balance if a prior same-checkpoint spend/withdraw lowered it, so redeem + // may fail with the `InsufficientFundsForWithdraw` execution status (retry-safe next + // checkpoint). The empty settled + // case is a clean zero-balance no-op that never reaches the flagged primitive. + let amount = balance::settled_funds_value(root, vault_id.to_address()); + let bal = if (amount == 0) { + balance::zero() + } else { + let w = balance::withdraw_funds_from_object(&mut v.id, amount); + balance::redeem_funds(w) + }; + + event::emit(Withdrawn { + vault_id, + coin_type: type_name::with_defining_ids(), + amount, + by: ctx.sender(), + }); + + bal +} + +// === View helpers === + +// All reads are TOTAL: they never abort, for any input, in any vault state +// (absent `(cap, T)`, revoked, suspended, expired, empty pool, zero coin +// types). Absent entries return the documented defaults, not errors. +// +// All reads are ADVISORY: results are stale the moment a later tx mutates the +// vault or the pool (a permissionless `send_funds` top-up moves the pool between +// a read and a later act). Cross-tx check-then-act is unsound; within one PTB +// the shared Vault is locked for the whole tx, so read -> decide -> write is +// atomic (the CAS idiom on `set_allowance`). The pool-reading reads take the +// `AccumulatorRoot` and report the settled (start-of-checkpoint) balance. + +/// Raw `remaining` for `(cap, T)`; `0` if absent. Ambiguous at 0: suspended and +/// absent both read 0, disambiguate with `contains`. `u64::MAX` is the +/// unlimited sentinel, not a volume. +public fun allowance(v: &Vault, cap_id: ID): u64 { + let key = budget_key(cap_id); + if (v.allowances.contains(key)) { + v.allowances.borrow(key).remaining + } else { + 0 + } +} + +/// What a `spend` through this entry could draw RIGHT NOW: `0` if absent, +/// expired, or suspended (`remaining == 0`), else `min(remaining, settled_pool)`; +/// for an unlimited entry this +/// reduces to the settled pool. ADVISORY UPPER BOUND, not a guarantee: the pool +/// term is the SETTLED (start-of-checkpoint) value, so +/// `spend(spendable_now(...))` can still fail with the +/// `InsufficientFundsForWithdraw` execution status if a +/// prior same-checkpoint op reduced the LIVE pool below this quote (the +/// settled-vs-live skew). Time and budget do hold (no intervening mutation); treat +/// the pool as a ceiling and handle the abort, or avoid same-checkpoint contention. +/// Guard `> 0` before feeding it to `spend`: a zero quote aborts `EZeroAmount`. +/// +/// #### Parameters +/// - `v`: The vault to inspect. +/// - `cap_id`: The SpenderCap object id whose `(cap_id, T)` entry is quoted. +/// - `root`: The `AccumulatorRoot`, read for the settled `T` pool value. +/// - `clock`: Reference to the Sui `Clock`, used to evaluate expiry. +/// +/// #### Returns +/// - The advisory upper bound on a current `spend`; `0` when absent, expired, +/// or suspended (`remaining == 0`). +public fun spendable_now( + v: &Vault, + cap_id: ID, + root: &AccumulatorRoot, + clock: &Clock, +): u64 { + let key = budget_key(cap_id); + if (!v.allowances.contains(key)) { + return 0 + }; + let entry = v.allowances.borrow(key); + // Same closed boundary as `spend` check 3. + if (entry.expires_at_ms != std::u64::max_value!() + && clock.timestamp_ms() >= entry.expires_at_ms) { + return 0 + }; + // The u64::MAX sentinel is min's neutral element: unlimited reduces to the + // settled pool with no special case. + entry.remaining.min(balance::settled_funds_value(root, object::id(v).to_address())) +} + +/// Raw `expires_at_ms` for `(cap, T)`; `0` if absent. A present entry's value is +/// a future timestamp or the `u64::MAX` no-expiry sentinel, never `0`, so use +/// `contains` to distinguish absent from present. +public fun expiry(v: &Vault, cap_id: ID): u64 { + let key = budget_key(cap_id); + if (v.allowances.contains(key)) { + v.allowances.borrow(key).expires_at_ms + } else { + 0 + } +} + +/// Ledger membership for `(cap, T)`: the absent-vs-suspended disambiguator. +/// `allowance == 0 && contains` is a suspended (or drained) entry whose cap is +/// still valid; `!contains` is never-granted / revoked / renounced. +public fun contains(v: &Vault, cap_id: ID): bool { + v.allowances.contains(budget_key(cap_id)) +} + +/// The settled `T` pool at the vault's address (the START-OF-CHECKPOINT snapshot; +/// advisory). Named for the balance it reports, though the funds live as an address +/// balance rather than a struct field. NOTE: deriving a `withdraw(amount)` from +/// this read can still fail with the `InsufficientFundsForWithdraw` execution +/// status if the live pool dropped since the read (the settled-vs-live skew). +public fun balance_value(v: &Vault, root: &AccumulatorRoot): u64 { + balance::settled_funds_value(root, object::id(v).to_address()) +} + +/// The coin types the OWNER has granted: exactly what `revoke_all`/`renounce` +/// iterate (SDK / indexer aid). GROWS-ONLY and never pruned: includes +/// every type ever granted, even ones whose entries were all revoked. NOTE: this +/// is NOT the drain-before-`destroy` list: that is off-chain +/// `getAllBalances(vault_address)`, which also surfaces untracked `send_funds` +/// types and loose coins. +public fun granted_coin_types(v: &Vault): vector { + *v.granted_coin_types.keys() +} + +/// The Vault this OwnerCap is bound to: on-chain custodians validate the binding +/// before accepting a cap. +public fun owner_cap_vault_id(cap: &OwnerCap): ID { + cap.vault_id +} + +/// The Vault this SpenderCap is bound to: protocols accepting a user's cap MUST +/// check this against the expected vault before custody (see the module-level +/// bearer-cap warning). +public fun spender_cap_vault_id(cap: &SpenderCap): ID { + cap.vault_id +} + +// === Private Functions === + +/// Build the composite ledger key for `(cap_id, T)`. The canonical +/// `with_defining_ids()` (never the deprecated `get`) keeps keys stable +/// across grant and spend. Used by the read paths; the mutating +/// paths build the key inline because they also emit `coin_type` in their event. +fun budget_key(cap_id: ID): BudgetKey { + BudgetKey { cap_id, coin_type: type_name::with_defining_ids() } +} + +// === Test-Only Helpers === + +// Event-value constructors for test-side equality assertions (the events are +// otherwise module-private and unconstructable). One per event, matching the +// untyped + runtime `coin_type` schema. + +#[test_only] +public fun test_new_vault_created( + vault_id: ID, + owner_cap_id: ID, + creator: address, +): VaultCreated { + VaultCreated { vault_id, owner_cap_id, creator } +} + +#[test_only] +public fun test_new_deposited( + vault_id: ID, + coin_type: TypeName, + amount: u64, + depositor: address, +): Deposited { + Deposited { vault_id, coin_type, amount, depositor } +} + +#[test_only] +public fun test_new_squashed( + vault_id: ID, + coin_type: TypeName, + amount: u64, + by: address, +): Squashed { + Squashed { vault_id, coin_type, amount, by } +} + +#[test_only] +public fun test_new_spender_cap_minted(vault_id: ID, cap_id: ID, by: address): SpenderCapMinted { + SpenderCapMinted { vault_id, cap_id, by } +} + +#[test_only] +public fun test_new_allowance_set( + vault_id: ID, + cap_id: ID, + coin_type: TypeName, + new_amount: u64, + new_expires_at_ms: u64, + cas_was_provided: bool, + was_created: bool, + by: address, +): AllowanceSet { + AllowanceSet { + vault_id, + cap_id, + coin_type, + new_amount, + new_expires_at_ms, + cas_was_provided, + was_created, + by, + } +} + +#[test_only] +public fun test_new_spent( + vault_id: ID, + cap_id: ID, + coin_type: TypeName, + amount: u64, + remaining: u64, + caller: address, +): Spent { + Spent { vault_id, cap_id, coin_type, amount, remaining, caller } +} + +#[test_only] +public fun test_new_revoked( + vault_id: ID, + cap_id: ID, + coin_type: TypeName, + was_present: bool, + by: address, +): Revoked { + Revoked { vault_id, cap_id, coin_type, was_present, by } +} + +#[test_only] +public fun test_new_renounced(vault_id: ID, cap_id: ID, by: address): Renounced { + Renounced { vault_id, cap_id, by } +} + +#[test_only] +public fun test_new_withdrawn( + vault_id: ID, + coin_type: TypeName, + amount: u64, + by: address, +): Withdrawn { + Withdrawn { vault_id, coin_type, amount, by } +} + +#[test_only] +public fun test_new_vault_destroyed(vault_id: ID, by: address): VaultDestroyed { + VaultDestroyed { vault_id, by } +} + +#[test_only] +public fun test_new_cap_deleted(vault_id: ID, cap_id: ID): CapDeleted { + CapDeleted { vault_id, cap_id } +} diff --git a/contracts/allowance/tests/spend_vault/cap_budget_tests.move b/contracts/allowance/tests/spend_vault/cap_budget_tests.move new file mode 100644 index 0000000..631e8de --- /dev/null +++ b/contracts/allowance/tests/spend_vault/cap_budget_tests.move @@ -0,0 +1,1098 @@ +// mint_cap + set_allowance: the two owner verbs (cap issuance + budget upsert). +// +// Covers the ledger / cap / event surface of the two owner-side verbs: bare +// vault-bound cap issuance, the owner gate, upsert create/overwrite semantics, +// expiry validity and revival, the no-expiry and unlimited-budget sentinels, +// compare-and-set, suspension via amount 0, granted-coin-type tracking, +// per-(cap, coin) independence, cap_id stability, and event emission. +// +// Pool-balance effects need a live AccumulatorRoot, which the unit-test VM +// cannot construct; those are covered by integration tests. +module openzeppelin_allowance::spend_vault_cap_budget_tests; + +use openzeppelin_allowance::spend_vault::{Self, SpenderCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT, DEEP, FOO}; +use std::type_name; +use std::unit_test::{assert_eq, destroy}; +use sui::coin; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === mint_cap === + +#[test] +// mint_cap returns a bare, vault-bound cap; NO ledger entry exists for it until +// set_allowance; SpenderCapMinted is emitted bare. +fun mint_cap_is_bare_and_bound() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + + // cap binds to THIS vault. + assert_eq!(spend_vault::spender_cap_vault_id(&cap), vid); + // no entry exists before any set_allowance. + assert!(!spend_vault::contains(&v, cid)); + assert_eq!(spend_vault::allowance(&v, cid), 0); + // granted_coin_types is still empty (mint records nothing). + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + + // exactly one SpenderCapMinted, bare. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_spender_cap_minted(vid, cid, OWNER)); + + transfer::public_transfer(cap, SPENDER); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +// mint_cap with a FOREIGN OwnerCap aborts EWrongOwnerCap. +fun mint_cap_foreign_owner_aborts() { + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + let v = u::take_vault(&s); + // an OwnerCap bound to a DIFFERENT vault + let (_vb, ocb) = spend_vault::new(s.ctx()); + let _cap = spend_vault::mint_cap(&v, &ocb, s.ctx()); + abort +} + +#[test] +// two mint_cap calls yield two distinct cap_ids, the only way to get two summing +// budgets for one person. +fun mint_cap_twice_distinct_ids() { + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + + let cap1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cap2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + assert!(object::id(&cap1) != object::id(&cap2)); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 2); + + transfer::public_transfer(cap1, SPENDER); + transfer::public_transfer(cap2, SPENDER); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === set_allowance: create / overwrite === + +#[test] +// create on an absent (cap, T): was_created==true, allowance==budget, expiry set, +// granted_coin_types now has T, AllowanceSet emitted. +fun set_allowance_create_on_absent() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + + assert_eq!(spend_vault::allowance(&v, cid), 500); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + assert!(spend_vault::contains(&v, cid)); + // granted_coin_types now records USDC. + let gct = spend_vault::granted_coin_types(&v); + assert_eq!(gct.length(), 1); + assert!(gct.contains(&type_name::with_defining_ids())); + + // one AllowanceSet, was_created==true, cas_was_provided==false. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_allowance_set( + vid, cid, type_name::with_defining_ids(), 500, MAXU64, false, true, OWNER, + ), + ); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// a second set on the same (cap, T) OVERWRITES (does not add): 500 then 800 -> 800 +// (not 1300); was_created==false on the overwrite. +fun set_allowance_overwrite_not_additive() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 800, MAXU64, option::none(), &clk, s.ctx()); + + // OVERWRITE, not 500+800. + assert_eq!(spend_vault::allowance(&v, cid), 800); + // granted_coin_types not duplicated by the second create-path probe. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 1); + + // The second emit carries was_created==false. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 2); + assert_eq!( + evs[1], + spend_vault::test_new_allowance_set( + vid, cid, type_name::with_defining_ids(), 800, MAXU64, false, false, OWNER, + ), + ); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Suspension + revival === + +#[test] +// set amount 0 does NOT abort (no EZeroAmount); contains stays true, allowance +// reads 0 (live-but-suspended). +fun set_allowance_zero_suspends_no_abort() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + // amount 0 = suspend, NOT EZeroAmount. + spend_vault::set_allowance(&mut v, &oc, cid, 0, MAXU64, option::none(), &clk, s.ctx()); + + assert!(spend_vault::contains(&v, cid)); + assert_eq!(spend_vault::allowance(&v, cid), 0); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// a suspended (amount-0) entry revives to a positive budget in place. +fun set_allowance_revives_suspended() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 0, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + + assert_eq!(spend_vault::allowance(&v, cid), 500); + assert!(spend_vault::contains(&v, cid)); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// setting a finite future expiry on an expired entry REVIVES it in place (same +// cap_id, same key). +fun set_allowance_future_expiry_revives_expired() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + let exp = u::start_ms() + 1_000; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let mut clk = u::take_clock(&s); + + // create with a finite expiry, then advance past it (entry now expired). + spend_vault::set_allowance(&mut v, &oc, cid, 500, exp, option::none(), &clk, s.ctx()); + clk.set_for_testing(exp + 50); + + // restate a fresh future expiry -> revives in place. + let exp2 = exp + 5_000; + spend_vault::set_allowance(&mut v, &oc, cid, 500, exp2, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::expiry(&v, cid), exp2); + assert_eq!(spend_vault::allowance(&v, cid), 500); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Expiry validity === + +#[test, expected_failure(abort_code = spend_vault::EExpiryInPast)] +// a finite new_expires_at_ms == clock.now aborts EExpiryInPast (closed boundary: +// must be strictly future). +fun set_allowance_expiry_equals_now_aborts() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + // now == start_ms; expiry == now aborts EExpiryInPast + spend_vault::set_allowance(&mut v, &oc, cid, 500, u::start_ms(), option::none(), &clk, s.ctx()); + abort +} + +#[test] +// new_expires_at_ms == now + 1 (strictly future) succeeds. +fun set_allowance_expiry_now_plus_one_ok() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + let exp = u::start_ms() + 1; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 500, exp, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::expiry(&v, cid), exp); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// new_expires_at_ms == u64::MAX (no-expiry sentinel) always passes; the +// expires_at_ms == u64::MAX equality short-circuits the strictly-future check +// before any comparison against "now". +fun set_allowance_expiry_sentinel_ok() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Sentinels === + +#[test] +// budget u64::MAX (unlimited) AND expiry u64::MAX (no-expiry) both succeed. +fun set_allowance_unlimited_budget_and_no_expiry() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, MAXU64, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), MAXU64); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === CAS === + +#[test] +// Some(e) matching the current remaining overwrites (set 400 with Some(400)). +// cas_was_provided==true on the AllowanceSet. +fun set_allowance_cas_match_overwrites() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 400, MAXU64, option::none(), &clk, s.ctx()); + // CAS: expect 400 (current), set to 250. + spend_vault::set_allowance(&mut v, &oc, cid, 250, MAXU64, option::some(400), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 250); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 2); + assert_eq!( + evs[1], + spend_vault::test_new_allowance_set( + vid, cid, type_name::with_defining_ids(), 250, MAXU64, true, false, OWNER, + ), + ); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EUnexpectedAllowance)] +// Some(e) MISMATCH (expect a value other than current remaining) aborts +// EUnexpectedAllowance. +fun set_allowance_cas_mismatch_aborts() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 400, MAXU64, option::none(), &clk, s.ctx()); + // current remaining is 400; expecting 399 aborts EUnexpectedAllowance + spend_vault::set_allowance(&mut v, &oc, cid, 250, MAXU64, option::some(399), &clk, s.ctx()); + abort +} + +#[test, expected_failure(abort_code = spend_vault::EUnexpectedAllowance)] +// Some(e) on an ABSENT (cap, T) aborts EUnexpectedAllowance; you cannot CAS-match +// a value that does not exist. +fun set_allowance_cas_on_absent_aborts() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + // never created (cap, USDC); a CAS of Some(0) still aborts (absent != match) + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::some(0), &clk, s.ctx()); + abort +} + +#[test] +// None is the unconditional create/overwrite; the create event records +// cas_was_provided==false. +fun set_allowance_none_unconditional_create() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 700, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 700); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + // cas_was_provided flag is false on the None path. + assert_eq!( + evs[0], + spend_vault::test_new_allowance_set( + vid, cid, type_name::with_defining_ids(), 700, MAXU64, false, true, OWNER, + ), + ); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// CAS compares the RAW remaining including the unlimited sentinel: Some(u64::MAX) +// matches an unlimited entry and overwrites. +fun set_allowance_cas_matches_unlimited_sentinel() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, MAXU64, MAXU64, option::none(), &clk, s.ctx()); + // expect the unlimited sentinel; reduce to a finite budget. + spend_vault::set_allowance(&mut v, &oc, cid, 600, MAXU64, option::some(MAXU64), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 600); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// CAS compares the RAW remaining including 0: Some(0) matches a suspended entry +// and revives it. +fun set_allowance_cas_matches_suspended_zero() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 0, MAXU64, option::none(), &clk, s.ctx()); + // current remaining is 0 (suspended); Some(0) matches. + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::some(0), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 500); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Owner gate + precedence === + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +// set_allowance with a FOREIGN OwnerCap aborts EWrongOwnerCap first. +fun set_allowance_foreign_owner_aborts() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + // an OwnerCap for a DIFFERENT vault + let (_vb, ocb) = spend_vault::new(s.ctx()); + spend_vault::set_allowance(&mut v, &ocb, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + abort +} + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +// precedence: with a foreign owner cap AND a past expiry, EWrongOwnerCap wins over +// EExpiryInPast (the owner gate is checked first). +fun precedence_wrong_owner_beats_expiry_in_past() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let (_vb, ocb) = spend_vault::new(s.ctx()); + // foreign cap AND expiry == now (past): the gate fires before the expiry check + spend_vault::set_allowance(&mut v, &ocb, cid, 500, u::start_ms(), option::none(), &clk, s.ctx()); + abort +} + +#[test, expected_failure(abort_code = spend_vault::EExpiryInPast)] +// precedence: with a past expiry AND a CAS mismatch, EExpiryInPast wins over +// EUnexpectedAllowance (the expiry check fires before the CAS check). +fun precedence_expiry_in_past_beats_cas_mismatch() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + // create so a CAS could otherwise be evaluated, then trigger past-expiry + CAS mismatch + spend_vault::set_allowance(&mut v, &oc, cid, 400, MAXU64, option::none(), &clk, s.ctx()); + // expiry == now (past) AND CAS expects 999 (mismatch): the expiry check fires first + spend_vault::set_allowance(&mut v, &oc, cid, 250, u::start_ms(), option::some(999), &clk, s.ctx()); + abort +} + +// === set_allowance is upsert, never ENoAllowance === + +#[test] +// set_allowance on a never-granted (cap, T) does NOT abort ENoAllowance; it CREATES +// (the upsert), distinguishing set_allowance from spend. +fun set_allowance_on_absent_never_no_allowance() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + // FOO never granted; this creates rather than aborting ENoAllowance. + assert!(!spend_vault::contains(&v, cid)); + spend_vault::set_allowance(&mut v, &oc, cid, 123, MAXU64, option::none(), &clk, s.ctx()); + assert!(spend_vault::contains(&v, cid)); + assert_eq!(spend_vault::allowance(&v, cid), 123); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === cap_id stability: the load-bearing composition property === + +#[test] +// across create, raise, lower, suspend(0), renew-expiry, the SpenderCap object's id +// is unchanged and the SAME cap still spends under the final params. The owner uses +// cap_id (ID), never the cap object. +fun cap_id_stable_across_all_updates_then_spends() { + let mut s = ts::begin(OWNER); + + // Setup in one tx: vault funded, cap minted+held by SPENDER, no grant yet. + let cid_holder: ID; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + cid_holder = object::id(&cap); + let _ = vid; + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + let cid = cid_holder; + + // Owner runs the full lifecycle of param changes, all keyed by cid. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + let exp = u::start_ms() + 1_000_000; + + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); // create + spend_vault::set_allowance(&mut v, &oc, cid, 900, MAXU64, option::none(), &clk, s.ctx()); // raise + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::none(), &clk, s.ctx()); // lower + spend_vault::set_allowance(&mut v, &oc, cid, 0, MAXU64, option::none(), &clk, s.ctx()); // suspend + spend_vault::set_allowance(&mut v, &oc, cid, 350, exp, option::none(), &clk, s.ctx()); // revive + renew expiry + + assert_eq!(spend_vault::allowance(&v, cid), 350); + assert_eq!(spend_vault::expiry(&v, cid), exp); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + + // The cap held by SPENDER is unchanged: its id still equals cid, and it spends + // under the final params (350). + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + // cap object id is bit-stable across every owner update. + assert_eq!(object::id(&cap), cid); + + let b = spend_vault::spend(&mut v, &cap, 350, &clk, s.ctx()); + assert_eq!(b.value(), 350); + assert_eq!(spend_vault::allowance(&v, cid), 0); + destroy(b); + + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === granted_coin_types completeness / un-griefability === + +#[test] +// set_allowance-create then -create => granted_coin_types is +// {USDC, SUIT}; re-set (update) does NOT duplicate; a deposit does NOT +// add FOO (permissionless funding writes no on-chain type set). +fun granted_coin_types_sole_writer_and_ungriefable() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + + let gct = spend_vault::granted_coin_types(&v); + assert_eq!(gct.length(), 2); + assert!(gct.contains(&type_name::with_defining_ids())); + assert!(gct.contains(&type_name::with_defining_ids())); + + // re-set USDC (update, not create) does not duplicate the type. + spend_vault::set_allowance(&mut v, &oc, cid, 999, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 2); + + // a permissionless deposit writes NO on-chain type set. + spend_vault::deposit(&v, coin::mint_for_testing(50, s.ctx()), s.ctx()); + let gct2 = spend_vault::granted_coin_types(&v); + assert_eq!(gct2.length(), 2); + assert!(!gct2.contains(&type_name::with_defining_ids())); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// A phantom cap_id (never minted) is accepted by set_allowance: it creates a fresh +// entry (AllowanceSet.was_created == true) and adds T to the grows-only +// granted_coin_types. After revoke removes the entry, T STILL appears in +// granted_coin_types (the set is never pruned). +#[test] +fun phantom_cap_id_creates_entry_and_grows_granted_types_permanently() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + // A cap_id that was never minted: derived from an arbitrary address. + let phantom = object::id_from_address(@0xDEAD); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, phantom, 500, MAXU64, option::none(), &clk, s.ctx()); + + // The phantom cap_id created a fresh entry: was_created == true. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_allowance_set( + vid, phantom, type_name::with_defining_ids(), 500, MAXU64, false, true, OWNER, + ), + ); + // USDC is now in granted_coin_types. + let gct = spend_vault::granted_coin_types(&v); + assert_eq!(gct.length(), 1); + assert!(gct.contains(&type_name::with_defining_ids())); + + // Revoke the phantom entry: it is removed, but USDC stays in granted_coin_types. + let was_present = spend_vault::revoke(&mut v, &oc, phantom, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, phantom)); + // grows-only: USDC is never pruned from granted_coin_types. + let gct2 = spend_vault::granted_coin_types(&v); + assert_eq!(gct2.length(), 1); + assert!(gct2.contains(&type_name::with_defining_ids())); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === per-(cap,coin) no-reset independence === + +#[test] +// set USDC, SUIT, DEEP on one cap; updating USDC's remaining leaves SUIT and DEEP +// entries bit-identical (no iteration, no sibling access). +fun set_allowance_per_coin_no_reset() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + let suit_exp = u::start_ms() + 7_000; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, suit_exp, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 700, MAXU64, option::none(), &clk, s.ctx()); + + // update only USDC. + spend_vault::set_allowance(&mut v, &oc, cid, 50, MAXU64, option::none(), &clk, s.ctx()); + + assert_eq!(spend_vault::allowance(&v, cid), 50); + // SUIT bit-identical (remaining + expiry). + assert_eq!(spend_vault::allowance(&v, cid), 300); + assert_eq!(spend_vault::expiry(&v, cid), suit_exp); + // DEEP bit-identical. + assert_eq!(spend_vault::allowance(&v, cid), 700); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// cross-cap: two caps each set for USDC are independent entries; updating cap-X's +// USDC never alters cap-Y's USDC (distinct cap_ids = distinct keys). +fun set_allowance_cross_cap_independent() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + + let cid_x: ID; + let cid_y: ID; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + let cap_x = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cap_y = spend_vault::mint_cap(&v, &oc, s.ctx()); + cid_x = object::id(&cap_x); + cid_y = object::id(&cap_y); + let _ = vid; + + spend_vault::set_allowance(&mut v, &oc, cid_x, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid_y, 300, MAXU64, option::none(), &clk, s.ctx()); + + // update cap-X only. + spend_vault::set_allowance(&mut v, &oc, cid_x, 50, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid_x), 50); + // cap-Y untouched. + assert_eq!(spend_vault::allowance(&v, cid_y), 300); + + transfer::public_transfer(cap_x, SPENDER); + transfer::public_transfer(cap_y, SPENDER); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Additional coverage === + +#[test] +// the SpenderCapMinted.by is ctx.sender(); minting via a NON-owner-address sender +// that holds the OwnerCap still attributes `by` to that sender (the cap, not +// identity, is the gate). Here OWNER holds the cap but a second tx sender mints. +fun mint_cap_by_is_sender_not_owner_identity() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + // Move the OwnerCap to SPENDER, then SPENDER mints: `by` must be SPENDER. + s.next_tx(OWNER); + { + let oc = u::take_owner_cap(&s, OWNER); + transfer::public_transfer(oc, SPENDER); + }; + s.next_tx(SPENDER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, SPENDER); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_spender_cap_minted(vid, cid, SPENDER)); + transfer::public_transfer(cap, SPENDER); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +// suspension via amount 0 on a PRESENT entry overwrites in place; the AllowanceSet +// carries new_amount==0 and was_created==false (not a create). +fun set_allowance_suspend_event_flags() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 0, MAXU64, option::none(), &clk, s.ctx()); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 2); + assert_eq!( + evs[1], + spend_vault::test_new_allowance_set( + vid, cid, type_name::with_defining_ids(), 0, MAXU64, false, false, OWNER, + ), + ); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// a finite-value CAS match (Some(200) on a 200 entry) overwrites; the new entry +// reflects the new amount AND a new finite future expiry in one call. +fun set_allowance_cas_match_with_new_expiry() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + let exp = u::start_ms() + 9_000; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 150, exp, option::some(200), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 150); + assert_eq!(spend_vault::expiry(&v, cid), exp); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// granted_coin_types is grows-only and stable: after revoke removes a (cap, T) +// entry it still lists T (the set is not pruned). Verified indirectly here by +// re-setting the same T (update) leaving length unchanged across the lifecycle. +fun granted_coin_types_grows_only_no_dup_across_lifecycle() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 1); + // suspend (update), revive (update), raise (update): no new type entry. + spend_vault::set_allowance(&mut v, &oc, cid, 0, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 900, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 1); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// minting two caps and setting an allowance on only ONE leaves the other cap +// entry-less (mint creates no entry; only the targeted set_allowance does). +fun set_allowance_targets_only_named_cap() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + + let cid_a: ID; + let cid_b: ID; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + + let cap_a = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cap_b = spend_vault::mint_cap(&v, &oc, s.ctx()); + cid_a = object::id(&cap_a); + cid_b = object::id(&cap_b); + let _ = vid; + + // grant only cap A. + spend_vault::set_allowance(&mut v, &oc, cid_a, 500, MAXU64, option::none(), &clk, s.ctx()); + assert!(spend_vault::contains(&v, cid_a)); + // cap B has NO entry (mint created none, no set targeted it). + assert!(!spend_vault::contains(&v, cid_b)); + assert_eq!(spend_vault::allowance(&v, cid_b), 0); + + transfer::public_transfer(cap_a, SPENDER); + transfer::public_transfer(cap_b, SPENDER); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +// revoke on one coin of a multi-coin cap leaves SUIT spendable (per-coin +// independence on the owner verb pair). granted_coin_types stays grows-only. +fun revoke_one_coin_leaves_other_grant_intact() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + let cid = mint_one_cap(&mut s, vid); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let clk = u::take_clock(&s); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + // USDC gone, SUIT intact. + assert!(!spend_vault::contains(&v, cid)); + assert!(spend_vault::contains(&v, cid)); + assert_eq!(spend_vault::allowance(&v, cid), 300); + // granted_coin_types is grows-only: USDC still listed though its entry is gone. + let gct = spend_vault::granted_coin_types(&v); + assert_eq!(gct.length(), 2); + assert!(gct.contains(&type_name::with_defining_ids())); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === CAS under interleaving / read-derived updates === + +// Given a spend was sequenced between the owner's read and write, when the CAS +// expects the stale pre-spend value, it aborts EUnexpectedAllowance. This drives +// the value stale via a REAL interleaved spend (the race CAS defends), unlike the +// literal-seeded set_allowance_cas_mismatch_aborts. +#[test, expected_failure(abort_code = spend_vault::EUnexpectedAllowance)] +fun set_allowance_cas_stale_after_spend_aborts() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 400, MAXU64); + // A spend lands first: remaining 400 -> 350. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 50, &clk, s.ctx()); + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + // Owner CAS-updates against the STALE pre-spend value 400 -> aborts. + s.next_tx(OWNER); + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::some(400), &clk, s.ctx()); + abort +} + +// Given the owner reads allowance then CAS-updates with that exact value in ONE tx, +// it succeeds (read-decide-write is atomic on the locked shared Vault: the +// documented race-free idiom). +#[test] +fun set_allowance_read_then_cas_atomic_succeeds() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 400, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let oc = u::take_owner_cap(&s, OWNER); + let current = spend_vault::allowance(&v, cid); // read + // CAS on the value just read, same tx: proceeds iff nothing raced in between. + spend_vault::set_allowance(&mut v, &oc, cid, 250, MAXU64, option::some(current), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 250); + ts::return_to_sender(&s, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// Given interleaved deposit + partial withdraw + revoke on a DIFFERENT cap, it +// leaves the target (capA, USDC) entry bit-identical (only spend lowers it, only +// set_allowance raises it). +#[test] +fun set_allowance_target_entry_stable_under_interleaving() { + let mut s = ts::begin(OWNER); + let cida; + let cidb; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let capa = spend_vault::mint_cap(&v, &oc, s.ctx()); + cida = object::id(&capa); + let capb = spend_vault::mint_cap(&v, &oc, s.ctx()); + cidb = object::id(&capb); + spend_vault::set_allowance(&mut v, &oc, cida, 400, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cidb, 700, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(capa, SPENDER); + transfer::public_transfer(capb, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + // Operations that must NOT touch (capA, USDC): + spend_vault::deposit(&v, coin::mint_for_testing(5_000, s.ctx()), s.ctx()); + let b = spend_vault::withdraw(&mut v, &oc, 1_000, s.ctx()); + destroy(b); + let _ = spend_vault::revoke(&mut v, &oc, cidb, s.ctx()); // a DIFFERENT cap + // (capA, USDC) is bit-identical. + assert_eq!(spend_vault::allowance(&v, cida), 400); + assert_eq!(spend_vault::expiry(&v, cida), MAXU64); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === Helpers === + +/// Mint one cap, transfer it to SPENDER, return its cap_id. Runs in a fresh OWNER +/// tx after a vault already exists (e.g. via new_funded_vault). +fun mint_one_cap(s: &mut ts::Scenario, _vid: ID): ID { + s.next_tx(OWNER); + let v = u::take_vault(s); + let oc = u::take_owner_cap(s, OWNER); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + transfer::public_transfer(cap, SPENDER); + ts::return_to_sender(s, oc); + u::return_vault(v); + cid +} diff --git a/contracts/allowance/tests/spend_vault/composability_tests.move b/contracts/allowance/tests/spend_vault/composability_tests.move new file mode 100644 index 0000000..0e4ab96 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/composability_tests.move @@ -0,0 +1,287 @@ +// Composability and ledger-shape coverage: atomic single-PTB lifecycle shapes, +// Balance round-trips both ways, never-sole-caller PTB batching, single-tx +// deposit-then-spend, BudgetKey composite-key independence, and the two-caps-sum +// footgun at the cap level. +// +// Pool-balance effects need a live AccumulatorRoot, which the unit-test VM cannot +// construct; pool conservation and the same-PTB under-drain edge are covered by +// integration tests. +module openzeppelin_allowance::spend_vault_composability_tests; + +use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT, DEEP}; +use std::unit_test::{assert_eq, destroy}; +use sui::balance; +use sui::coin; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === The full create+fund+grant+share+handoff composes in one PTB === + +#[test] +fun atomic_lifecycle_multi_coin_one_ptb() { + let mut s = ts::begin(OWNER); + let (vid, cid) = { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + // fund two coins, mint one cap, grant two budgets, share, hand off -- all one tx + spend_vault::deposit(&v, coin::mint_for_testing(1_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(2_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 700, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + transfer::public_transfer(cap, SPENDER); + clk.share_for_testing(); + (vid, cid) + }; + // both grants live after the one-PTB setup + s.next_tx(SPENDER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), 500); + assert_eq!(spend_vault::allowance(&v, cid), 700); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 2); + let _ = vid; + u::return_vault(v); + }; + s.end(); +} + +// === Deposit-then-spend in the SAME tx === + +#[test] +fun deposit_then_spend_same_tx() { + // Grant 1_000 budget but only 100 pool; in one tx deposit 400 more then spend + // 500. The redeem path reads the live accumulator, so the same-tx credit is + // spendable. The unit VM does not gate the pool, so this asserts the ordering + // composes, returning exactly amount. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 100, 1_000, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + spend_vault::deposit(&v, coin::mint_for_testing(400, s.ctx()), s.ctx()); + let bal = spend_vault::spend(&mut v, &cap, 500, &clk, s.ctx()); + assert_eq!(bal.value(), 500); + assert_eq!(spend_vault::allowance(&v, cid), 500); + destroy(bal); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Balance round-trips both ways === + +#[test] +fun spend_output_redeposited_same_vault() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let bal = spend_vault::spend(&mut v, &cap, 200, &clk, s.ctx()); + spend_vault::deposit_balance(&v, bal, s.ctx()); // egress folds back in + assert_eq!(spend_vault::allowance(&v, cid), 300); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun withdraw_output_routed_into_second_vault() { + // OUT of vault A (withdraw -> Balance) and straight IN to vault B + // (deposit_balance), zero glue. Both ledger-side; pool conservation is E2E. + let mut s = ts::begin(OWNER); + let _a = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + let b_id = { + let (vb, ocb) = spend_vault::new(s.ctx()); + let bid = object::id(&vb); + spend_vault::share(vb); + transfer::public_transfer(ocb, @0xC); // B's owner cap elsewhere so OWNER holds only A's + bid + }; + s.next_tx(OWNER); + { + // vault A is the first shared Vault; take both shared vaults distinctly + let mut va = ts::take_shared_by_id(&s, _a); + let vb = ts::take_shared_by_id(&s, b_id); + let oca = u::take_owner_cap(&s, OWNER); + let bal = spend_vault::withdraw(&mut va, &oca, 300, s.ctx()); + spend_vault::deposit_balance(&vb, bal, s.ctx()); + ts::return_to_address(OWNER, oca); + ts::return_shared(va); + ts::return_shared(vb); + }; + s.end(); +} + +#[test] +fun deposit_balance_ingress_from_raw_balance() { + // Ingress: a Balance the integrator controls folds in via deposit_balance. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let b = balance::create_for_testing(750); + spend_vault::deposit_balance(&v, b, s.ctx()); + u::return_vault(v); + }; + s.end(); +} + +// === BudgetKey composite key gives N independent entries === + +#[test] +fun one_cap_three_coins_three_independent_entries() { + // Allowance is per-(cap,coin) and reads return scalars; the BudgetKey + // {cap_id, coin_type} keys distinct entries: one cap holds three independent + // Allowance values; touching one leaves the others bit-identical. + let mut s = ts::begin(OWNER); + let cid = { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 100, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + transfer::public_transfer(cap, SPENDER); + clk.share_for_testing(); + cid + }; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let oc = u::take_owner_cap(&s, OWNER); + // three distinct keys -> three distinct values + assert_eq!(spend_vault::allowance(&v, cid), 100); + assert_eq!(spend_vault::allowance(&v, cid), 200); + assert_eq!(spend_vault::allowance(&v, cid), 300); + // mutate only SUIT + spend_vault::set_allowance(&mut v, &oc, cid, 999, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 100); // untouched + assert_eq!(spend_vault::allowance(&v, cid), 999); // changed + assert_eq!(spend_vault::allowance(&v, cid), 300); // untouched + ts::return_to_address(OWNER, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Two caps = two summing budgets (footgun lives at cap level) === + +#[test] +fun two_caps_same_coin_independent_summing_budgets() { + let mut s = ts::begin(OWNER); + let (cid1, cid2) = { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cap2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid1 = object::id(&cap1); + let cid2 = object::id(&cap2); + // two independent USDC budgets on one vault; they SUM from the pool's view + spend_vault::set_allowance(&mut v, &oc, cid1, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid2, 500, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap1, SPENDER); + transfer::public_transfer(cap2, @0xC); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (cid1, cid2) + }; + // distinct cap_ids => distinct entries; drawing on one never touches the other + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 400, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid1), 100); + assert_eq!(spend_vault::allowance(&v, cid2), 500); // cap2 untouched + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Never-sole-caller: batched same-function calls in one tx === + +#[test] +fun batched_mint_caps_yield_distinct_ids() { + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let c1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c3 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let id1 = object::id(&c1); + let id2 = object::id(&c2); + let id3 = object::id(&c3); + assert!(id1 != id2 && id2 != id3 && id1 != id3); + spend_vault::delete_orphaned_cap(c1); + spend_vault::delete_orphaned_cap(c2); + spend_vault::delete_orphaned_cap(c3); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun batched_deposits_and_grants_one_tx() { + let mut s = ts::begin(OWNER); + let cid = { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + // N deposits (mixed coins) + N grants across coins, one tx + spend_vault::deposit(&v, coin::mint_for_testing(100, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(100, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(100, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 10, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 20, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 30, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + transfer::public_transfer(cap, SPENDER); + clk.share_for_testing(); + cid + }; + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 3); + assert_eq!(spend_vault::allowance(&v, cid), 30); + u::return_vault(v); + }; + s.end(); +} diff --git a/contracts/allowance/tests/spend_vault/fund_tests.move b/contracts/allowance/tests/spend_vault/fund_tests.move new file mode 100644 index 0000000..a20a825 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/fund_tests.move @@ -0,0 +1,294 @@ +// Funding surface: deposit, deposit_balance, squash. +// +// These tests cover the permissionless funding paths: deposit and deposit_balance +// emit one Deposited per call with no rights conferred and no granted_coin_types +// written; squash recovers a stray coin emitting one Squashed (distinct from +// Deposited) and is funds-in-only; batched deposits in one tx each succeed. +// +// Pool-balance effects need a live accumulator pool, which the unit-test VM cannot +// construct (balance_value needs an &AccumulatorRoot that is unavailable here), so +// these tests assert EVENTS and LEDGER non-effects (granted_coin_types stays empty, +// contains stays false), NOT pool balances; those are covered by integration tests. +module openzeppelin_allowance::spend_vault_fund_tests; + +use openzeppelin_allowance::spend_vault::{Self, Vault}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, FOO}; +use std::type_name; +use std::unit_test::assert_eq; +use sui::balance; +use sui::coin::{Self, Coin}; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const STRANGER: address = @0xBAD; +const THIRD: address = @0xCAFE; + +// A throwaway cap_id (any ID) for contains/allowance probes: deposit/squash never +// create a (cap, T) entry, so contains against ANY cap_id must stay false. +fun some_id(s: &mut ts::Scenario): ID { + let uid = object::new(s.ctx()); + let id = uid.to_inner(); + uid.delete(); + id +} + +// === deposit (permissionless, confers no rights) === + +#[test] +fun deposit_emits_deposited_event() { + // deposit(amt>0) -> exactly one Deposited{vault,coin,amt,sender}. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); // empty vault, no grant + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + spend_vault::deposit(&v, coin::mint_for_testing(750, s.ctx()), s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 750, OWNER), + ); + u::return_vault(v); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EZeroAmount)] +fun deposit_zero_amount_aborts() { + // Zero is meaningless on deposit. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(OWNER); + let v = u::take_vault(&s); + spend_vault::deposit(&v, coin::mint_for_testing(0, s.ctx()), s.ctx()); + abort +} + +#[test] +fun deposit_by_non_owner_succeeds_confers_no_rights() { + // A non-owner deposits -> succeeds, Deposited emitted, but no entry is + // created: granted_coin_types stays empty and contains is false for any cap_id. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(STRANGER); + { + let v = u::take_vault(&s); + let probe = some_id(&mut s); + spend_vault::deposit(&v, coin::mint_for_testing(500, s.ctx()), s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 500, STRANGER), + ); + // Confers NO rights: no granted type, no (cap, USDC) entry. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + assert!(!spend_vault::contains(&v, probe)); + assert_eq!(spend_vault::allowance(&v, probe), 0); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun deposit_does_not_write_granted_coin_types() { + // Depositing FOO must not inflate the owner-only granted set. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + spend_vault::deposit(&v, coin::mint_for_testing(123, s.ctx()), s.ctx()); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); // un-griefable + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun deposit_batched_in_one_tx_succeeds() { + // The module never assumes it is the sole PTB step; several deposits in + // one tx all succeed and each emits its own Deposited. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + spend_vault::deposit(&v, coin::mint_for_testing(100, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(200, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(300, s.ctx()), s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + // Still no ledger effect from any of them. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + u::return_vault(v); + }; + s.end(); +} + +// === deposit_balance (the Balance ingress) === + +#[test] +fun deposit_balance_emits_deposited_event() { + // deposit_balance(amt>0) -> one Deposited (same schema as deposit). + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let b = balance::create_for_testing(640); + spend_vault::deposit_balance(&v, b, s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 640, OWNER), + ); + u::return_vault(v); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EZeroAmount)] +fun deposit_balance_zero_amount_aborts() { + // Zero is meaningless on deposit_balance. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(OWNER); + let v = u::take_vault(&s); + let b = balance::create_for_testing(0); + spend_vault::deposit_balance(&v, b, s.ctx()); + abort +} + +#[test] +fun deposit_balance_by_non_owner_writes_no_granted_types() { + // deposit_balance is permissionless and writes no granted set. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + s.next_tx(STRANGER); + { + let v = u::take_vault(&s); + let probe = some_id(&mut s); + let b = balance::create_for_testing(900); + spend_vault::deposit_balance(&v, b, s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 900, STRANGER), + ); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + assert!(!spend_vault::contains(&v, probe)); + u::return_vault(v); + }; + s.end(); +} + +// === squash (permissionless, funds-in-only; Squashed distinct from Deposited) === + +#[test] +fun squash_recovers_stray_coin_emits_squashed() { + // A stray Coin sent to the vault address is recovered by + // squash -> one Squashed{vault,coin,amt,sender}, and it is DISTINCT from Deposited + // (no Deposited is emitted on the squash call). + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + + // Tx 1: public_transfer a loose Coin to the vault's object address. + s.next_tx(OWNER); + let coin_id = { + let c = coin::mint_for_testing(450, s.ctx()); + let cid = object::id(&c); + transfer::public_transfer(c, vid.to_address()); + cid + }; + + // Tx 2: squash it back into the pool via a receiving ticket. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let ticket = ts::receiving_ticket_by_id>(coin_id); + spend_vault::squash(&mut v, ticket, s.ctx()); + + let sq = event::events_by_type(); + assert_eq!(sq.length(), 1); + assert_eq!( + sq[0], + spend_vault::test_new_squashed(vid, type_name::with_defining_ids(), 450, OWNER), + ); + // DISTINCT event types: squash emits Squashed, not Deposited. + let dep = event::events_by_type(); + assert_eq!(dep.length(), 0); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun squash_by_third_party_succeeds() { + // squash is permissionless (funds-in-only); a party who is neither owner + // nor depositor can recover the stray. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + + s.next_tx(STRANGER); + let coin_id = { + let c = coin::mint_for_testing(77, s.ctx()); + let cid = object::id(&c); + transfer::public_transfer(c, vid.to_address()); + cid + }; + + s.next_tx(THIRD); + { + let mut v = u::take_vault(&s); + let ticket = ts::receiving_ticket_by_id>(coin_id); + spend_vault::squash(&mut v, ticket, s.ctx()); + + let sq = event::events_by_type(); + assert_eq!(sq.length(), 1); + assert_eq!( + sq[0], + spend_vault::test_new_squashed(vid, type_name::with_defining_ids(), 77, THIRD), + ); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun squash_writes_no_granted_coin_types() { + // Squashing FOO must not inflate the owner-only granted set. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + + s.next_tx(OWNER); + let coin_id = { + let c = coin::mint_for_testing(321, s.ctx()); + let cid = object::id(&c); + transfer::public_transfer(c, vid.to_address()); + cid + }; + + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let probe = some_id(&mut s); + let ticket = ts::receiving_ticket_by_id>(coin_id); + spend_vault::squash(&mut v, ticket, s.ctx()); + + // Funds-in-only, writes no type set, creates no entry. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + assert!(!spend_vault::contains(&v, probe)); + u::return_vault(v); + }; + s.end(); +} diff --git a/contracts/allowance/tests/spend_vault/isolation_tests.move b/contracts/allowance/tests/spend_vault/isolation_tests.move new file mode 100644 index 0000000..1ab0169 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/isolation_tests.move @@ -0,0 +1,524 @@ +// Isolation triad + batching/sequencing (no AccumulatorRoot functions). +// +// Covers vault/cap/coin-type isolation, per-(cap,coin) independence, multi-call +// PTB batching of each verb, and the two sequential spend/revoke orderings. +// +// Pool-balance effects need a live AccumulatorRoot, which the unit-test VM cannot +// construct; those (the native pool-short abort and every &AccumulatorRoot-taking +// read) are covered by integration tests. +module openzeppelin_allowance::spend_vault_isolation_tests; + +use openzeppelin_allowance::spend_vault::{Self, SpenderCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT, DEEP}; +use std::type_name; +use std::unit_test::{assert_eq, destroy}; +use sui::coin; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === CROSS-VAULT === + +#[test, expected_failure(abort_code = spend_vault::EWrongVault)] +fun spend_with_foreign_vault_cap_aborts_wrong_vault() { + // A SpenderCap bound to vault B presented to spend on vault A -> code 1. + let mut s = ts::begin(OWNER); + // Vault A: the target of the spend. + let (_vida, _cida) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + let mut va = u::take_vault(&s); + let clk = u::take_clock(&s); + // Vault B: a brand-new, granted vault; mint a cap bound to B. + let (mut vb, ocb) = spend_vault::new(s.ctx()); + let capb = spend_vault::mint_cap(&vb, &ocb, s.ctx()); + let cidb = object::id(&capb); + spend_vault::set_allowance(&mut vb, &ocb, cidb, 500, MAXU64, option::none(), &clk, s.ctx()); + // B's cap on A -> EWrongVault (code 1), before any ledger access. + let b = spend_vault::spend(&mut va, &capb, 100, &clk, s.ctx()); + destroy(b); + abort +} + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +fun revoke_with_foreign_owner_cap_aborts_wrong_owner_cap() { + // An OwnerCap bound to vault B presented to revoke on vault A -> code 0. + let mut s = ts::begin(OWNER); + let (_vida, cida) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + let mut va = u::take_vault(&s); + // Vault B with its own OwnerCap. + let (_vb, ocb) = spend_vault::new(s.ctx()); + // B's owner cap used to revoke A's grant -> EWrongOwnerCap (code 0). + let _ = spend_vault::revoke(&mut va, &ocb, cida, s.ctx()); + abort +} + +#[test] +fun op_on_vault_a_leaves_vault_b_ledger_untouched() { + // A spend on A leaves B's allowance bit-identical (a DIFFERENT object). + let mut s = ts::begin(OWNER); + // Vault A and Vault B both funded + granted USDC=500 to their own caps. + let (vida, cida) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 500, MAXU64); + let (vidb, cidb) = make_second_vault(&mut s); + // Operate on A: spend 200 via cap A. B (a separate shared object) must be untouched. + s.next_tx(SPENDER); + { + let mut va = ts::take_shared_by_id(&s, vida); + let vb = ts::take_shared_by_id(&s, vidb); + let clk = u::take_clock(&s); + let capa = ts::take_from_address_by_id(&s, SPENDER, cida); + let b = spend_vault::spend(&mut va, &capa, 200, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&va, cida), 300); // A drawn + // B's ledger is read off a DIFFERENT vault object: cannot have shifted. + assert_eq!(spend_vault::allowance(&vb, cidb), 500); + assert!(spend_vault::contains(&vb, cidb)); + destroy(b); + ts::return_to_address(SPENDER, capa); + u::return_vault(va); + u::return_vault(vb); + u::return_clock(clk); + }; + s.end(); +} + +// === CROSS-CAP === + +#[test] +fun revoke_cap_x_leaves_cap_y_live_and_spendable() { + // One vault, two caps both granted USDC=500; revoke X -> Y still live. + let mut s = ts::begin(OWNER); + let (_vid, cidx, cidy) = two_cap_vault(&mut s); + // Owner revokes cap X's USDC. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was = spend_vault::revoke(&mut v, &oc, cidx, s.ctx()); + assert!(was); // X was present, now removed + assert!(!spend_vault::contains(&v, cidx)); // X gone + assert!(spend_vault::contains(&v, cidy)); // Y untouched + assert_eq!(spend_vault::allowance(&v, cidy), 500); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + // Cap Y can still spend its full 500. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let capy = ts::take_from_address_by_id(&s, SPENDER, cidy); + let b = spend_vault::spend(&mut v, &capy, 500, &clk, s.ctx()); + assert_eq!(b.value(), 500); + assert_eq!(spend_vault::allowance(&v, cidy), 0); + destroy(b); + ts::return_to_address(SPENDER, capy); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun spends_on_two_caps_draw_independently() { + // Draw 200 on cap X -> X 300, cap Y still 500 (independent accounting). + let mut s = ts::begin(OWNER); + let (_vid, cidx, cidy) = two_cap_vault(&mut s); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + // Both caps live at the same address; take each by its known id. + let capx = ts::take_from_address_by_id(&s, SPENDER, cidx); + let capy = ts::take_from_address_by_id(&s, SPENDER, cidy); + // Spend on X only. + let b = spend_vault::spend(&mut v, &capx, 200, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cidx), 300); // X drawn + assert_eq!(spend_vault::allowance(&v, cidy), 500); // Y untouched + destroy(b); + ts::return_to_address(SPENDER, capx); + ts::return_to_address(SPENDER, capy); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun set_allowance_on_cap_x_leaves_cap_y_identical() { + // Owner-side change to cap X never alters cap Y's entry. + let mut s = ts::begin(OWNER); + let (_vid, cidx, cidy) = two_cap_vault(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::set_allowance(&mut v, &oc, cidx, 999, MAXU64, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cidx), 999); // X changed + assert_eq!(spend_vault::allowance(&v, cidy), 500); // Y bit-identical + assert_eq!(spend_vault::expiry(&v, cidy), MAXU64); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === CROSS-TYPE runtime gate === + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun usdc_cap_spend_suit_aborts_no_allowance() { + // A USDC-only cap, spend -> code 2 (the runtime coin-type gate). + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_address(&s, SPENDER); + let b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // code 2 + destroy(b); + abort +} + +#[test] +fun cross_type_other_coin_spends_after_grant() { + // Granting the second coin makes spend succeed; the gate is purely the + // (cap, coin) entry presence, not the cap object. + let mut s = ts::begin(OWNER); + let (vid, cid) = build_usdc_suit_cap(&mut s); + let _ = vid; + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_address(&s, SPENDER); + let b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); + assert_eq!(b.value(), 100); + assert_eq!(spend_vault::allowance(&v, cid), 200); + destroy(b); + ts::return_to_address(SPENDER, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === PER-(cap,coin) NO-RESET === + +#[test] +fun revoke_usdc_leaves_suit_live() { + // Set USDC+SUIT on one cap, revoke -> SUIT still live + identical. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_usdc_suit_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was); + assert!(!spend_vault::contains(&v, cid)); // USDC removed + assert!(spend_vault::contains(&v, cid)); // SUIT survives + assert_eq!(spend_vault::allowance(&v, cid), 300); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun update_usdc_budget_leaves_suit_bit_identical() { + // Re-setting (cap, USDC) never touches (cap, SUIT)'s two scalars. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_usdc_suit_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let oc = u::take_owner_cap(&s, OWNER); + let exp = u::start_ms() + 5_000; + spend_vault::set_allowance(&mut v, &oc, cid, 42, exp, option::none(), &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 42); + assert_eq!(spend_vault::expiry(&v, cid), exp); + // SUIT entry: both scalars unchanged. + assert_eq!(spend_vault::allowance(&v, cid), 300); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === BATCHING: N>=3 of one verb in ONE tx === + +#[test] +fun batch_three_deposits_mixed_coins_one_tx() { + // N=3 deposits (mixed coins) in one tx, no per-tx caching assumption. + let mut s = ts::begin(OWNER); + s.next_tx(OWNER); + { + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(100, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(200, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(300, s.ctx()), s.ctx()); + // Three distinct-coin Deposited events emitted in this tx. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + assert!(evs.contains(&spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 100, OWNER))); + assert!(evs.contains(&spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 200, OWNER))); + assert!(evs.contains(&spend_vault::test_new_deposited(vid, type_name::with_defining_ids(), 300, OWNER))); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + }; + s.end(); +} + +#[test] +fun batch_three_mint_caps_yields_distinct_ids_one_tx() { + // N=3 mint_cap in one tx -> 3 DISTINCT cap_ids (no first-call caching). + let mut s = ts::begin(OWNER); + s.next_tx(OWNER); + { + let (v, oc) = spend_vault::new(s.ctx()); + let c1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c3 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let id1 = object::id(&c1); + let id2 = object::id(&c2); + let id3 = object::id(&c3); + assert!(id1 != id2); + assert!(id2 != id3); + assert!(id1 != id3); + // Three SpenderCapMinted events in the one tx. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + spend_vault::delete_orphaned_cap(c1); + spend_vault::delete_orphaned_cap(c2); + spend_vault::delete_orphaned_cap(c3); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + }; + s.end(); +} + +#[test] +fun batch_three_set_allowance_across_coins_one_tx() { + // N=3 set_allowance across 3 coins on one cap in one tx, all create. + let mut s = ts::begin(OWNER); + s.next_tx(OWNER); + { + let (mut v, oc) = spend_vault::new(s.ctx()); + let clk = u::clock_at(u::start_ms(), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 100, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + // All three independent budgets land on the one cap. + assert_eq!(spend_vault::allowance(&v, cid), 100); + assert_eq!(spend_vault::allowance(&v, cid), 200); + assert_eq!(spend_vault::allowance(&v, cid), 300); + // granted_coin_types records all three. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 3); + // Three AllowanceSet events in the one tx. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + spend_vault::delete_orphaned_cap(cap); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.end(); +} + +#[test] +fun batch_three_revokes_one_tx() { + // N=3 revoke across 3 coins on one cap in one tx, all succeed. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_three_coin_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let w1 = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + let w2 = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + let w3 = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(w1 && w2 && w3); // all three present, all removed + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + // Three Revoked events in the one tx. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun batch_mixed_sequence_deposit_spend_revoke_one_tx() { + // A mixed interleaving in ONE tx (deposit, then spend, then revoke a + // different coin) all compose: no one-call-per-tx assumption anywhere. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_usdc_suit_cap(&mut s); // USDC=500, SUIT=300, both funded + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_address(&s, SPENDER); + // deposit (permissionless), then spend USDC, all in one tx. + spend_vault::deposit(&v, coin::mint_for_testing(1_000, s.ctx()), s.ctx()); + let b = spend_vault::spend(&mut v, &cap, 200, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 300); + destroy(b); + ts::return_to_address(SPENDER, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === SEQUENCING: the two sequential orderings === + +#[test] +fun sequence_spend_then_revoke_both_succeed() { + // Spend sequenced BEFORE revoke -> spend succeeds (non-retroactive), then + // revoke removes the entry. The deterministic "spend wins" ordering. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 500, MAXU64); + // tx1: spender draws 200. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_address(&s, SPENDER); + let b = spend_vault::spend(&mut v, &cap, 200, &clk, s.ctx()); + assert_eq!(b.value(), 200); + assert_eq!(spend_vault::allowance(&v, cid), 300); + destroy(b); + ts::return_to_address(SPENDER, cap); + u::return_vault(v); + u::return_clock(clk); + }; + // tx2: owner revokes; the prior spend stands, the entry is now gone. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun sequence_revoke_then_spend_aborts_no_allowance() { + // Revoke sequenced BEFORE spend -> the entry is gone, spend aborts code 2. + // The opposite ordering of the same race; deterministic. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 500, MAXU64); + // tx1: owner revokes first. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + // tx2: the spender's spend now finds no entry -> ENoAllowance. + s.next_tx(SPENDER); + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_address(&s, SPENDER); + let b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // code 2 + destroy(b); + abort +} + +// === Helpers === + +/// One tx: a SECOND vault funded + granted USDC=500 to a fresh cap sent to SPENDER. +/// Returns (vault_id, cap_id). (The first vault came from setup_granted.) +fun make_second_vault(s: &mut ts::Scenario): (ID, ID) { + s.next_tx(OWNER); + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} + +/// One tx: a vault funded with USDC=10_000, two caps X and Y each granted USDC=500, +/// both sent to SPENDER. Returns (vid, cid_x, cid_y). +fun two_cap_vault(s: &mut ts::Scenario): (ID, ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let capx = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cidx = object::id(&capx); + let capy = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cidy = object::id(&capy); + spend_vault::set_allowance(&mut v, &oc, cidx, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cidy, 500, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(capx, SPENDER); + transfer::public_transfer(capy, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cidx, cidy) +} + +/// One tx: a vault funded USDC + SUIT, one cap granted USDC=500 and SUIT=300, sent to +/// SPENDER. Returns (vid, cid). +fun build_usdc_suit_cap(s: &mut ts::Scenario): (ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} + +/// One tx: a vault granting one cap USDC=100, SUIT=200, DEEP=300. Returns (vid, cid). +fun build_three_coin_cap(s: &mut ts::Scenario): (ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 100, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} diff --git a/contracts/allowance/tests/spend_vault/lifecycle_tests.move b/contracts/allowance/tests/spend_vault/lifecycle_tests.move new file mode 100644 index 0000000..2e87004 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/lifecycle_tests.move @@ -0,0 +1,482 @@ +// Vault lifecycle: new, share, destroy. Covers the ledger-side surface of the +// vault lifecycle: creating a vault with its sole vault-bound OwnerCap, the +// atomic single-PTB create+fund+mint+grant+share+handoff setup, sharing, and +// tearing down a vault (draining the ledger, deleting the UIDs, orphaning live +// caps, and the VaultCreated / VaultDestroyed events). +// +// A `new` that neither shares nor destroys the vault is a compile-time property: +// `Vault` has `key` and only `key`, so a no-drop value left unconsumed does not +// type-check and the file would not build. It is therefore not expressible as a +// runtime test and is asserted here only by the fact that every test consumes +// its vault via share or destroy. +// +// Pool-balance effects of destroy (destroy does NOT drain the address-balance +// pool; the owner must withdraw_all every coin first) need a live +// AccumulatorRoot, which the unit-test VM cannot construct; those are covered by +// integration tests. Only the ledger leg lives here. +module openzeppelin_allowance::spend_vault_lifecycle_tests; + +use openzeppelin_allowance::spend_vault; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT}; +use std::unit_test::assert_eq; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === new: vault + sole OwnerCap, binding, VaultCreated event === + +#[test] +// new returns one vault + one OwnerCap whose vault_id binds to the vault, and +// emits exactly one VaultCreated{vault_id, owner_cap_id, creator}. +fun new_returns_vault_and_bound_owner_cap() { + let mut s = ts::begin(OWNER); + { + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + let ocid = object::id(&oc); + + // The OwnerCap is bound to exactly this vault. + assert_eq!(spend_vault::owner_cap_vault_id(&oc), vid); + + // One VaultCreated, creator == sender (OWNER here). + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_created(vid, ocid, OWNER)); + + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + }; + s.end(); +} + +#[test] +// A freshly created vault has an empty ledger (no caps minted yet) and no +// granted coin types: new builds only the vault + its sole OwnerCap. +fun new_vault_has_empty_ledger() { + let mut s = ts::begin(OWNER); + { + let (v, oc) = spend_vault::new(s.ctx()); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + }; + s.end(); +} + +#[test] +// VaultCreated.creator is ctx.sender() and may differ from the eventual owner: +// create as a third party, hand the cap to OWNER. +fun new_creator_may_differ_from_owner() { + let creator: address = @0xC0FFEE; + let mut s = ts::begin(creator); + { + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + let ocid = object::id(&oc); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_created(vid, ocid, creator)); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); // owner != creator + }; + s.end(); +} + +#[test] +// Each new() is independent: two vaults get distinct ids and distinct, +// correctly-bound OwnerCaps (one cap per vault, no cross-binding). Two +// VaultCreated events in the tx, one per vault. +fun two_new_vaults_are_independent() { + let mut s = ts::begin(OWNER); + { + let (v1, oc1) = spend_vault::new(s.ctx()); + let (v2, oc2) = spend_vault::new(s.ctx()); + let vid1 = object::id(&v1); + let vid2 = object::id(&v2); + + assert!(vid1 != vid2); + // each cap binds to its own vault, never the other. + assert_eq!(spend_vault::owner_cap_vault_id(&oc1), vid1); + assert_eq!(spend_vault::owner_cap_vault_id(&oc2), vid2); + assert!(spend_vault::owner_cap_vault_id(&oc1) != vid2); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 2); + + spend_vault::share(v1); + spend_vault::share(v2); + transfer::public_transfer(oc1, OWNER); + transfer::public_transfer(oc2, OWNER); + }; + s.end(); +} + +// === atomic single-PTB create+fund+mint+grant+share+handoff === + +#[test] +// The full setup composes in one tx and the resulting vault is usable in the +// next tx (cap takeable, grant present, owner cap takeable). +fun full_one_ptb_setup_succeeds() { + let mut s = ts::begin(OWNER); + // setup_granted does new -> deposit -> mint_cap -> set_allowance -> share -> + // transfer(owner_cap) -> transfer(spender_cap) -> share(clock), all in tx 0. + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let v = u::take_vault(&s); + let cap = u::take_spender_cap(&s, SPENDER); + let oc = u::take_owner_cap(&s, OWNER); + // grant landed atomically with the share. + assert_eq!(spend_vault::allowance(&v, cid), 500); + assert!(spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, cap); + ts::return_to_address(OWNER, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +// Explicit inline version: new -> deposit USDC -> mint_cap -> set_allowance -> +// share -> transfer(owner_cap), all in ONE tx, then verify the shared vault in +// the next tx. Mirrors setup_granted but spelled out. +fun explicit_one_ptb_setup_succeeds() { + let mut s = ts::begin(OWNER); + let cid; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + spend_vault::deposit(&v, sui::coin::mint_for_testing(1_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + cid = object::id(&cap); + spend_vault::set_allowance( + &mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx(), + ); + spend_vault::share(v); + transfer::public_transfer(cap, SPENDER); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(SPENDER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), 500); + u::return_vault(v); + }; + s.end(); +} + +// === share emits nothing === + +#[test] +// share emits no event. new() emits one VaultCreated; the subsequent share adds +// nothing, so the only VaultCreated in the tx is new()'s. +fun share_emits_no_event() { + let mut s = ts::begin(OWNER); + { + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + let ocid = object::id(&oc); + spend_vault::share(v); // <- the action under test; must add no event + // Still exactly one VaultCreated (from new); share contributed none. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_created(vid, ocid, OWNER)); + // and zero VaultDestroyed: share is not a teardown. + assert_eq!(event::events_by_type().length(), 0); + transfer::public_transfer(oc, OWNER); + }; + s.end(); +} + +// === destroy: empty / never-shared, same-tx === + +#[test] +// A freshly created, NEVER-shared, empty vault is torn down in the same tx (new +// then destroy) and emits one VaultDestroyed{vault_id, by}. +fun destroy_fresh_empty_vault_same_tx() { + let mut s = ts::begin(OWNER); + { + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::destroy(v, oc, s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, OWNER)); + }; + s.end(); +} + +// === destroy: shared vault, canonical teardown === + +#[test] +// The canonical teardown: share in tx 0, then in a later tx take_shared + +// take the owner cap and destroy. VaultDestroyed emitted with by == the +// destroying sender. +fun destroy_shared_vault_in_later_tx() { + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); // empty, shared, oc -> OWNER + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + assert_eq!(object::id(&v), vid); + spend_vault::destroy(v, oc, s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, OWNER)); + }; + s.end(); +} + +// === destroy: drains a populated ledger === + +#[test] +// destroy fully pop_front-drains a non-empty ledger. Build a vault with 3 +// entries (cap1: USDC+SUIT, cap2: USDC) in tx 0, then destroy in a later tx: +// succeeds (the drain loop empties the table before destroy_empty), one +// VaultDestroyed emitted. +fun destroy_drains_three_ledger_entries() { + let mut s = ts::begin(OWNER); + let vid; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + vid = object::id(&v); + let cap1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cap2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid1 = object::id(&cap1); + let cid2 = object::id(&cap2); + // cap1 granted two coins, cap2 granted one: 3 ledger entries total. + spend_vault::set_allowance(&mut v, &oc, cid1, 100, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid1, 200, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid2, 300, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::share(v); + transfer::public_transfer(cap1, SPENDER); + transfer::public_transfer(cap2, SPENDER); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::destroy(v, oc, s.ctx()); // drains all 3 entries, then deletes UIDs + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, OWNER)); + }; + s.end(); +} + +#[test] +// destroy of a once-populated vault whose ledger was emptied first (here by +// revoke_all) still succeeds: the drain loop is a no-op and destroy_empty is the +// live backstop. VaultDestroyed emitted. +fun destroy_after_revoke_all_emptied_ledger() { + let mut s = ts::begin(OWNER); + let vid; + let cid; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + vid = object::id(&v); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 100, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 200, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::share(v); + transfer::public_transfer(cap, SPENDER); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + // Owner empties the ledger via revoke_all. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + u::return_vault(v); + ts::return_to_address(OWNER, oc); + }; + // Now destroy the emptied vault. + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::destroy(v, oc, s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, OWNER)); + }; + s.end(); +} + +// === destroy: foreign OwnerCap aborts (binding gate) === + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +// Destroying vault A with vault B's OwnerCap aborts EWrongOwnerCap (the binding +// gate is the first and only check). +fun destroy_with_foreign_owner_cap_aborts() { + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 0); // vault A, shared + s.next_tx(OWNER); + let va = u::take_vault(&s); + // a SECOND, unrelated vault B; its owner cap is foreign to A. + let (_vb, ocb) = spend_vault::new(s.ctx()); + spend_vault::destroy(va, ocb, s.ctx()); // EWrongOwnerCap: ocb binds to B, not A + abort +} + +// === destroy orphans live caps; orphan is disposable === + +#[test] +// destroy orphans every live cap; in a later tx the SpenderCap still exists and +// delete_orphaned_cap disposes it, emitting one CapDeleted. +fun destroy_orphans_cap_then_delete_orphaned() { + let mut s = ts::begin(OWNER); + let vid; + let cid; + { + let (v, oc) = spend_vault::new(s.ctx()); + vid = object::id(&v); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + cid = object::id(&cap); + spend_vault::share(v); + transfer::public_transfer(cap, SPENDER); + transfer::public_transfer(oc, OWNER); + }; + // Owner tears the vault down; the cap is now orphaned in SPENDER's wallet. + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::destroy(v, oc, s.ctx()); + }; + // The orphaned SpenderCap still exists and is disposable. + s.next_tx(SPENDER); + { + let cap = u::take_spender_cap(&s, SPENDER); + // sanity: the orphan still reports its original vault binding. + assert_eq!(spend_vault::spender_cap_vault_id(&cap), vid); + spend_vault::delete_orphaned_cap(cap); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_cap_deleted(vid, cid)); + }; + s.end(); +} + +#[test] +// destroy consumes the OwnerCap BY VALUE. After teardown, no OwnerCap remains at +// OWNER's address; a follow-up take would have nothing. We witness consumption +// indirectly: a destroyed vault leaves the address with no OwnerCap to re-take +// (we simply do not re-take it; the by-value signature of destroy guarantees it +// is gone). This test pins that the by-value teardown path runs clean for a +// shared, populated vault. +fun destroy_consumes_owner_cap_by_value() { + let mut s = ts::begin(OWNER); + let vid; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + vid = object::id(&v); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, c, 50, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::share(v); + transfer::public_transfer(cap, SPENDER); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); // the ONE owner cap + spend_vault::destroy(v, oc, s.ctx()); // <- consumes it by value + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, OWNER)); + }; + s.end(); +} + +#[test] +// The destroying sender attribution: `by` is ctx.sender() of the destroy call, +// which can be any holder of the OwnerCap (here a rotated owner). +fun destroy_by_is_sender() { + let new_owner: address = @0xDEAD; + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 0); + // OWNER rotates the cap to new_owner. + s.next_tx(OWNER); + { + let oc = u::take_owner_cap(&s, OWNER); + transfer::public_transfer(oc, new_owner); + }; + s.next_tx(new_owner); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, new_owner); + spend_vault::destroy(v, oc, s.ctx()); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, new_owner)); + }; + s.end(); +} + +// === destroy is unconditional w.r.t. ledger STATE === + +// Given a ledger holding suspended (remaining 0), expired, and unlimited entries +// across two caps, destroy tears the vault down regardless of entry state (one +// VaultDestroyed, no spender/ledger precondition): the owner exit is +// unconditional and ledger-independent, and the drain handles whatever exists. +#[test] +fun destroy_unconditional_over_adversarial_ledger() { + let mut s = ts::begin(OWNER); + let future = u::start_ms() + 1_000; + let vid; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + vid = object::id(&v); + let cap1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c1 = object::id(&cap1); + let cap2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let c2 = object::id(&cap2); + spend_vault::set_allowance(&mut v, &oc, c1, 0, MAXU64, option::none(), &clk, s.ctx()); // suspended + spend_vault::set_allowance(&mut v, &oc, c1, 100, future, option::none(), &clk, s.ctx()); // will expire + spend_vault::set_allowance(&mut v, &oc, c2, MAXU64, MAXU64, option::none(), &clk, s.ctx()); // unlimited + transfer::public_transfer(cap1, SPENDER); + transfer::public_transfer(cap2, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(OWNER); + { + let mut clk = u::take_clock(&s); + clk.set_for_testing(future + 1); // the SUIT entry is now expired + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::destroy(v, oc, s.ctx()); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_vault_destroyed(vid, OWNER)); + u::return_clock(clk); + }; + s.end(); +} diff --git a/contracts/allowance/tests/spend_vault/reads_tests.move b/contracts/allowance/tests/spend_vault/reads_tests.move new file mode 100644 index 0000000..532fc5f --- /dev/null +++ b/contracts/allowance/tests/spend_vault/reads_tests.move @@ -0,0 +1,369 @@ +// Unit coverage for the non-root vault reads: allowance, expiry, +// contains, granted_coin_types, owner_cap_vault_id, spender_cap_vault_id. +// These reads are total (every read returns a documented default and never +// aborts, in any vault state), and this file pins their definitional values +// and the absent-vs-suspended disambiguation. +// +// NOTE: balance_value and spendable_now take &AccumulatorRoot, which the +// unit-test VM cannot construct; those reads are covered by integration tests. +module openzeppelin_allowance::spend_vault_reads_tests; + +use openzeppelin_allowance::spend_vault::{Self, SpenderCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT, DEEP, FOO}; +use std::type_name; +use std::unit_test::assert_eq; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === allowance === + +#[test] +fun allowance_absent_is_zero() { + // A never-granted (cap, T) reads 0, no abort. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + // granted for USDC, but FOO was never granted on this cap. + assert_eq!(spend_vault::allowance(&v, cid), 0); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun allowance_live_grant_is_raw_remaining() { + // allowance == raw remaining of the live entry. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), 500); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun allowance_suspended_entry_is_zero() { + // A suspended (remaining==0) entry reads 0 (same surface value as absent). + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 0, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), 0); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun allowance_unlimited_entry_is_sentinel() { + // An unlimited grant reads the u64::MAX sentinel, not a volume. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, MAXU64, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), MAXU64); + u::return_vault(v); + }; + s.end(); +} + +// === expiry === + +#[test] +fun expiry_absent_is_zero() { + // Absent (cap, T) -> expiry 0, no abort. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::expiry(&v, cid), 0); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun expiry_finite_grant_is_raw_value() { + // expiry == the raw finite expires_at_ms of the entry. + let mut s = ts::begin(OWNER); + let exp = u::start_ms() + 5_000; + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, exp); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::expiry(&v, cid), exp); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun expiry_no_expiry_grant_is_sentinel() { + // A no-expiry grant reads the u64::MAX sentinel. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::expiry(&v, cid), MAXU64); + u::return_vault(v); + }; + s.end(); +} + +// === contains: the absent-vs-suspended disambiguator === + +#[test] +fun contains_absent_is_false() { + // A never-granted (cap, T) is NOT in the ledger. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert!(!spend_vault::contains(&v, cid)); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun contains_live_grant_is_true() { + // A live grant is present. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert!(spend_vault::contains(&v, cid)); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun contains_suspended_at_zero_is_true() { + // The disambiguator. allowance==0 AND contains==true means SUSPENDED + // (cap still valid), distinct from a never-granted/revoked entry. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 0, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), 0); // looks empty by value + assert!(spend_vault::contains(&v, cid)); // but the entry is live + u::return_vault(v); + }; + s.end(); +} + +// === granted_coin_types === + +#[test] +fun granted_coin_types_fresh_vault_is_empty() { + // A fresh vault (no grants) has an empty granted-type set. + let mut s = ts::begin(OWNER); + // new_funded_vault funds USDC but grants nothing -> the granted set stays empty. + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 0); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun granted_coin_types_lists_both_granted_types() { + // After granting USDC and SUIT on one cap, both appear (length 2). + let mut s = ts::begin(OWNER); + let _cid = build_two_grant_cap(&mut s); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let types = spend_vault::granted_coin_types(&v); + assert_eq!(types.length(), 2); + assert!(types.contains(&type_name::with_defining_ids())); + assert!(types.contains(&type_name::with_defining_ids())); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun granted_coin_types_excludes_deposited_only_types() { + // deposit writes NO type set. A deposited-only coin (DEEP) is never + // enumerated; only the owner-granted USDC is. + let mut s = ts::begin(OWNER); + { + let (mut v, oc) = spend_vault::new(s.ctx()); + let clk = u::clock_at(u::start_ms(), s.ctx()); + // grant USDC (records USDC), but only DEPOSIT DEEP (records nothing). + spend_vault::deposit(&v, sui::coin::mint_for_testing(1_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let types = spend_vault::granted_coin_types(&v); + assert_eq!(types.length(), 1); + assert!(types.contains(&type_name::with_defining_ids())); + assert!(!types.contains(&type_name::with_defining_ids())); // deposited-only excluded + u::return_vault(v); + }; + s.end(); +} + +// === Cap binding reads === + +#[test] +fun owner_cap_vault_id_matches_vault() { + // owner_cap_vault_id(&oc) == object::id(&v). + let mut s = ts::begin(OWNER); + let (vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + assert_eq!(spend_vault::owner_cap_vault_id(&oc), vid); + assert_eq!(spend_vault::owner_cap_vault_id(&oc), object::id(&v)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun spender_cap_vault_id_matches_vault() { + // spender_cap_vault_id(&cap) == object::id(&v). + let mut s = ts::begin(OWNER); + let (vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let v = u::take_vault(&s); + let cap = ts::take_from_sender(&s); + assert_eq!(spend_vault::spender_cap_vault_id(&cap), vid); + assert_eq!(spend_vault::spender_cap_vault_id(&cap), object::id(&v)); + ts::return_to_sender(&s, cap); + u::return_vault(v); + }; + s.end(); +} + +// === Totality sweeps === + +#[test] +fun reads_after_revoke_all_default_and_never_abort() { + // After a (cap, T) is granted then revoked, EVERY read returns a default + // (allowance 0, expiry 0, contains false) and NONE aborts. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + // Owner revokes the (cap, USDC) entry. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + // Now every read on the revoked (cap, USDC) is at its default, no abort. + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + assert_eq!(spend_vault::allowance(&v, cid), 0); + assert_eq!(spend_vault::expiry(&v, cid), 0); + assert!(!spend_vault::contains(&v, cid)); + // granted_coin_types is grows-only: USDC stays recorded even after revoke. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 1); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun reads_on_fresh_never_touched_cap_id_are_defaults() { + // A brand-new cap_id that was never set against the vault: every read + // defaults (allowance 0, expiry 0, contains false), no abort. + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + // Mint a fresh, budgetless cap: a never-touched cap_id. + let fresh = spend_vault::mint_cap(&v, &oc, s.ctx()); + let fresh_id = object::id(&fresh); + + assert_eq!(spend_vault::allowance(&v, fresh_id), 0); + assert_eq!(spend_vault::expiry(&v, fresh_id), 0); + assert!(!spend_vault::contains(&v, fresh_id)); + + spend_vault::delete_orphaned_cap(fresh); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun reads_on_unrelated_arbitrary_cap_id_are_defaults() { + // Reads keyed by a cap_id belonging to a DIFFERENT vault still default + // cleanly (the lookup is just absent), never abort. + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + // A foreign vault's owner cap id, unknown to this vault's ledger. + let (vb, ocb) = spend_vault::new(s.ctx()); + let foreign_id = object::id(&ocb); + + assert_eq!(spend_vault::allowance(&v, foreign_id), 0); + assert_eq!(spend_vault::expiry(&v, foreign_id), 0); + assert!(!spend_vault::contains(&v, foreign_id)); + + spend_vault::share(vb); + transfer::public_transfer(ocb, OWNER); + u::return_vault(v); + }; + s.end(); +} + +// === Helpers === + +/// One tx: vault funded with USDC + SUIT, a cap granted both. Returns the cap_id. +fun build_two_grant_cap(s: &mut ts::Scenario): ID { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + spend_vault::deposit(&v, sui::coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, sui::coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + cid +} diff --git a/contracts/allowance/tests/spend_vault/revoke_tests.move b/contracts/allowance/tests/spend_vault/revoke_tests.move new file mode 100644 index 0000000..b63b4a9 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/revoke_tests.move @@ -0,0 +1,861 @@ +// Unit coverage for the cap-disposal surface: revoke, revoke_all, renounce, +// and delete_orphaned_cap. Exercises per-coin idempotent revoke, whole-cap +// revoke_all and renounce, orphaned-cap cleanup, cap/entry lifecycle +// independence, non-retroactive timing, and the canonical Revoked / Renounced / +// CapDeleted events. +// +// These paths never touch the accumulator, so there are no pool-balance effects +// to cover here. +module openzeppelin_allowance::spend_vault_revoke_tests; + +use openzeppelin_allowance::spend_vault::{Self, SpenderCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT, DEEP, FOO}; +use std::type_name; +use std::unit_test::{assert_eq, destroy}; +use sui::coin; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === revoke === + +#[test] +fun revoke_live_entry_returns_true_and_removes() { + // Revoke a live (cap, USDC) -> true, entry gone, one Revoked + // {was_present: true}. + let mut s = ts::begin(OWNER); + let (vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, cid)); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_revoked(vid, cid, type_name::with_defining_ids(), true, OWNER), + ); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_again_is_idempotent_no_op() { + // A second revoke of the now-absent (cap, USDC) -> false, NO abort, + // one Revoked {was_present: false} (the typo'd-cap_id signal). + let mut s = ts::begin(OWNER); + let (vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let first = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(first); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + // second revoke in its own tx for a clean event count. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let again = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(!again); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_revoked(vid, cid, type_name::with_defining_ids(), false, OWNER), + ); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_never_granted_coin_returns_false() { + // Revoking a (cap, FOO) that was never granted -> false, no abort. + let mut s = ts::begin(OWNER); + let (vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(!was_present); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_revoked(vid, cid, type_name::with_defining_ids(), false, OWNER), + ); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_one_coin_leaves_other_coin_intact() { + // revoke on a USDC+SUIT cap leaves (cap, SUIT) + // live and spendable. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_two_coin_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, cid)); + // SUIT untouched. + assert!(spend_vault::contains(&v, cid)); + assert_eq!(spend_vault::allowance(&v, cid), 300); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + // SUIT still spendable. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); + assert_eq!(b.value(), 100); + assert_eq!(spend_vault::allowance(&v, cid), 200); + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +fun revoke_foreign_owner_cap_aborts() { + // revoke's only abort is EWrongOwnerCap. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut va = u::take_vault(&s); + // an OwnerCap bound to a DIFFERENT vault. + let (_vb, ocb) = spend_vault::new(s.ctx()); + spend_vault::revoke(&mut va, &ocb, cid, s.ctx()); // EWrongOwnerCap + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun revoke_then_spend_aborts_no_allowance() { + // After revoke the (cap, USDC) entry is truly gone, so a subsequent spend + // aborts ENoAllowance (not suspended-at-zero). + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let _ = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // ENoAllowance + abort + } +} + +#[test] +fun revoke_non_retroactive_spend_first_then_revoke() { + // A spend sequenced BEFORE the owner's revoke succeeds and is not + // clawed back; the later revoke also succeeds. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + // tx A: spend succeeds. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 200, &clk, s.ctx()); + assert_eq!(b.value(), 200); + assert_eq!(spend_vault::allowance(&v, cid), 300); + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + // tx B: revoke succeeds (was_present true, the spend stands). + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun revoke_non_retroactive_revoke_first_then_spend_aborts() { + // Reversed order: revoke (tx A) then spend (tx B) -> ENoAllowance. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let _ = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // ENoAllowance + abort + } +} + +#[test] +fun revoke_suspended_entry_returns_true() { + // No allowance state can race revoke into failure. A suspended + // (remaining == 0) entry is still present, so revoke returns true and removes it. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 0, MAXU64); // suspended + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + assert!(spend_vault::contains(&v, cid)); // suspended-but-present + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_unlimited_entry_returns_true() { + // An unlimited (remaining == u64::MAX) grant is ordinary to + // revoke; the sentinel state does not block removal. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, MAXU64, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_expired_entry_returns_true() { + // An EXPIRED entry cannot race revoke into failure; revoke is + // time-blind and removes it, returning true. + let mut s = ts::begin(OWNER); + let exp = u::start_ms() + 1_000; + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, exp); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + // the clock is irrelevant: revoke never reads it. + let was_present = spend_vault::revoke(&mut v, &oc, cid, s.ctx()); + assert!(was_present); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === revoke_all === + +#[test] +fun revoke_all_three_coin_cap_removes_all() { + // A 3-coin cap (USDC, SUIT, DEEP) -> revoke_all removes all + // three, emits 3 Revoked, every contains is false afterward. + let mut s = ts::begin(OWNER); + let (vid, cid) = build_three_coin_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + + // one Revoked per removed coin (order is granted_coin_types insertion order, + // but we assert by membership for robustness). + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + assert!(evs.contains(&revoked_ev(vid, cid, type_name::with_defining_ids()))); + assert!(evs.contains(&revoked_ev(vid, cid, type_name::with_defining_ids()))); + assert!(evs.contains(&revoked_ev(vid, cid, type_name::with_defining_ids()))); + + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun revoke_all_then_spend_aborts_no_allowance() { + // After revoke_all every (cap, T) is gone, so spends abort ENoAllowance. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_three_coin_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // ENoAllowance + abort + } +} + +#[test] +fun revoke_all_zero_entry_cap_succeeds_no_events() { + // revoke_all on a bare cap (no (cap, T) entries) succeeds and emits + // nothing (total on ledger state). + let mut s = ts::begin(OWNER); + let (_vid, cid) = bare_cap_vault(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 0); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_all_does_not_touch_second_cap() { + // revoke_all on cap1 leaves cap2's USDC live and spendable + // (it only builds keys with this cap_id). + let mut s = ts::begin(OWNER); + let (_vid, cid1, cid2) = two_caps_both_usdc(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid1, s.ctx()); + // cap1 dead, cap2 untouched. + assert!(!spend_vault::contains(&v, cid1)); + assert!(spend_vault::contains(&v, cid2)); + assert_eq!(spend_vault::allowance(&v, cid2), 700); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + // cap2 still spendable (take it by id: both caps live at SPENDER). + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap2 = ts::take_from_address_by_id(&s, SPENDER, cid2); + let b = spend_vault::spend(&mut v, &cap2, 100, &clk, s.ctx()); + assert_eq!(b.value(), 100); + assert_eq!(spend_vault::allowance(&v, cid2), 600); + destroy(b); + ts::return_to_address(SPENDER, cap2); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +fun revoke_all_foreign_owner_cap_aborts() { + // revoke_all's only abort is EWrongOwnerCap. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_three_coin_cap(&mut s); + s.next_tx(OWNER); + { + let mut va = u::take_vault(&s); + let (_vb, ocb) = spend_vault::new(s.ctx()); + spend_vault::revoke_all(&mut va, &ocb, cid, s.ctx()); // EWrongOwnerCap + abort + } +} + +#[test] +fun revoke_all_ungriefable_by_permissionless_deposit() { + // A permissionless deposit of dust does NOT inflate + // granted_coin_types; revoke_all still works over only the owner-granted types. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_two_coin_cap(&mut s); // owner granted {USDC, SUIT} + // anyone deposits junk FOO dust (permissionless, confers no rights, writes no set). + s.next_tx(@0xBAD); + { + let v = u::take_vault(&s); + spend_vault::deposit(&v, coin::mint_for_testing(7, s.ctx()), s.ctx()); + // granted_coin_types is unchanged: still exactly the two owner grants. + assert_eq!(spend_vault::granted_coin_types(&v).length(), 2); + u::return_vault(v); + }; + // revoke_all still removes exactly the owner-granted set, emits 2 Revoked. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 2); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_all_single_coin_emits_one_revoked() { + // A one-coin cap -> revoke_all emits exactly one Revoked and removes it. + let mut s = ts::begin(OWNER); + let (vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], revoked_ev(vid, cid, type_name::with_defining_ids())); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun revoke_all_twice_second_call_emits_nothing() { + // revoke_all is total and idempotent; a second call on the same cap + // (now empty) removes nothing and emits zero Revoked. + let mut s = ts::begin(OWNER); + let (_vid, cid) = build_two_coin_cap(&mut s); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); // all probes are no-ops + let evs = event::events_by_type(); + assert_eq!(evs.length(), 0); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === renounce === + +#[test] +fun renounce_three_coin_cap_removes_all_and_deletes_cap() { + // Renounce a cap with 3 live coin entries -> all removed, cap consumed, + // one Renounced; the cap cannot be taken again. + let mut s = ts::begin(OWNER); + let (vid, cid) = build_three_coin_cap(&mut s); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let cap = ts::take_from_sender(&s); + spend_vault::renounce(&mut v, cap, s.ctx()); // consumes the cap + + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_renounced(vid, cid, SPENDER)); + + u::return_vault(v); + }; + // The cap is gone: SPENDER holds no SpenderCap any more. + s.next_tx(SPENDER); + { + assert!(!ts::has_most_recent_for_address(SPENDER)); + }; + s.end(); +} + +#[test] +fun renounce_already_revoked_entries_still_succeeds() { + // Total on ledger state: a cap whose entries were ALL already revoked + // still renounces successfully and the cap is deleted. + let mut s = ts::begin(OWNER); + let (vid, cid) = build_two_coin_cap(&mut s); + // owner revokes everything first. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + // spender renounces the now-empty cap: still succeeds, cap deleted. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let cap = ts::take_from_sender(&s); + spend_vault::renounce(&mut v, cap, s.ctx()); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_renounced(vid, cid, SPENDER)); + u::return_vault(v); + }; + s.next_tx(SPENDER); + { + assert!(!ts::has_most_recent_for_address(SPENDER)); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EWrongVault)] +fun renounce_foreign_cap_aborts() { + // renounce's only abort is EWrongVault, a cap bound to a different vault. + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut va = u::take_vault(&s); + // a cap bound to a DIFFERENT vault. + let (vb, ocb) = spend_vault::new(s.ctx()); + let foreign = spend_vault::mint_cap(&vb, &ocb, s.ctx()); + spend_vault::renounce(&mut va, foreign, s.ctx()); // EWrongVault + abort + } +} + +#[test] +fun renounce_one_cap_leaves_second_cap_intact() { + // renounce only removes the renounced cap's own (cap, T) + // entries (keyed by object::id(&cap)); a second cap's USDC stays live. + let mut s = ts::begin(OWNER); + let (_vid, cid1, cid2) = two_caps_both_usdc(&mut s); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let cap1 = ts::take_from_address_by_id(&s, SPENDER, cid1); + spend_vault::renounce(&mut v, cap1, s.ctx()); + // cap1 gone, cap2 untouched. + assert!(!spend_vault::contains(&v, cid1)); + assert!(spend_vault::contains(&v, cid2)); + assert_eq!(spend_vault::allowance(&v, cid2), 700); + u::return_vault(v); + }; + // cap2 still spendable. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap2 = ts::take_from_address_by_id(&s, SPENDER, cid2); + let b = spend_vault::spend(&mut v, &cap2, 100, &clk, s.ctx()); + assert_eq!(b.value(), 100); + destroy(b); + ts::return_to_address(SPENDER, cap2); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === delete_orphaned_cap === + +#[test] +fun delete_orphaned_cap_after_vault_destroyed() { + // On an ORPHANED cap (vault destroyed first) -> succeeds, one + // CapDeleted {vault_id, cap_id}. + let mut s = ts::begin(OWNER); + let (vid, cid) = bare_cap_vault(&mut s); + // owner destroys the vault, orphaning the cap. + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::destroy(v, oc, s.ctx()); + }; + // the orphaned cap can still be disposed of. + s.next_tx(SPENDER); + { + let cap = ts::take_from_sender(&s); + spend_vault::delete_orphaned_cap(cap); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_cap_deleted(vid, cid)); + }; + s.end(); +} + +#[test] +fun delete_orphaned_cap_on_live_cap_strands_entries() { + // Deleting a LIVE cap with 2 entries succeeds but STRANDS + // both (contains still true); the owner then revoke_all's to recover them. + let mut s = ts::begin(OWNER); + let (vid, cid) = build_two_coin_cap(&mut s); + // spender deletes the live cap. + s.next_tx(SPENDER); + { + let cap = ts::take_from_sender(&s); + spend_vault::delete_orphaned_cap(cap); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_cap_deleted(vid, cid)); + }; + // entries STRANDED: still visible, no cap can re-match them. + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + assert!(spend_vault::contains(&v, cid)); + assert!(spend_vault::contains(&v, cid)); + // owner cleanup: revoke_all recovers the stranded entries. + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cid, s.ctx()); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +#[test] +fun delete_orphaned_cap_bare_live_cap_succeeds() { + // delete_orphaned_cap is total and vault-blind; a bare cap (no + // entries) on a still-LIVE vault deletes cleanly and emits CapDeleted. + let mut s = ts::begin(OWNER); + let (vid, cid) = bare_cap_vault(&mut s); + s.next_tx(SPENDER); + { + let cap = ts::take_from_sender(&s); + spend_vault::delete_orphaned_cap(cap); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_cap_deleted(vid, cid)); + }; + // the cap is gone. + s.next_tx(SPENDER); + { + assert!(!ts::has_most_recent_for_address(SPENDER)); + }; + s.end(); +} + +// When granted_coin_types holds a coin (SUIT, via a 2nd cap) the target cap never +// held, revoke_all on the 1st cap skips SUIT (the loop's `if (contains(key))` FALSE +// branch: a no-op probe with no Revoked) and removes only USDC. One Revoked is +// emitted per present coin only; iterating granted_coin_types over an absent key is +// a harmless probe. +#[test] +fun revoke_all_skips_coin_target_cap_never_held() { + let mut s = ts::begin(OWNER); + // capX holds USDC; capY holds SUIT, so granted_coin_types = {USDC, SUIT}. + let vid; + let cidx; + let cidy; + { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let capx = spend_vault::mint_cap(&v, &oc, s.ctx()); + cidx = object::id(&capx); + let capy = spend_vault::mint_cap(&v, &oc, s.ctx()); + cidy = object::id(&capy); + spend_vault::set_allowance(&mut v, &oc, cidx, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cidy, 300, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(capx, SPENDER); + transfer::public_transfer(capy, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + }; + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + spend_vault::revoke_all(&mut v, &oc, cidx, s.ctx()); + // Exactly ONE Revoked (USDC, present); SUIT is in granted_coin_types but never + // held by capX, so it is skipped with no emit. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert!(evs.contains(&revoked_ev(vid, cidx, type_name::with_defining_ids()))); + assert!(!spend_vault::contains(&v, cidx)); + assert!(spend_vault::contains(&v, cidy)); // capY untouched + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// Given a permissionless junk-type deposit by a third party, when the spender +// renounces, iteration stays owner-bounded (granted_coin_types is not inflated) and +// renounce removes all held coins and deletes the cap. granted_coin_types is +// owner-written and un-griefable. +#[test] +fun renounce_ungriefable_by_permissionless_deposit() { + let mut s = ts::begin(OWNER); + let (vid, cid) = build_three_coin_cap(&mut s); // granted = {USDC, SUIT, DEEP} + // A third party deposits junk FOO dust (permissionless; writes no granted type). + s.next_tx(@0xBAD); + { + let v = u::take_vault(&s); + spend_vault::deposit(&v, coin::mint_for_testing(7, s.ctx()), s.ctx()); + assert_eq!(spend_vault::granted_coin_types(&v).length(), 3); // not inflated + u::return_vault(v); + }; + // Spender renounces: iteration bounded by the 3 owner-granted types. + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let cap = ts::take_from_sender(&s); + spend_vault::renounce(&mut v, cap, s.ctx()); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + assert!(!spend_vault::contains(&v, cid)); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!(evs[0], spend_vault::test_new_renounced(vid, cid, SPENDER)); + u::return_vault(v); + }; + s.end(); +} + +// === Helpers === + +/// One tx: vault funded with USDC + SUIT, a cap granted both (500 / 300). +/// Returns (vid, cid). OwnerCap -> OWNER, SpenderCap -> SPENDER, Clock shared. +fun build_two_coin_cap(s: &mut ts::Scenario): (ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} + +/// One tx: vault funded with USDC + SUIT + DEEP, a cap granted all three +/// (500 / 300 / 100). Returns (vid, cid). +fun build_three_coin_cap(s: &mut ts::Scenario): (ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 100, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} + +/// One tx: a shared vault with a BARE cap (minted, no (cap, T) entry). Returns +/// (vid, cid). OwnerCap -> OWNER, SpenderCap -> SPENDER, Clock shared. +fun bare_cap_vault(s: &mut ts::Scenario): (ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} + +/// One tx: a vault funded with USDC and TWO caps both granted USDC (cap1=500, +/// cap2=700), both SpenderCaps sent to SPENDER. Returns (vid, cid1, cid2). +fun two_caps_both_usdc(s: &mut ts::Scenario): (ID, ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap1 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid1 = object::id(&cap1); + let cap2 = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid2 = object::id(&cap2); + spend_vault::set_allowance(&mut v, &oc, cid1, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid2, 700, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap1, SPENDER); + transfer::public_transfer(cap2, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid1, cid2) +} + +/// Build the canonical Revoked{was_present: true, by: OWNER} value for a +/// revoke_all leg, for membership assertions over the emitted batch. +fun revoked_ev(vault_id: ID, cap_id: ID, coin_type: type_name::TypeName): spend_vault::Revoked { + spend_vault::test_new_revoked(vault_id, cap_id, coin_type, true, OWNER) +} diff --git a/contracts/allowance/tests/spend_vault/spend_tests.move b/contracts/allowance/tests/spend_vault/spend_tests.move new file mode 100644 index 0000000..38bdcbe --- /dev/null +++ b/contracts/allowance/tests/spend_vault/spend_tests.move @@ -0,0 +1,538 @@ +// spend: the cap-gated, exact-amount-or-abort draw. +// +// Covers the unit-level surface of spend: exact-amount delivery and exact +// ledger decrement, the u64::MAX unlimited sentinel (never decremented), expiry +// boundary and no-expiry sentinel, the per-(cap,coin) cumulative ceiling, the +// runtime coin-type gate, abort codes and their firing precedence, bearer-cap +// behavior, and cross-cap/cross-type isolation. +// +// Pool-balance effects (the native pool-short abort at `redeem_funds` and the +// atomic revert of the pre-decrement) need a live AccumulatorRoot, which the +// unit-test VM cannot construct; those are covered by integration tests. +module openzeppelin_allowance::spend_vault_spend_tests; + +use openzeppelin_allowance::spend_vault::{Self, SpenderCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC, SUIT, FOO}; +use std::type_name; +use std::unit_test::{assert_eq, destroy}; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const THIEF: address = @0xBAD; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === Happy path: exact amount, exact decrement, Spent event === + +#[test] +fun spend_partial_delivers_exact_and_decrements() { + let mut s = ts::begin(OWNER); + let (vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + + let bal = spend_vault::spend(&mut v, &cap, 300, &clk, s.ctx()); + assert_eq!(bal.value(), 300); // exact amount out + assert_eq!(spend_vault::allowance(&v, cid), 200); // exact decrement + + // exactly one Spent with the post-call raw remaining. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_spent(vid, cid, type_name::with_defining_ids(), 300, 200, SPENDER), + ); + + destroy(bal); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun spend_full_budget_to_zero_keeps_entry() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + + let bal = spend_vault::spend(&mut v, &cap, 500, &clk, s.ctx()); + assert_eq!(bal.value(), 500); + // drained-to-zero entry STAYS (suspended, not removed). + assert_eq!(spend_vault::allowance(&v, cid), 0); + assert!(spend_vault::contains(&v, cid)); + + destroy(bal); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun spend_cumulative_draws_sum_to_budget() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 500, MAXU64); + // three draws summing to exactly the budget. + spend_n(&mut s, cid, 200, 300); + spend_n(&mut s, cid, 200, 100); + spend_n(&mut s, cid, 100, 0); + s.end(); +} + +#[test] +fun spend_deposit_then_spend_same_grant() { + // a credit is spendable; here the deposit is in setup, the spend in the next + // tx (cross-tx credit persists in the VM). + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 400, 1_000, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let bal = spend_vault::spend(&mut v, &cap, 400, &clk, s.ctx()); + assert_eq!(bal.value(), 400); + assert_eq!(spend_vault::allowance(&v, cid), 600); + destroy(bal); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun spend_output_routes_back_via_deposit_balance() { + // the Balance egress folds straight back through deposit_balance. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let bal = spend_vault::spend(&mut v, &cap, 300, &clk, s.ctx()); + spend_vault::deposit_balance(&v, bal, s.ctx()); // consumes the Balance + assert_eq!(spend_vault::allowance(&v, cid), 200); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Sentinels (unlimited budget) === + +#[test] +fun spend_unlimited_never_decrements() { + let mut s = ts::begin(OWNER); + let (vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, MAXU64, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + + let b1 = spend_vault::spend(&mut v, &cap, 4_000, &clk, s.ctx()); + let b2 = spend_vault::spend(&mut v, &cap, 5_000, &clk, s.ctx()); + // remaining stays the sentinel; Spent.remaining reports u64::MAX. + assert_eq!(spend_vault::allowance(&v, cid), MAXU64); + let evs = event::events_by_type(); + assert_eq!(evs[evs.length() - 1], + spend_vault::test_new_spent(vid, cid, type_name::with_defining_ids(), 5_000, MAXU64, SPENDER)); + + destroy(b1); + destroy(b2); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test] +fun spend_near_sentinel_finite_decrements_normally() { + // MAX-1 is an ordinary finite budget, NOT the sentinel. + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10, MAXU64 - 1, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let bal = spend_vault::spend(&mut v, &cap, 1, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), MAXU64 - 2); + destroy(bal); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// Given a no-expiry grant (expires_at_ms == u64::MAX) and the clock pushed to +// u64::MAX, the spend still succeeds: with clock == expiry the `clock < expiry` +// comparison is false, so success rides entirely on the `expires_at_ms == +// u64::MAX` no-expiry sentinel short-circuit. +#[test] +fun spend_no_expiry_sentinel_succeeds_at_max_clock() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 1_000, u::no_expiry()); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let mut clk = u::take_clock(&s); + // clock == expiry == u64::MAX: `clock < expiry` is false, so success rides + // entirely on the `expires_at_ms == u64::MAX` short-circuit. + clk.set_for_testing(MAXU64); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 1_000, &clk, s.ctx()); + assert_eq!(b.value(), 1_000); + assert_eq!(spend_vault::allowance(&v, cid), 0); + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Multi-coin one cap (per-coin independence) === + +#[test] +fun spend_two_coins_one_cap_independent() { + let mut s = ts::begin(OWNER); + // Build a cap with USDC=500 and SUIT=300 in one tx. + let (vid, cid) = build_two_coin_cap(&mut s); + let _ = vid; + // spend USDC 200 -> USDC 300, SUIT untouched + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 200, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 300); + assert_eq!(spend_vault::allowance(&v, cid), 300); // SUIT bit-identical + destroy(b); + // spend SUIT 100 -> SUIT 200, USDC unchanged + let b2 = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); + assert_eq!(spend_vault::allowance(&v, cid), 200); + assert_eq!(spend_vault::allowance(&v, cid), 300); + destroy(b2); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Bearer cap + sender-independence === + +#[test] +fun spend_works_for_new_holder_after_transfer() { + let mut s = ts::begin(OWNER); + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + // SPENDER leaks the cap to THIEF. + s.next_tx(SPENDER); + { + let cap = ts::take_from_sender(&s); + transfer::public_transfer(cap, THIEF); + }; + // THIEF (an unrelated sender) spends identically: cap-gated, never sender-gated. + s.next_tx(THIEF); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 500, &clk, s.ctx()); + assert_eq!(b.value(), 500); + assert_eq!(spend_vault::allowance(&v, cid), 0); + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// Given one cap budgeted for USDC+SUIT leaked to an unrelated holder, when the +// holder spends each coin it drains BOTH: the bearer blast radius is the sum of +// the cap's per-coin budgets, with no holder-identity check (cap-gated, never +// sender-gated). +#[test] +fun spend_leaked_cap_drains_both_coins() { + let mut s = ts::begin(OWNER); + let (vid, cid) = build_two_coin_cap(&mut s); // USDC=500, SUIT=300 at SPENDER + let _ = vid; + // SPENDER leaks the cap to THIEF. + s.next_tx(SPENDER); + { + let cap = ts::take_from_sender(&s); + transfer::public_transfer(cap, THIEF); + }; + // THIEF drains BOTH coins through the one leaked cap. + s.next_tx(THIEF); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let bu = spend_vault::spend(&mut v, &cap, 500, &clk, s.ctx()); + let bs = spend_vault::spend(&mut v, &cap, 300, &clk, s.ctx()); + assert_eq!(bu.value(), 500); + assert_eq!(bs.value(), 300); + assert_eq!(spend_vault::allowance(&v, cid), 0); + assert_eq!(spend_vault::allowance(&v, cid), 0); + destroy(bu); + destroy(bs); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +// === Aborts (exact codes) === + +#[test, expected_failure(abort_code = spend_vault::EWrongVault)] +fun spend_wrong_vault_cap_aborts() { + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut va = u::take_vault(&s); + let clk = u::take_clock(&s); + // a cap bound to a DIFFERENT vault + let (vb, ocb) = spend_vault::new(s.ctx()); + let foreign = spend_vault::mint_cap(&vb, &ocb, s.ctx()); + let _b = spend_vault::spend(&mut va, &foreign, 100, &clk, s.ctx()); // EWrongVault + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun spend_never_granted_coin_aborts_no_allowance() { + // cap budgeted for USDC only; spend -> ENoAllowance. + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // code 2 + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EAllowanceExpired)] +fun spend_at_exact_expiry_ms_aborts() { + // closed boundary: a spend in the exact ms of expiry fails. + let mut s = ts::begin(OWNER); + let exp = u::start_ms() + 1_000; + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, exp); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let mut clk = u::take_clock(&s); + clk.set_for_testing(exp); // now == expires_at_ms + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); // code 3 + abort + } +} + +#[test] +fun spend_one_ms_before_expiry_succeeds() { + let mut s = ts::begin(OWNER); + let exp = u::start_ms() + 1_000; + let (_vid, cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, exp); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let mut clk = u::take_clock(&s); + clk.set_for_testing(exp - 1); + let cap = ts::take_from_sender(&s); + let b = spend_vault::spend(&mut v, &cap, 100, &clk, s.ctx()); + assert_eq!(b.value(), 100); + assert_eq!(spend_vault::allowance(&v, cid), 400); + destroy(b); + ts::return_to_sender(&s, cap); + u::return_vault(v); + u::return_clock(clk); + }; + s.end(); +} + +#[test, expected_failure(abort_code = spend_vault::EZeroAmount)] +fun spend_zero_amount_aborts() { + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 0, &clk, s.ctx()); // code 5 + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EAllowanceExceeded)] +fun spend_over_budget_aborts() { + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 501, &clk, s.ctx()); // code 4 + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EAllowanceExceeded)] +fun spend_suspended_at_zero_aborts_exceeded_not_no_allowance() { + // a suspended (remaining==0) entry is code 4, NOT code 2. + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 0, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 1, &clk, s.ctx()); // code 4 + abort + } +} + +// === Precedence pairs (firing order is by POSITION, not code magnitude) === + +#[test, expected_failure(abort_code = spend_vault::EWrongVault)] +fun precedence_wrong_vault_beats_zero_amount() { + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut va = u::take_vault(&s); + let clk = u::take_clock(&s); + let (vb, ocb) = spend_vault::new(s.ctx()); + let foreign = spend_vault::mint_cap(&vb, &ocb, s.ctx()); + // wrong vault AND zero amount -> code 1 wins (pos 1) + let _b = spend_vault::spend(&mut va, &foreign, 0, &clk, s.ctx()); + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::ENoAllowance)] +fun precedence_no_allowance_beats_zero_amount() { + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, MAXU64); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + // wrong coin (no entry) AND zero amount -> code 2 (pos 2) wins + let _b = spend_vault::spend(&mut v, &cap, 0, &clk, s.ctx()); + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EAllowanceExpired)] +fun precedence_expired_beats_exceeded() { + let mut s = ts::begin(OWNER); + let exp = u::start_ms() + 1_000; + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 10_000, 500, exp); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let mut clk = u::take_clock(&s); + clk.set_for_testing(exp + 5); // expired + let cap = ts::take_from_sender(&s); + // expired AND over-budget -> code 3 (pos 3) beats code 4 (pos 5) + let _b = spend_vault::spend(&mut v, &cap, 9_999, &clk, s.ctx()); + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EAllowanceExpired)] +fun precedence_expired_beats_zero_amount() { + // expiry (pos 3) fires before zero-amount (pos 4): a spend of 0 on an expired + // grant aborts EAllowanceExpired, NOT EZeroAmount. + let mut s = ts::begin(OWNER); + let exp = u::start_ms() + 1_000; + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 500, exp); + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let mut clk = u::take_clock(&s); + clk.set_for_testing(exp + 5); // expired + let cap = ts::take_from_sender(&s); + // expired AND zero amount -> code 3 (pos 3) beats EZeroAmount (pos 4) + let _b = spend_vault::spend(&mut v, &cap, 0, &clk, s.ctx()); + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EZeroAmount)] +fun precedence_zero_beats_exceeded() { + // amount 0 (pos 4) fires before over-budget (pos 5), even though code 5 > code 4. + let mut s = ts::begin(OWNER); + let (_vid, _cid) = u::setup_granted(&mut s, OWNER, SPENDER, 1_000, 0, MAXU64); // suspended + s.next_tx(SPENDER); + { + let mut v = u::take_vault(&s); + let clk = u::take_clock(&s); + let cap = ts::take_from_sender(&s); + let _b = spend_vault::spend(&mut v, &cap, 0, &clk, s.ctx()); // code 5, not 4 + abort + } +} + +// === Helpers === + +/// One spend of `amount` in a fresh SPENDER tx, asserting the post-call remaining. +fun spend_n(s: &mut ts::Scenario, cid: ID, amount: u64, expect_remaining: u64) { + s.next_tx(SPENDER); + let mut v = u::take_vault(s); + let clk = u::take_clock(s); + let cap = ts::take_from_sender(s); + let b = spend_vault::spend(&mut v, &cap, amount, &clk, s.ctx()); + assert_eq!(b.value(), amount); + assert_eq!(spend_vault::allowance(&v, cid), expect_remaining); + destroy(b); + ts::return_to_sender(s, cap); + u::return_vault(v); + u::return_clock(clk); +} + +/// One tx: vault funded with USDC + SUIT, a cap granted both. Returns (vid, cid). +fun build_two_coin_cap(s: &mut ts::Scenario): (ID, ID) { + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, sui::coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + spend_vault::deposit(&v, sui::coin::mint_for_testing(10_000, s.ctx()), s.ctx()); + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cid, 500, MAXU64, option::none(), &clk, s.ctx()); + spend_vault::set_allowance(&mut v, &oc, cid, 300, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + (vid, cid) +} diff --git a/contracts/allowance/tests/spend_vault/sv_test_utils.move b/contracts/allowance/tests/spend_vault/sv_test_utils.move new file mode 100644 index 0000000..693ede9 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/sv_test_utils.move @@ -0,0 +1,122 @@ +// Shared test scaffolding for the spend_vault unit suite. +// +// Holds the test coin types, common constants, and the high-traffic setup +// helpers so each thematic test file stays focused on the behavior it pins, +// not on boilerplate. Unit-test-only: never compiled into the published module. +// +// Testability note: the funds-accumulator pool (object-owned address balances) +// is NOT modeled by the unit-test VM (over-withdraw and withdraw-from-empty both +// succeed, and `AccumulatorRoot` cannot be constructed), so these helpers cover +// the ledger / cap / event / exact-value-delivery surface. Because the unit VM +// does not move accumulator funds and cannot construct an `AccumulatorRoot`, the +// following behaviors are NOT exercised by the unit suite: +// (a) the native pool-short abort and the atomic-revert rollback of the +// pre-decrement in `spend` / `withdraw`; +// (b) all branches of `withdraw_all`, including the zero-pool `amount: 0` path; +// (c) the root-taking reads `spendable_now` and `balance_value`. +// CAVEAT: because the unit VM lets the withdraw "succeed" without moving funds, +// line-coverage tooling will mark the `spend` / `withdraw` fund-movement lines as +// covered even though the pool-short and rollback behavior is NOT asserted by any +// unit test. Those paths (the pool-short native abort, the rollback, and every +// `&AccumulatorRoot`-taking read) require integration tests against a live +// network, not here. +#[test_only] +module openzeppelin_allowance::sv_test_utils; + +use openzeppelin_allowance::spend_vault::{Self, Vault, OwnerCap, SpenderCap}; +use sui::clock::{Self, Clock}; +use sui::coin; +use sui::test_scenario::{Self as ts, Scenario}; + +// === Test coin types (distinct defining types so BudgetKeys differ) === + +public struct USDC has drop {} +public struct SUIT has drop {} // a stand-in "SUI"-like coin (avoids the real sui::sui::SUI) +public struct DEEP has drop {} +public struct FOO has drop {} // junk type for un-griefability / wrong-coin tests + +// === Common constants (consts are module-private in Move; expose via fns) === + +const NO_EXPIRY: u64 = 18_446_744_073_709_551_615; // u64::MAX sentinel +const MAX_U64: u64 = 18_446_744_073_709_551_615; +const START_MS: u64 = 1_700_000_000_000; // a fixed "now" base for clock tests + +public fun no_expiry(): u64 { NO_EXPIRY } + +public fun max_u64(): u64 { MAX_U64 } + +public fun start_ms(): u64 { START_MS } + +// === Clock helpers === + +/// A fresh test Clock set to `ms` (caller owns it; share or destroy it). +public fun clock_at(ms: u64, ctx: &mut TxContext): Clock { + let mut c = clock::create_for_testing(ctx); + c.set_for_testing(ms); + c +} + +// === Vault setup helpers (each runs inside the CURRENT scenario tx) === + +/// Create + share a vault funded with `amt` of `T`, send the OwnerCap to +/// `owner`, and create+share a Clock at START_MS. Returns the vault id. +/// No cap, no grant: for fund/withdraw/lifecycle tests. +public fun new_funded_vault(s: &mut Scenario, owner: address, amt: u64): ID { + let (v, oc) = spend_vault::new(s.ctx()); + let vault_id = object::id(&v); + if (amt > 0) { + spend_vault::deposit(&v, coin::mint_for_testing(amt, s.ctx()), s.ctx()); + }; + spend_vault::share(v); + transfer::public_transfer(oc, owner); + let clk = clock_at(START_MS, s.ctx()); + clk.share_for_testing(); + vault_id +} + +/// Full single-coin setup in one tx: create vault, deposit `amt` of `T`, mint a +/// cap, grant (`budget`, `expiry`) on (cap, T), share the vault, send the +/// OwnerCap to `owner` and the SpenderCap to `spender`, and create+share a Clock +/// at START_MS. Returns (vault_id, cap_id). The workhorse for spend / revoke / +/// cap-update tests. +public fun setup_granted( + s: &mut Scenario, + owner: address, + spender: address, + amt: u64, + budget: u64, + expiry: u64, +): (ID, ID) { + let clk = clock_at(START_MS, s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vault_id = object::id(&v); + if (amt > 0) { + spend_vault::deposit(&v, coin::mint_for_testing(amt, s.ctx()), s.ctx()); + }; + let cap = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cap_id = object::id(&cap); + spend_vault::set_allowance(&mut v, &oc, cap_id, budget, expiry, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap, spender); + spend_vault::share(v); + transfer::public_transfer(oc, owner); + clk.share_for_testing(); + (vault_id, cap_id) +} + +// === Object-taking shorthands (cut return_shared boilerplate noise) === + +public fun take_vault(s: &Scenario): Vault { ts::take_shared(s) } + +public fun take_clock(s: &Scenario): Clock { ts::take_shared(s) } + +public fun return_vault(v: Vault) { ts::return_shared(v); } + +public fun return_clock(c: Clock) { ts::return_shared(c); } + +public fun take_owner_cap(s: &Scenario, owner: address): OwnerCap { + ts::take_from_address(s, owner) +} + +public fun take_spender_cap(s: &Scenario, spender: address): SpenderCap { + ts::take_from_address(s, spender) +} diff --git a/contracts/allowance/tests/spend_vault/withdraw_tests.move b/contracts/allowance/tests/spend_vault/withdraw_tests.move new file mode 100644 index 0000000..a2dcba1 --- /dev/null +++ b/contracts/allowance/tests/spend_vault/withdraw_tests.move @@ -0,0 +1,239 @@ +// withdraw: partial owner exit, exact-value delivery as Balance. +// +// Covers the partial-withdraw surface: Balance return linearity, the owner +// gate (EWrongOwnerCap first), EZeroAmount on a partial withdraw, abort +// precedence by position (owner gate before zero-amount), exact partial +// delivery with no skim or retention, the consumable Balance egress routing +// onward via deposit_balance, batching multiple withdraws in one tx, the owner +// exit consulting only the cap binding and pool (never the ledger, so no +// spender state can block it), and a single Withdrawn event emitted post-draw. +// +// withdraw_all (full drain) and the native pool-short aborts are not covered +// here: pool-balance effects need a live AccumulatorRoot, which the unit-test +// VM cannot construct, so over-withdraw and withdraw-from-empty succeed; those +// are covered by integration tests. +module openzeppelin_allowance::spend_vault_withdraw_tests; + +use openzeppelin_allowance::spend_vault::{Self, OwnerCap}; +use openzeppelin_allowance::sv_test_utils::{Self as u, USDC}; +use std::type_name; +use std::unit_test::{assert_eq, destroy}; +use sui::event; +use sui::test_scenario as ts; + +const OWNER: address = @0xA; +const SPENDER: address = @0xB; +const MAXU64: u64 = 18_446_744_073_709_551_615; + +// === Happy path: exact value out + Withdrawn event === + +#[test] +fun withdraw_partial_delivers_exact_value_and_emits() { + // withdraw(300) from a 1000 pool returns exactly 300, no skim, and + // emits exactly one Withdrawn carrying the actual amount. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + + let bal = spend_vault::withdraw(&mut v, &oc, 300, s.ctx()); + assert_eq!(bal.value(), 300); // exact delivery + + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_withdrawn(vid, type_name::with_defining_ids(), 300, OWNER), + ); + + destroy(bal); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === The Balance egress is consumable and routes onward === + +#[test] +fun withdraw_output_routes_back_via_deposit_balance() { + // The withdrawn Balance folds straight back through deposit_balance (the + // symmetric ingress), proving it is a real consumable linear value, not a + // phantom. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + + let mut vmut = v; + let bal = spend_vault::withdraw(&mut vmut, &oc, 300, s.ctx()); + spend_vault::deposit_balance(&vmut, bal, s.ctx()); // consumes the Balance + + ts::return_to_sender(&s, oc); + u::return_vault(vmut); + }; + s.end(); +} + +// === Multiple withdraws batch in one tx (never sole PTB step) === + +#[test] +fun withdraw_multiple_in_one_tx_succeed() { + // The module assumes no sole-caller / sole-step; three withdraws in one tx + // each deliver their exact amount and emit their own Withdrawn. + let mut s = ts::begin(OWNER); + let vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + + let b1 = spend_vault::withdraw(&mut v, &oc, 100, s.ctx()); + let b2 = spend_vault::withdraw(&mut v, &oc, 250, s.ctx()); + let b3 = spend_vault::withdraw(&mut v, &oc, 50, s.ctx()); + assert_eq!(b1.value(), 100); + assert_eq!(b2.value(), 250); + assert_eq!(b3.value(), 50); + + // Three distinct Withdrawn events; check the last by value. + let evs = event::events_by_type(); + assert_eq!(evs.length(), 3); + assert_eq!( + evs[2], + spend_vault::test_new_withdrawn(vid, type_name::with_defining_ids(), 50, OWNER), + ); + + destroy(b1); + destroy(b2); + destroy(b3); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === Owner exit consults only the cap binding, never the ledger === + +#[test] +fun withdraw_succeeds_with_maximal_adversarial_ledger() { + // Build a vault carrying a live grant, a suspended grant, and an unlimited + // grant across two caps; withdraw still succeeds (no ledger consult). No + // spender state can block the owner exit. Non-root path only. + let mut s = ts::begin(OWNER); + let clk = u::clock_at(u::start_ms(), s.ctx()); + let (mut v, oc) = spend_vault::new(s.ctx()); + let vid = object::id(&v); + spend_vault::deposit(&v, sui::coin::mint_for_testing(1_000, s.ctx()), s.ctx()); + // cap A: a live finite grant. + let cap_a = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid_a = object::id(&cap_a); + spend_vault::set_allowance(&mut v, &oc, cid_a, 500, MAXU64, option::none(), &clk, s.ctx()); + // cap B: a suspended grant (remaining == 0) + an unlimited grant (sentinel). + let cap_b = spend_vault::mint_cap(&v, &oc, s.ctx()); + let cid_b = object::id(&cap_b); + spend_vault::set_allowance(&mut v, &oc, cid_b, 0, MAXU64, option::none(), &clk, s.ctx()); + transfer::public_transfer(cap_a, SPENDER); + transfer::public_transfer(cap_b, SPENDER); + spend_vault::share(v); + transfer::public_transfer(oc, OWNER); + clk.share_for_testing(); + + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + // Despite the live + suspended ledger entries across two caps, the owner + // exits cleanly: withdraw never reads the ledger. + let bal = spend_vault::withdraw(&mut v, &oc, 800, s.ctx()); + assert_eq!(bal.value(), 800); + let evs = event::events_by_type(); + assert_eq!(evs.length(), 1); + assert_eq!( + evs[0], + spend_vault::test_new_withdrawn(vid, type_name::with_defining_ids(), 800, OWNER), + ); + destroy(bal); + ts::return_to_sender(&s, oc); + u::return_vault(v); + }; + s.end(); +} + +// === Aborts (exact codes) === + +#[test, expected_failure(abort_code = spend_vault::EZeroAmount)] +fun withdraw_zero_amount_aborts() { + // Zero is meaningless on a partial withdraw. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let mut v = u::take_vault(&s); + let oc = u::take_owner_cap(&s, OWNER); + let _bal = spend_vault::withdraw(&mut v, &oc, 0, s.ctx()); + abort + } +} + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +fun withdraw_foreign_owner_cap_aborts() { + // An OwnerCap bound to a different vault is rejected by the first check. + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let mut va = u::take_vault(&s); + // a fresh, unrelated vault + its owner cap (the foreign cap) + let (_vb, foreign_oc) = spend_vault::new(s.ctx()); + let _bal = spend_vault::withdraw(&mut va, &foreign_oc, 100, s.ctx()); + abort + } +} + +// === Precedence (firing order is by position, not code magnitude) === + +#[test, expected_failure(abort_code = spend_vault::EWrongOwnerCap)] +fun precedence_wrong_owner_cap_beats_zero_amount() { + // Foreign owner cap AND amount 0: the owner gate (position 1) beats the + // zero-amount check (position 2). + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + s.next_tx(OWNER); + { + let mut va = u::take_vault(&s); + let (_vb, foreign_oc) = spend_vault::new(s.ctx()); + // wrong vault AND zero amount -> owner gate wins + let _bal = spend_vault::withdraw(&mut va, &foreign_oc, 0, s.ctx()); + abort + } +} + +// === Helpers === + +/// One withdraw of `amount` in a fresh OWNER tx, asserting the delivered value. +fun withdraw_n(s: &mut ts::Scenario, amount: u64) { + s.next_tx(OWNER); + let mut v = u::take_vault(s); + let oc = ts::take_from_sender(s); + let bal = spend_vault::withdraw(&mut v, &oc, amount, s.ctx()); + assert_eq!(bal.value(), amount); + destroy(bal); + ts::return_to_sender(s, oc); + u::return_vault(v); +} + +#[test] +fun withdraw_across_txs_each_delivers_exact() { + // Repeated partial withdraws across txs each deliver exactly `amount` (the + // unit VM lets each draw succeed regardless of pool). + let mut s = ts::begin(OWNER); + let _vid = u::new_funded_vault(&mut s, OWNER, 1_000); + withdraw_n(&mut s, 300); + withdraw_n(&mut s, 400); + withdraw_n(&mut s, 300); + s.end(); +}