-
Notifications
You must be signed in to change notification settings - Fork 11
feat: add allowance spend_vault module #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| [package] | ||
| name = "openzeppelin_allowance" | ||
| edition = "2024" | ||
|
|
||
| [addresses] | ||
| openzeppelin_allowance = "0x0" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>` (returned) | `spend`, `withdraw`, and `withdraw_all` hand back a `Balance<T>` 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<T>` (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<T>` 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<T>` for exactly `amount`, receiving a `Balance<T>` 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<T>` (the cap object is never invalidated), ends one coin with `revoke<T>`, 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<T>` / `withdraw_all<T>`, 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<T>( | ||
| funding: Coin<T>, | ||
| 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<T>( | ||
| &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<T> with no `drop`, so it must be consumed in the same PTB. | ||
| public fun draw<T>( | ||
| vault: &mut Vault, | ||
| cap: &SpenderCap, | ||
| amount: u64, | ||
| clock: &Clock, | ||
| ctx: &mut TxContext, | ||
| ) { | ||
| let bal = vault.spend<T>(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<T>` 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>` 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<T>` 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<u8> = "Caller is not the service operator"; | ||
| #[error(code = 1)] | ||
| const EWrongVaultForService: vector<u8> = "Cap is bound to a different vault than this service serves"; | ||
| #[error(code = 2)] | ||
| const ENotRegistered: vector<u8> = "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<address, SpenderCap>, | ||
| } | ||
|
|
||
| // === 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<T>( | ||
| s: &mut Service, | ||
| v: &mut Vault, | ||
| user: address, | ||
| amount: u64, | ||
| clock: &Clock, | ||
| ctx: &mut TxContext, | ||
| ): Balance<T> { | ||
| assert!(ctx.sender() == s.operator, ENotOperator); | ||
| assert!(s.caps.contains(user), ENotRegistered); | ||
|
|
||
| let cap = s.caps.borrow(user); | ||
| v.spend<T>(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) | ||
|
Comment on lines
+63
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Public function docs need required The public API comments are descriptive, but they are missing the STYLEGUIDE-required structured sections and complete abort listings. As per coding guidelines, STYLEGUIDE.md requires 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Use two-step service initialization (return object first, share separately).
createcurrently shares internally and returns onlyID. Returning theServiceobject and sharing in a separate step would align with the package’s composability/shared-object architecture pattern.As per coding guidelines, ARCHITECTURE.md prescribes two-step shared-object flows (create/return then share) and prefers returning objects over internal transfer/share in create-style APIs.
🤖 Prompt for AI Agents
Source: Coding guidelines