Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
23 changes: 23 additions & 0 deletions contracts/allowance/Move.lock
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" }
6 changes: 6 additions & 0 deletions contracts/allowance/Move.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[package]
name = "openzeppelin_allowance"
edition = "2024"

[addresses]
openzeppelin_allowance = "0x0"
129 changes: 129 additions & 0 deletions contracts/allowance/README.md
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)
128 changes: 128 additions & 0 deletions contracts/allowance/examples/spend_vault/defi_keeper.move
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
Comment on lines +66 to +75

Copy link
Copy Markdown

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).

create currently shares internally and returns only ID. Returning the Service object 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/allowance/examples/spend_vault/defi_keeper.move` around lines 66 -
75, The create function in defi_keeper.move currently combines object creation
with sharing in a single step and returns only the ID. Refactor it to follow the
two-step initialization pattern: modify the function to return the Service
object itself instead of just the service_id, and remove the internal
transfer::share_object(service) call. This allows callers to receive the Service
object and handle the sharing step separately, aligning with the package's
composability architecture pattern as specified in ARCHITECTURE.md.

Source: Coding guidelines

}

/// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Public function docs need required Parameters/Returns/Aborts sections.

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 /// docs for public APIs with #### Parameters, #### Returns, and complete #### Aborts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/allowance/examples/spend_vault/defi_keeper.move` around lines 63 -
127, Add STYLEGUIDE-required structured documentation sections to all public
functions in this module. For each public function (create, register,
execute_topup, unregister, vault_id, is_registered), augment the existing
descriptive comments by adding #### Parameters, #### Returns, and #### Aborts
sections. The Parameters section should describe each function parameter,
Returns should describe the return value or type, and Aborts should list all
assertion conditions that cause the function to abort, including those from
internal calls like EWrongVaultForService, ENotOperator, ENotRegistered, and
ENoAllowance from the vault library.

Source: Coding guidelines

}
Loading
Loading