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(); +}