diff --git a/content/contracts-sui/1.x/api/utils.mdx b/content/contracts-sui/1.x/api/utils.mdx new file mode 100644 index 00000000..69e5333c --- /dev/null +++ b/content/contracts-sui/1.x/api/utils.mdx @@ -0,0 +1,330 @@ +--- +title: Utilities API Reference +--- + +This page documents the public API of `openzeppelin_utils` for OpenZeppelin Contracts for Sui `v1.x`. + +### `rate_limiter` [toc] [#rate_limiter] + + + +```move +use openzeppelin_utils::rate_limiter; +``` + +Embeddable, multi-strategy rate-limiting primitive. `RateLimiter` is a `store + drop` value, not a Sui object: it lives as a field inside an object the integrator owns, and its scope is that parent. There is no registry, no shared state, and no library-owned object. + +The same consume and inspect API serves all three strategies. The variant is chosen at construction and can only be swapped by building a fresh `RateLimiter` and overwriting the field. All functions that observe or advance time take `&Clock` (the shared Sui `Clock` singleton at `0x6`). + +Types + +- [`RateLimiter`](#rate_limiter-RateLimiter) + +Functions + +- [`new_bucket(capacity, refill_amount, refill_interval_ms, last_refill_ms, initial_available, clock)`](#rate_limiter-new_bucket) +- [`new_fixed_window(capacity, window_ms, window_start_ms, initial_available, clock)`](#rate_limiter-new_fixed_window) +- [`new_cooldown(capacity, cooldown_ms, cooldown_end_ms, initial_available, clock)`](#rate_limiter-new_cooldown) +- [`try_consume(self, amount, clock)`](#rate_limiter-try_consume) +- [`consume_or_abort(self, amount, clock)`](#rate_limiter-consume_or_abort) +- [`available(self, clock)`](#rate_limiter-available) +- [`capacity(self)`](#rate_limiter-capacity) +- [`is_bucket(self)`](#rate_limiter-is_bucket) +- [`is_fixed_window(self)`](#rate_limiter-is_fixed_window) +- [`is_cooldown(self)`](#rate_limiter-is_cooldown) +- [`refill_amount(self)`](#rate_limiter-refill_amount) +- [`refill_interval_ms(self)`](#rate_limiter-refill_interval_ms) +- [`last_refill_ms(self, clock)`](#rate_limiter-last_refill_ms) +- [`window_ms(self)`](#rate_limiter-window_ms) +- [`window_start_ms(self, clock)`](#rate_limiter-window_start_ms) +- [`cooldown_ms(self)`](#rate_limiter-cooldown_ms) +- [`cooldown_end_ms(self)`](#rate_limiter-cooldown_end_ms) + +Errors + +- [`ERateLimited`](#rate_limiter-ERateLimited) +- [`EZeroCapacity`](#rate_limiter-EZeroCapacity) +- [`EWrongVariant`](#rate_limiter-EWrongVariant) +- [`EInvalidAmount`](#rate_limiter-EInvalidAmount) +- [`EZeroRefillAmount`](#rate_limiter-EZeroRefillAmount) +- [`EZeroRefillInterval`](#rate_limiter-EZeroRefillInterval) +- [`EZeroWindow`](#rate_limiter-EZeroWindow) +- [`EZeroCooldown`](#rate_limiter-EZeroCooldown) +- [`EInitialAboveCapacity`](#rate_limiter-EInitialAboveCapacity) +- [`EWindowAnchorInFuture`](#rate_limiter-EWindowAnchorInFuture) +- [`ECooldownArmedWithTokens`](#rate_limiter-ECooldownArmedWithTokens) +- [`EBucketAnchorInFuture`](#rate_limiter-EBucketAnchorInFuture) +- [`ECooldownDeadlineOverflow`](#rate_limiter-ECooldownDeadlineOverflow) + +#### Types [!toc] [#rate_limiter-Types] + + +One embeddable limiter with three strategies: `Bucket`, `FixedWindow`, and `Cooldown`. Every variant stores an `available` counter that starts at `initial_available`, is decremented by successful consumes, and is reset back toward `capacity` by refill (`Bucket`), window rollover (`FixedWindow`), or cooldown release (`Cooldown`). + +`store` lets it live as a field inside an integrator's `key` object, and `drop` lets the parent object be destroyed and lets a fresh limiter overwrite an old one during reconfigure-by-reconstruction. There is no `key`/`UID` (it is never a top-level object) and no `copy` (a duplicable limiter would multiply configured capacity by N). + +The variant fields are not directly accessible from outside the module; read them through the getters below. + + +#### Functions [!toc] [#rate_limiter-Functions] + + +Creates a token bucket with an explicit initial balance and refill anchor. `available` accrues `refill_amount` every `refill_interval_ms`, capped at `capacity`. + +`last_refill_ms` anchors the refill schedule; for a greenfield limiter pass `clock.timestamp_ms()`. Pass an earlier value to preserve the refill phase across a reconstruction, but only when the rate (`refill_amount` / `refill_interval_ms`) is unchanged — carrying an old anchor into a new rate pre-credits elapsed time at the new rate and can briefly exceed it. `initial_available` is the starting balance and must be `<= capacity`; `0` forces a wait for the first refill. + +Aborts with `EZeroCapacity` if `capacity` is `0`. + +Aborts with `EZeroRefillAmount` if `refill_amount` is `0`. + +Aborts with `EZeroRefillInterval` if `refill_interval_ms` is `0`. + +Aborts with `EInitialAboveCapacity` if `initial_available > capacity`. + +Aborts with `EBucketAnchorInFuture` if `last_refill_ms > clock.timestamp_ms()`. + + + +Creates a fixed window limiter anchored at `window_start_ms`. Windows are `[window_start_ms + k·window_ms, window_start_ms + (k+1)·window_ms)` for `k >= 0`, and `available` resets to `capacity` when time crosses into a later window. The first window always has length exactly `window_ms` (anchor-based, not wall-clock-aligned). + +`initial_available` is the starting available units for the current window and must be `<= capacity`. For a greenfield limiter pass `clock.timestamp_ms()` as `window_start_ms`. + +Aborts with `EZeroCapacity` if `capacity` is `0`. + +Aborts with `EZeroWindow` if `window_ms` is `0`. + +Aborts with `EInitialAboveCapacity` if `initial_available > capacity`. + +Aborts with `EWindowAnchorInFuture` if `window_start_ms > clock.timestamp_ms()`. + + +A caller can spend the full quota at the end of one window and again at the start of the next, yielding a `2 * capacity` burst across the boundary. Pick `FixedWindow` when the per-window quota is the contract and that boundary burst is acceptable; otherwise use a `Bucket`. + + + + +Creates a cooldown limiter. Up to `capacity` units may be consumed (in any combination of per-call amounts) before the limiter gates. Once `available` reaches `0`, `cooldown_end_ms` is set to `now + cooldown_ms`; no further consume succeeds until `now >= cooldown_end_ms`, at which point `available` resets to `capacity`. + +`cooldown_end_ms <= now` means no gate is armed. The valid seed combinations are: `initial_available > 0` with `cooldown_end_ms <= now` (granted — up to `initial_available` units before the first arm); `initial_available == 0` with `cooldown_end_ms > now` (gated — reconstructing a limiter mid-throttle, or arming a delay in front of an action); and `initial_available == 0` with `cooldown_end_ms <= now` (released — projects to fully available on the next read or consume). + +Aborts with `EZeroCapacity` if `capacity` is `0`. + +Aborts with `EZeroCooldown` if `cooldown_ms` is `0`. + +Aborts with `EInitialAboveCapacity` if `initial_available > capacity`. + +Aborts with `ECooldownArmedWithTokens` if `initial_available > 0` and `cooldown_end_ms > now` (self-contradictory). + + + +Projects state forward (accrual / window rollover / gate release), then consumes `amount` if the projected headroom allows. All-or-nothing: on success the projected state is committed and `amount` deducted; on failure the persisted state is left untouched. Returns `true` if the consume succeeded, `false` if the limiter refused or if `amount` is `0`. + +Aborts with `ECooldownDeadlineOverflow` if consuming arms a `Cooldown` gate and `now + cooldown_ms` would overflow `u64`. + + + +Applies accrual, then consumes `amount` or aborts. The ergonomic wrapper for the common "rate-limit then act" path. + +Aborts with `EInvalidAmount` if `amount` is `0`. + +Aborts with `ERateLimited` if the limiter cannot satisfy the request. + +Aborts with `ECooldownDeadlineOverflow` if consuming arms a `Cooldown` gate and `now + cooldown_ms` would overflow `u64`. + + + +Read-only view of the units consumable right now, after projecting accrual / window reset / gate release on read (not committed). Variant-agnostic; never aborts. + +For `Bucket`, this is the tokens consumable now. For `FixedWindow`, the remaining headroom after any rollover. For `Cooldown`, `capacity` if the gate has elapsed, otherwise the stored `available`. + + +`try_consume(self.available(clock), clock)` returns `false` when `available()` returns `0` (a zero-unit consume is rejected). Guard with `if (n > 0) { self.try_consume(n, clock); }`. + + + + +Returns the capacity of the limiter, regardless of variant. Variant-agnostic; never aborts. + + + +Returns `true` only if the limiter is a `Bucket`. Variant-agnostic; never aborts. Use a predicate to branch before calling a variant-typed getter, which would otherwise abort `EWrongVariant` on a mismatch. + + + +Returns `true` only if the limiter is a `FixedWindow`. Variant-agnostic; never aborts. + + + +Returns `true` only if the limiter is a `Cooldown`. Variant-agnostic; never aborts. + + + +Returns the tokens credited per refill interval. + +Aborts with `EWrongVariant` if the limiter is not a `Bucket`. + + + +Returns the length of one refill interval, in milliseconds. + +Aborts with `EWrongVariant` if the limiter is not a `Bucket`. + + + +Returns the **projected** refill anchor at `now`, so it pairs coherently with `available(clock)`: read both, reconstruct, and the new limiter preserves refill phase. + +Aborts with `EWrongVariant` if the limiter is not a `Bucket`. + + + +Returns the length of one window, in milliseconds. + +Aborts with `EWrongVariant` if the limiter is not a `FixedWindow`. + + + +Returns the **projected** window anchor at `now`, so it pairs coherently with `available(clock)` for snapshotting. + +Aborts with `EWrongVariant` if the limiter is not a `FixedWindow`. + + + +Returns the wait between exhausting a batch and the next reset, in milliseconds. + +Aborts with `EWrongVariant` if the limiter is not a `Cooldown`. + + + +Returns the **stored** gate deadline as-is (a deadline does not evolve with time). Only meaningful when `available(clock) == 0`. + +Aborts with `EWrongVariant` if the limiter is not a `Cooldown`. + + +#### Errors [!toc] [#rate_limiter-Errors] + + +Raised by `consume_or_abort` when the limiter cannot satisfy the request. This is the only consume-time error; `try_consume` returns `false` instead of aborting. + + + +Raised by any constructor called with `capacity == 0`. + + + +Raised when a variant-typed getter is called on a limiter of a different variant. Guard with `is_bucket` / `is_fixed_window` / `is_cooldown` when the variant is not known statically. + + + +Raised by `consume_or_abort` when `amount == 0`. `try_consume` returns `false` instead of aborting. + + + +Raised by `new_bucket` when `refill_amount == 0`. + + + +Raised by `new_bucket` when `refill_interval_ms == 0`. + + + +Raised by `new_fixed_window` when `window_ms == 0`. + + + +Raised by `new_cooldown` when `cooldown_ms == 0`. + + + +Raised by any constructor when `initial_available > capacity`. + + + +Raised by `new_fixed_window` when `window_start_ms` is later than the current time. + + + +Raised by `new_cooldown` when `initial_available > 0` and `cooldown_end_ms > now`, which is a self-contradictory seed (units available while a gate is armed). + + + +Raised by `new_bucket` when `last_refill_ms` is later than the current time. + + + +Raised by `try_consume` / `consume_or_abort` when a consume drains a `Cooldown` batch and arming its gate would overflow `u64` (`now + cooldown_ms`). Any realistic `cooldown_ms` (seconds to years) stays well clear of this bound. + diff --git a/content/contracts-sui/1.x/guides/rate-limiter.mdx b/content/contracts-sui/1.x/guides/rate-limiter.mdx new file mode 100644 index 00000000..c6382b31 --- /dev/null +++ b/content/contracts-sui/1.x/guides/rate-limiter.mdx @@ -0,0 +1,455 @@ +--- +title: Rate Limiting +--- + + +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `rate_limiter` module provides an embeddable rate-limiting primitive for Sui Move. Unlike most building blocks, `RateLimiter` is **not** a Sui object: it is a `store + drop` value you embed as a field inside an object you already own, and call on the hot path. Its scope is exactly that parent object — there is no registry, no shared policy object, and no ID to track. + +```mermaid +flowchart LR + Parent["Your object (key)
id: UID
limiter: RateLimiter
... your fields ..."] + Parent -->|"&mut authorizes"| Consume["try_consume / consume_or_abort / available
(each takes &Clock)"] +``` + +Because the limiter lives inside your object, `&mut` to that object is what authorizes both consuming and reconfiguring it. Authorization is whatever guards that `&mut` — the module makes no access-control claim of its own. + +## What we will build + +This guide builds **one** end-to-end example — a token faucet — and runs it on testnet from start to finish. + +The faucet hands out a fixed-supply token (`RARE_COIN`), throttled two ways at once: + +- A **global** `FixedWindow` limiter on the shared `Faucet`: at most `100` coins may be claimed per rolling hour, *collectively across every claimer*. +- A **personal** `Bucket` limiter carried inside each holder's `ClaimCap`: it caps how much that specific holder can claim, refilling over time. + +A claim must satisfy **both** limiters. That makes the faucet a compact tour of the primitive's range in a single module: + +- embedding a limiter in a shared object (the global window), +- embedding a limiter in a per-user capability (the personal bucket), +- composing two limiters of *different variants* across two objects, +- running the rate-limit check *before* the side effect, and +- observing each limiter become the binding constraint in turn. + +```mermaid +flowchart LR + Caller["Claimer"] -->|"claim(amount)"| Cap["ClaimCap
personal_limiter: Bucket"] + Cap -->|"consume_or_abort (per-user)"| Faucet["Faucet (shared)
global_limiter: FixedWindow"] + Faucet -->|"consume_or_abort (global)"| Funds["split balance"] +``` + +## Prerequisites + +To follow the on-chain walkthrough you need: + +- The [Sui CLI](https://docs.sui.io/references/cli) installed, with an address configured for **testnet** and funded from the [faucet](https://docs.sui.io/guides/developer/getting-started/get-coins). +- [`jq`](https://jqlang.org/) for pulling object IDs out of JSON output. + +It also helps to be familiar with Sui Move shared objects, capabilities, the shared `Clock` at `0x6`, and programmable transaction blocks (PTBs). + +## Add the dependency + +Add the utilities package to `Move.toml`: + +```toml +[dependencies] +openzeppelin_utils = { r.mvr = "@openzeppelin-move/utils" } +``` + +Then import the module from your Move code: + +```move +use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; +``` + +## The token to dispense + +A faucet needs something to hand out. We use a minimal fixed-supply coin, `RARE_COIN`, that mints its entire supply to the publisher on creation. + +```move title="sources/rare_coin.move" +module rate_limiter_example::rare_coin; + +public struct RARE_COIN has drop {} + +fun init(witness: RARE_COIN, ctx: &mut TxContext) { + let (mut currency, mut treasury_cap) = sui::coin_registry::new_currency_with_otw( + witness, + 0, + "RARE_COIN", + "Rare Coin", + "", + "", + ctx, + ); + + let coins = treasury_cap.mint(10_000, ctx); + currency.make_supply_fixed(treasury_cap); + currency.finalize_and_delete_metadata_cap(ctx); + transfer::public_transfer(coins, ctx.sender()); +} +``` + +## The faucet module + +The faucet itself is one module. The shared `Faucet` holds the funds and the global window; each `ClaimCap` holds its owner's personal bucket. `claim` charges the personal bucket, then the global window, and only then splits the coins — so a refusal on either limiter never touches the balance. + +```move title="sources/faucet.move" +module rate_limiter_example::faucet; + +use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; +use sui::balance::Balance; +use sui::clock::Clock; +use sui::coin::{Self, Coin}; +use rate_limiter_example::rare_coin::RARE_COIN; + +// === Constants === + +const HOUR: u64 = 60 * 60 * 1000; + +const HOURLY_LIMIT: u64 = 100; + +// === Structs === + +/// Shared faucet with one global claim limiter shared by every holder. +public struct Faucet has key { + id: UID, + balance: Balance, + global_limiter: RateLimiter, +} + +/// Handed to whoever funds the faucet; authorizes issuing `ClaimCap`s with per-holder limits. +public struct AdminCap has key, store { id: UID } + +/// Presented on every claim. Carries a personal bucket limiter that caps this holder. +public struct ClaimCap has key, store { + id: UID, + personal_limiter: RateLimiter, +} + +// === Public Functions === + +/// Share a faucet whose global budget is 100 coins per hour, and return an `AdminCap` +/// for issuing claim capabilities. +public fun new(funds: Coin, clock: &Clock, ctx: &mut TxContext): AdminCap { + // FixedWindow: a hard quota of 100 per rolling hour, anchored at now, starting full. + let global_limiter = rate_limiter::new_fixed_window( + HOURLY_LIMIT, // capacity (units per window) + HOUR, // window_ms (1 hour) + clock.timestamp_ms(), // window_start_ms (anchor at now) + HOURLY_LIMIT, // initial_available (start full) + clock, + ); + + transfer::share_object(Faucet { + id: object::new(ctx), + balance: funds.into_balance(), + global_limiter, + }); + AdminCap { id: object::new(ctx) } +} + +/// Issue a claim capability with a personal token-bucket limit: at most `per_user_cap` +/// outstanding, refilling `refill_amount` every `refill_interval_ms`, starting full. +public fun issue_claim_cap( + _: &AdminCap, + recipient: address, + per_user_cap: u64, + refill_amount: u64, + refill_interval_ms: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + let personal_limiter = rate_limiter::new_bucket( + per_user_cap, // capacity + refill_amount, + refill_interval_ms, + clock.timestamp_ms(), // last_refill_ms (anchor at now) + per_user_cap, // initial_available (start full) + clock, + ); + transfer::transfer(ClaimCap { id: object::new(ctx), personal_limiter }, recipient); +} + +/// Claim `amount`, charging the holder's personal bucket first, then the global window. +/// Both checks run before the balance split, so a denial on either never touches `balance`. +public fun claim( + self: &mut Faucet, + cap: &mut ClaimCap, + amount: u64, + clock: &Clock, + ctx: &mut TxContext, +): Coin { + cap.personal_limiter.consume_or_abort(amount, clock); // per-user cap + self.global_limiter.consume_or_abort(amount, clock); // global cap + coin::from_balance(self.balance.split(amount), ctx) +} + +// === View helpers === + +/// This holder's currently-available personal allowance (projects refill on read). +public fun personal_allowance(cap: &ClaimCap, clock: &Clock): u64 { + cap.personal_limiter.available(clock) +} + +/// The faucet's currently-available global allowance (projects window rollover on read). +public fun global_allowance(self: &Faucet, clock: &Clock): u64 { + self.global_limiter.available(clock) +} +``` + +A few things worth highlighting: + +- **Two variants, one API.** The global limiter is a `FixedWindow` and the personal limiter is a `Bucket`, yet both are consumed with the same `consume_or_abort(amount, clock)` call. Only the constructor differs. +- **Both limiters are independent.** The personal bucket refills smoothly over time; the global window resets on its own hourly boundary. A holder is bounded by whichever is tighter at the moment of the call. +- **Check before effect.** Both `consume_or_abort` calls run before `balance.split`, so a denied claim aborts `ERateLimited` and the whole transaction reverts without moving any funds. +- **The cap governs *who*, the limiter governs *how much*.** Holding a `ClaimCap` is what lets you call `claim` at all; the limiters bound the throughput once you can. + +## Deploy and try it on testnet + +The walkthrough below takes the faucet from a fresh publish to exercising *both* rate limits. Make sure your CLI is pointed at testnet first: + +```bash +sui client switch --env testnet +``` + +### 1. Publish the package + +Publishing creates `RARE_COIN`, mints its full supply (`10_000`) to you, and installs the faucet module. We capture the output as JSON and pull out the IDs we need. + +```bash +ADDR=$(sui client active-address) + +sui client publish --build-env testnet --gas-budget 200000000 --json > /tmp/pub.json +``` + +```bash +PKG=$(jq -r '.objectChanges[] | select(.type=="published") | .packageId' /tmp/pub.json) + +COIN=$(jq -r '.objectChanges[] + | select(.type=="created" and (.objectType|test("coin::Coin<.*::rare_coin::RARE_COIN>"))) + | .objectId' /tmp/pub.json) + +CURRENCY=$(jq -r '.objectChanges[] + | select(.type=="created" and (.objectType|test("coin_registry::Currency"))) + | .objectId' /tmp/pub.json) +``` + +### 2. Register the coin currency + +Finalize the `RARE_COIN` registration in the on-chain coin registry (`0xc`): + +```bash +sui client ptb --move-call 0x2::coin_registry::finalize_registration "<$PKG::rare_coin::RARE_COIN>" @0xc @$CURRENCY +``` + +Confirm the full supply landed with you: + +```bash +sui client balance --coin-type $PKG::rare_coin::RARE_COIN +``` + +``` +╭──────────────────────────────────────────────────╮ +│ Balance of coins owned by this address │ +├──────────────────────────────────────────────────┤ +│ ╭──────────────────────────────────────────────╮ │ +│ │ coin balance (raw) balance │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ Rare Coin 10000 10.00K RARE_COIN │ │ +│ ╰──────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────╯ +``` + +### 3. Create and fund the faucet + +`new` takes the coin to dispense, shares the `Faucet` (carrying the global limiter), and *returns* an `AdminCap`. We capture the returned cap and transfer it to ourselves in the same PTB. The shared `Clock` is the object at `0x6`. + +```bash +sui client ptb \ + --move-call $PKG::faucet::new @$COIN @0x6 \ + --assign admin_cap \ + --transfer-objects "[admin_cap]" @$ADDR \ + --gas-budget 100000000 --json > /tmp/new.json +``` + +Grab the shared `Faucet` and the owned `AdminCap` that transaction created: + +```bash +FAUCET=$(jq -r '.objectChanges[] | select(.type=="created" and (.objectType|test("::faucet::Faucet"))) | .objectId' /tmp/new.json) + +ADMIN=$(jq -r '.objectChanges[] | select(.type=="created" and (.objectType|test("::faucet::AdminCap"))) | .objectId' /tmp/new.json) +``` + +The faucet now holds the full balance, and its global window starts at the full `100`/hour allowance: + +```bash +sui client object $FAUCET --json | jq -r '.content.balance' +``` + +``` +10000 +``` + +```bash +sui client object $FAUCET --json | jq -r '.content.global_limiter.available' +``` + +``` +100 +``` + +### 4. Issue yourself a claim capability + +Issue a `ClaimCap` with a personal token-bucket cap of `10` coins, refilling `10` every hour, starting full: + +```bash +CAP=$(sui client ptb \ + --move-call $PKG::faucet::issue_claim_cap @$ADMIN @$ADDR 10 10 3600000 @0x6 \ + --gas-budget 100000000 \ + --json | jq -r '.objectChanges[] | select(.type=="created" and (.objectType|test("::faucet::ClaimCap"))) | .objectId') +``` + +The personal bucket starts full at `10`: + +```bash +sui client object $CAP --json | jq -r '.content.personal_limiter.available' +``` + +``` +10 +``` + +### 5. Claim — and watch both limiters get charged + +Claim `5` `RARE_COIN`, presenting the `Faucet` and your `ClaimCap`, and forward the coin to yourself in the same PTB: + +```bash +sui client ptb \ + --move-call $PKG::faucet::claim @$FAUCET @$CAP 5 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ADDR \ + --gas-budget 100000000 +``` + +``` +... +│ Status: Success │ +... +╭────────────────────────────────────────────────────────────────────╮ +│ Balance Changes │ +├────────────────────────────────────────────────────────────────────┤ +│ CoinType: ...::rare_coin::RARE_COIN Amount: 5 │ +╰────────────────────────────────────────────────────────────────────╯ +``` + +The faucet balance dropped by `5`, and *both* limiters were charged — the personal bucket `10 → 5` and the global window `100 → 95`: + +```bash +sui client object $FAUCET --json | jq -r '.content.balance' +``` + +``` +9995 +``` + +```bash +sui client object $CAP --json | jq -r '.content.personal_limiter.available' +``` + +``` +5 +``` + +```bash +sui client object $FAUCET --json | jq -r '.content.global_limiter.available' +``` + +``` +95 +``` + +### 6. The personal bucket binds + +Try to claim `6` with the same cap. The global window still has `95` left, but the **personal** bucket only has `5`, so the claim aborts `ERateLimited` and the whole PTB reverts: + +```bash +sui client ptb \ + --move-call $PKG::faucet::claim @$FAUCET @$CAP 6 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ADDR \ + --gas-budget 100000000 +``` + +``` +Error executing transaction '...': 1st command aborted within function +'...::rate_limiter::consume_or_abort' ... Aborted with error code 0 +--'ERateLimited' -- 'Rate limited' +``` + +### 7. The global window binds + +Now show the *other* limiter becoming the binding constraint. Issue a second cap with a generous personal cap of `1000`, so the personal bucket can never bind for this holder: + +```bash +CAP2=$(sui client ptb \ + --move-call $PKG::faucet::issue_claim_cap @$ADMIN @$ADDR 1000 1000 3600000 @0x6 \ + --gas-budget 100000000 \ + --json | jq -r '.objectChanges[] | select(.type=="created" and (.objectType|test("::faucet::ClaimCap"))) | .objectId') +``` + +The personal bucket has `1000` available, but the global window only has `95` left: + +```bash +sui client object $CAP2 --json | jq -r '.content.personal_limiter.available' +``` + +``` +1000 +``` + +```bash +sui client object $FAUCET --json | jq -r '.content.global_limiter.available' +``` + +``` +95 +``` + +Try to claim `96` with the generous cap. The personal bucket has room, but the **global** window only allows `95`, so it aborts — the global cap binds across all holders: + +```bash +sui client ptb \ + --move-call $PKG::faucet::claim @$FAUCET @$CAP2 96 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ADDR \ + --gas-budget 100000000 +``` + +``` +Error executing transaction '...': 1st command aborted within function +'...::rate_limiter::consume_or_abort' ... Aborted with error code 0 +--'ERateLimited' -- 'Rate limited' +``` + +And because both checks run before the balance split, the failed claims never moved any funds — the faucet balance is still `9995`: + +```bash +sui client object $FAUCET --json | jq -r '.content.balance' +``` + +``` +9995 +``` + +## What this showed + +In one module, the faucet exercised the core integration pattern end to end: + +- A `RateLimiter` is embedded as a field of an object you own — the shared `Faucet` for the global window, and each `ClaimCap` for a per-holder bucket — never as a standalone object. +- Different variants (`FixedWindow` and `Bucket`) share one `consume_or_abort` / `available` API, and compose cleanly across objects within a single PTB. +- Running the check before the side effect makes every denial all-or-nothing: a refused claim reverts the transaction without touching funds. +- Authorization (`ClaimCap`) and throughput (the limiters) are separate concerns — the module decides *how much*, the capability decides *who*. + +To change a limiter's configuration after deployment, there are no in-place setters: snapshot the current values through the getters, build a fresh `RateLimiter`, and overwrite the field, gating that entry function with the same `AdminCap`. See the [Rate Limiter module guide](/contracts-sui/1.x/rate-limiter) for key concepts and the common-mistakes reference, and the [Utilities API reference](/contracts-sui/1.x/api/utils) for function signatures, parameters, and errors. diff --git a/content/contracts-sui/1.x/index.mdx b/content/contracts-sui/1.x/index.mdx index 2e752da1..4747371b 100644 --- a/content/contracts-sui/1.x/index.mdx +++ b/content/contracts-sui/1.x/index.mdx @@ -2,11 +2,12 @@ title: Contracts for Sui 1.x --- -**OpenZeppelin Contracts for Sui v1.x** ships three core packages: +**OpenZeppelin Contracts for Sui v1.x** ships four core packages: - `openzeppelin_math` for deterministic integer arithmetic, configurable rounding, and decimal scaling. - `openzeppelin_fp_math` for 9-decimal fixed-point arithmetic (`UD30x9`, `SD29x9`) backed by `u128`. - `openzeppelin_access` for role-based authorization and ownership-transfer wrappers around privileged `key + store` objects. +- `openzeppelin_utils` for embeddable building blocks; its first module, `rate_limiter`, provides multi-strategy rate limiting. ## Quickstart @@ -29,6 +30,7 @@ cd my_sui_app mvr add @openzeppelin-move/access mvr add @openzeppelin-move/integer-math mvr add @openzeppelin-move/fixed-point-math +mvr add @openzeppelin-move/utils ``` You only need the dependencies your app actually uses. Add what you need and drop the others. @@ -42,6 +44,7 @@ You only need the dependencies your app actually uses. Add what you need and dro openzeppelin_access = { r.mvr = "@openzeppelin-move/access" } openzeppelin_math = { r.mvr = "@openzeppelin-move/integer-math" } openzeppelin_fp_math = { r.mvr = "@openzeppelin-move/fixed-point-math" } +openzeppelin_utils = { r.mvr = "@openzeppelin-move/utils" } ``` ### 4. Add a Minimal Module @@ -79,12 +82,14 @@ sui move test - Need role-based authorization for privileged functions or controlled transfer of admin/treasury/upgrade capabilities? Use [Access](/contracts-sui/1.x/access). - Need fractional values like prices, fees, rates, or signed deltas? Use [Fixed-Point Math](/contracts-sui/1.x/fixed-point). - Need integer arithmetic with safe overflow and explicit rounding? Use [Integer Math](/contracts-sui/1.x/math). +- Need to throttle withdrawals, meter per-user budgets, gate action reuse, or delay an action? Use [Rate Limiter](/contracts-sui/1.x/rate-limiter). The packages compose. A typical protocol module imports `openzeppelin_math` for share math, `openzeppelin_fp_math` for rate and fee math, and `openzeppelin_access` for the admin capability that governs both. ## Next steps -- Package guides: [Integer Math](/contracts-sui/1.x/math), [Fixed-Point Math](/contracts-sui/1.x/fixed-point), [Access](/contracts-sui/1.x/access). +- Package guides: [Integer Math](/contracts-sui/1.x/math), [Fixed-Point Math](/contracts-sui/1.x/fixed-point), [Access](/contracts-sui/1.x/access), [Utilities](/contracts-sui/1.x/utils). - Access modules: [RBAC](/contracts-sui/1.x/access-control), [Two-Step Transfer](/contracts-sui/1.x/two-step-transfer), [Delayed Transfer](/contracts-sui/1.x/delayed-transfer). -- Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control). -- API reference: [Integer Math](/contracts-sui/1.x/api/math), [Fixed-Point Math](/contracts-sui/1.x/api/fixed-point), [Access](/contracts-sui/1.x/api/access). +- Utilities modules: [Rate Limiter](/contracts-sui/1.x/rate-limiter). +- Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control), [Rate Limiting](/contracts-sui/1.x/guides/rate-limiter). +- API reference: [Integer Math](/contracts-sui/1.x/api/math), [Fixed-Point Math](/contracts-sui/1.x/api/fixed-point), [Access](/contracts-sui/1.x/api/access), [Utilities](/contracts-sui/1.x/api/utils). diff --git a/content/contracts-sui/1.x/rate-limiter.mdx b/content/contracts-sui/1.x/rate-limiter.mdx new file mode 100644 index 00000000..ff43e270 --- /dev/null +++ b/content/contracts-sui/1.x/rate-limiter.mdx @@ -0,0 +1,120 @@ +--- +title: Rate Limiter +--- + + +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `rate_limiter` module provides an embeddable, multi-strategy rate-limiting primitive for Sui Move. `RateLimiter` is a plain `store + drop` value — **not** a Sui object. You embed it as a field inside an object you already own (a vault, a capability, a per-player record) and call one function on the hot path. There is no registry, no shared policy object, no admin cap, and no separate ID to track: the limiter's scope is whatever object it lives inside. + +Rate limiting is otherwise reimplemented ad hoc by every protocol that needs it — withdrawal throttles, daily caps, action cooldowns. `rate_limiter` generalizes the proven rate limiting math into a reusable primitive and exposes three strategies (bucket, fixed window and cooldown) that share a single API. + +## Use cases + +Use `rate_limiter` when your protocol needs: + +- A throughput ceiling on a shared resource, such as withdrawals from a vault or flow through a bridge pathway. +- Independent per-user or per-object budgets that refill over time. +- A hard per-window quota, such as "100 mints per hour". +- A cooldown that gates the reuse of an action after a burst. +- A delay that must elapse before an action — like a claim or an unstake — can execute. +- A regenerating resource in non-financial logic, such as health, mana, stamina, or energy. + +## Import + +```move +use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; +``` + +## Strategies + +One `RateLimiter` enum, three strategies. The variant is chosen at construction; the consume and inspect calls are identical across all three, so only the constructor differs. + +| Variant | Behavior | When to pick it | +|---|---|---| +| `Bucket` | Holds up to `capacity` tokens, refilling `refill_amount` every `refill_interval_ms`, capped at `capacity`. | Smooth, sustained throughput with bursts up to `capacity` (vaults, throughput controls, mana/stamina regen). | +| `FixedWindow` | Up to `capacity` units per fixed window of `window_ms`, anchored at creation; resets to `capacity` at each boundary. | Hard per-window quotas, e.g. "100 mints per hour". | +| `Cooldown` | Up to `capacity` units per batch, then gated for `cooldown_ms` before the next full batch. | Burst-then-pause patterns, and one-shot delays in front of an action. | + +The library owns only the variant layout and the accrual / window / cooldown math. You own the enclosing object, the authorization model, and all reconfiguration semantics. + +## Quickstart + +Embed one `RateLimiter` field and check it on the hot path. Here a shared vault throttles all withdrawals collectively through a single token bucket: + +```move +module my_protocol::vault; + +use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; +use sui::balance::{Self, Balance}; +use sui::clock::Clock; +use sui::coin::{Self, Coin}; +use sui::sui::SUI; + +public struct Vault has key { + id: UID, + limiter: RateLimiter, // the only library type the integrator sees + funds: Balance, +} + +public fun create(clock: &Clock, ctx: &mut TxContext) { + // Bucket: at most 1_000 SUI outstanding, refilling 100 every 6 s, starting full. + let limiter = rate_limiter::new_bucket(1_000, 100, 6_000, clock.timestamp_ms(), 1_000, clock); + transfer::share_object(Vault { id: object::new(ctx), limiter, funds: balance::zero() }); +} + +public fun withdraw(self: &mut Vault, amount: u64, clock: &Clock, ctx: &mut TxContext): Coin { + self.limiter.consume_or_abort(amount, clock); // rate-limit check, aborts ERateLimited + coin::from_balance(self.funds.split(amount), ctx) // protocol action +} +``` + +The limiter check runs *before* the side effect, so a denied withdrawal never touches `funds`. + +## Key concepts + +- **Embedded value, not an object.** `RateLimiter` has `store + drop` and no `key`/`UID`. It can only exist as a field of a parent value you own, and its scope is that parent — no global state, no contention, no registry to index. Withholding `copy` is what prevents over-issuance: a duplicable limiter would multiply your configured capacity by N. + +- **Project-on-read.** `available(clock)` returns the units consumable *right now*, projecting any pending refill, window rollover, or cooldown release without mutating. `try_consume` commits that projection only on success. + +- **All-or-nothing consume.** `try_consume` either consumes `amount` and commits the time projection, or returns `false` and writes nothing. There is no "denied but charged", so you can safely probe with `try_consume` without skewing state. `consume_or_abort` is the ergonomic wrapper that turns a refusal into an `ERateLimited` abort. + +- **Reconfigure by reconstruction.** There are no in-place `reconfigure_*` functions. To change config or state, read the current values through the getters, build a fresh `RateLimiter`, and overwrite the field. Every policy (preserve anchor, re-anchor, full reset, proportional carry, freeze an in-flight gate) is expressible in your own code; the library validates only structural validity on construction. + +- **Authorization is yours.** Whoever holds `&mut` to the field may consume and reconfigure. Gate your entry functions with whatever model fits — a capability, [`openzeppelin_access`](/contracts-sui/1.x/access), governance, or a multisig. The module makes no access-control claim. + +## Common mistakes + +| Mistake | What happens | How to fix | +|---|---|---| +| `try_consume(rl.available(clock), clock)` when empty | Returns `false` (`available()` is `0`, and a zero-unit consume is rejected) — surprising if you expect "consume all available" to succeed as a no-op | Guard: `let n = rl.available(clock); if (n > 0) { rl.try_consume(n, clock); }` | +| Calling `consume_or_abort` with `amount == 0` | Aborts `EInvalidAmount` (`try_consume` returns `false` instead) | Treat zero-unit work as a no-op in your own code; never pass `0`. | +| Reading `cooldown_end_ms()` while `available > 0` | Returns a stale value (the gate is only consulted when `available == 0`) | Only interpret `cooldown_end_ms()` when `available(clock) == 0`. | +| Seeding `new_cooldown` with `initial_available > 0` and `cooldown_end_ms > now` | Aborts `ECooldownArmedWithTokens` | Use one of the valid seed combinations (see the [API reference](/contracts-sui/1.x/api/utils)). | +| Passing a future `last_refill_ms` / `window_start_ms` | Aborts `EBucketAnchorInFuture` / `EWindowAnchorInFuture` | Pass `clock.timestamp_ms()` (or an earlier value). | +| Expecting `FixedWindow` to prevent boundary bursts | A caller can spend `capacity` at the end of one window and `capacity` at the start of the next (a `2 * capacity` burst) | Use `Bucket` if a smooth ceiling matters; `FixedWindow` only guarantees the per-window cap. | +| Assuming the limiter authorizes callers | It does not — anyone with `&mut` to the field can consume and reconfigure | Gate your entry functions with a capability, `openzeppelin_access`, governance, or a multisig. | + +## FAQ + +**Do I need to register or track the limiter anywhere?** +No. It is a field of your object, not a separate object. There is no registry, no ID, nothing for a front end or indexer to track per environment. + +**How do I disable a limiter (pause)?** +The module has no `enabled` toggle by design. Implement pause in your own object (for example, a `bool` checked before the consume), or reconstruct with a configuration that denies. + +**Does the library emit events?** +No. Observability is left to you, since you hold the context an event needs (which user, which object). Emit your own event after a successful consume if you want indexing. + +**Can I probe the limiter without affecting it?** +Yes. You can use the `available(clock)` getter (projects pending refills). Also, a failed `try_consume` (returns `false`) is observably a no-op across all three variants — no anchor advance, no balance change, no gate re-arm. + +**How do I inspect a limiter whose variant I don't know (for example, a `Table` mixing variants)?** +Branch on `is_bucket()` / `is_fixed_window()` / `is_cooldown()` before calling a variant-typed getter (`refill_amount`, `window_ms`, `cooldown_ms`, ...). Those getters abort `EWrongVariant` on a mismatch, and Move cannot recover from an abort. `capacity()` and `available(clock)` are variant-agnostic and need no guard. + +## Learn more + +For end-to-end examples — a globally throttled faucet, mixed global and per-user limits, many limiters inside one type, and a cooldown that delays an action — with publishing and PTB walkthroughs, see the [Rate Limiting guide](/contracts-sui/1.x/guides/rate-limiter). + +For function-level signatures, parameters, and errors, see the [Utilities API reference](/contracts-sui/1.x/api/utils). diff --git a/content/contracts-sui/1.x/utils.mdx b/content/contracts-sui/1.x/utils.mdx new file mode 100644 index 00000000..472ff9ac --- /dev/null +++ b/content/contracts-sui/1.x/utils.mdx @@ -0,0 +1,39 @@ +--- +title: Utilities +--- + + +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `openzeppelin_utils` package provides reusable, embeddable building blocks for Sui Move protocols. Its modules are plain values you compose into objects you already own, rather than standalone services with their own lifecycle, registry, or admin surface. + +## Usage + +Add the dependency in `Move.toml`: + +```toml +[dependencies] +openzeppelin_utils = { r.mvr = "@openzeppelin-move/utils" } +``` + +Import the module you want to use: + +```move +use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; +``` + +## Modules + + + + Embeddable, multi-strategy rate limiting — token bucket, fixed window, and cooldown — for throttling withdrawals, metering per-user budgets, gating action reuse, and adding delays before an action can execute. + + + +## Next steps + +- [Rate Limiter](/contracts-sui/1.x/rate-limiter) for the module guide and key concepts. +- [Rate Limiting guide](/contracts-sui/1.x/guides/rate-limiter) for end-to-end examples and PTB walkthroughs. +- [Utilities API reference](/contracts-sui/1.x/api/utils) for function signatures and errors. +- [Access](/contracts-sui/1.x/access) for role-based authorization to gate the functions that consume a limiter. diff --git a/src/navigation/sui/current.json b/src/navigation/sui/current.json index 4d17cca1..ca24effe 100644 --- a/src/navigation/sui/current.json +++ b/src/navigation/sui/current.json @@ -21,6 +21,11 @@ "type": "page", "name": "Role Based Access Control", "url": "/contracts-sui/1.x/guides/access-control" + }, + { + "type": "page", + "name": "Rate Limiting", + "url": "/contracts-sui/1.x/guides/rate-limiter" } ] }, @@ -64,6 +69,22 @@ "url": "/contracts-sui/1.x/delayed-transfer" } ] + }, + { + "type": "folder", + "name": "Utilities", + "index": { + "type": "page", + "name": "Overview", + "url": "/contracts-sui/1.x/utils" + }, + "children": [ + { + "type": "page", + "name": "Rate Limiter", + "url": "/contracts-sui/1.x/rate-limiter" + } + ] } ] }, @@ -85,6 +106,11 @@ "type": "page", "name": "Access", "url": "/contracts-sui/1.x/api/access" + }, + { + "type": "page", + "name": "Utilities", + "url": "/contracts-sui/1.x/api/utils" } ] }