From 640c6db66978f3c0baa41a26ac357f0f8162341e Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Thu, 4 Jun 2026 13:34:28 +0200 Subject: [PATCH 1/5] add utils + rate limiter docs --- content/contracts-sui/1.x/api/utils.mdx | 323 +++++++++ .../contracts-sui/1.x/guides/rate-limiter.mdx | 677 ++++++++++++++++++ content/contracts-sui/1.x/index.mdx | 13 +- content/contracts-sui/1.x/rate-limiter.mdx | 120 ++++ content/contracts-sui/1.x/utils.mdx | 41 ++ src/navigation/sui/current.json | 26 + 6 files changed, 1196 insertions(+), 4 deletions(-) create mode 100644 content/contracts-sui/1.x/api/utils.mdx create mode 100644 content/contracts-sui/1.x/guides/rate-limiter.mdx create mode 100644 content/contracts-sui/1.x/rate-limiter.mdx create mode 100644 content/contracts-sui/1.x/utils.mdx 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..4b972c91 --- /dev/null +++ b/content/contracts-sui/1.x/api/utils.mdx @@ -0,0 +1,323 @@ +--- +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, initial_available, last_refill_ms, 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, initial_available, cooldown_end_ms, 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) + +#### 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`. + +`initial_available` is the starting balance and must be `<= capacity`; `0` forces a wait for the first refill. `last_refill_ms` anchors the refill schedule; for a greenfield limiter pass `clock.timestamp_ms()`. + +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. + +Aborts with `EInvalidAmount` if `amount` is `0`. + + + +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. + + + +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)` aborts `EInvalidAmount` when `available()` returns `0`. 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 `try_consume` or `consume_or_abort` when `amount == 0`. + + + +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. + 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..d7728db4 --- /dev/null +++ b/content/contracts-sui/1.x/guides/rate-limiter.mdx @@ -0,0 +1,677 @@ +--- +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. + +This guide builds four examples that showcase the range of the primitive: + +1. A globally throttled vault — one limiter shared by all withdrawers. +2. A vault that mixes the global limiter with a per-user limit carried in each capability. +3. A mage duel — many limiters of different variants inside one type, outside of DeFi. +4. A staking vault — a cooldown that adds a delay *before* an action can execute. + +## Prerequisites + +It helps to be familiar with Sui Move shared objects, capabilities, the shared `Clock` at `0x6`, and programmable transaction blocks (PTBs). The examples reference those concepts throughout. + +## 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}; +``` + +## Example 1: A globally throttled vault + +The simplest integration: one `RateLimiter` field on a shared `Vault` throttles *all* withdrawals collectively. Here it is a `FixedWindow` — a hard quota of 1,000 units per rolling hour. Withdrawing requires presenting a `WithdrawCap`, so the limiter governs throughput while the capability governs who may withdraw. + +```move +module my_protocol::simple_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; + +/// Shared vault holding pooled funds behind one global withdrawal limiter. +public struct Vault has key { + id: UID, + limiter: RateLimiter, + funds: Balance, +} + +/// Held by the publisher; authorizes issuing `WithdrawCap`s. +public struct AdminCap has key, store { id: UID } + +/// Presented on every withdrawal. Holding one marks a legitimate withdrawer. +public struct WithdrawCap has key, store { id: UID } + +/// On publish, hand the publisher an `AdminCap` for issuing withdraw capabilities. +fun init(ctx: &mut TxContext) { + transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender()); +} + +/// Share a vault whose withdrawals are capped at 1_000 SUI per hour, globally across all holders. +public fun create(clock: &Clock, ctx: &mut TxContext) { + // FixedWindow: a hard quota of 1_000 per rolling hour, anchored at now, starting full. + let limiter = rate_limiter::new_fixed_window( + 1_000, // capacity (units per window) + 3_600_000, // window_ms (1 hour) + clock.timestamp_ms(), // window_start_ms (anchor at now) + 1_000, // initial_available (start full) + clock, + ); + transfer::share_object(Vault { id: object::new(ctx), limiter, funds: balance::zero() }); +} + +/// Deposit funds into the vault (no rate limit on the way in). +public fun deposit(self: &mut Vault, payment: Coin) { + self.funds.join(payment.into_balance()); +} + +/// Issue a withdraw capability to `recipient`. Admin-gated so the global budget is only +/// shared among legitimate holders. +public fun issue_withdraw_cap(_: &AdminCap, recipient: address, ctx: &mut TxContext) { + transfer::transfer(WithdrawCap { id: object::new(ctx) }, recipient); +} + +/// Withdraw `amount`, charging the global limiter first. The rate-limit check runs before the +/// balance split, so a denied withdrawal never touches `funds`. +public fun withdraw( + self: &mut Vault, + _: &WithdrawCap, + amount: u64, + clock: &Clock, + ctx: &mut TxContext, +): Coin { + self.limiter.consume_or_abort(amount, clock); + coin::from_balance(self.funds.split(amount), ctx) +} + +/// How much the vault will allow to be withdrawn right now (projects window rollover on read). +public fun withdrawable_now(self: &Vault, clock: &Clock): u64 { + self.limiter.available(clock) +} +``` + +Because the `Vault` is shared, every caller draws from the same per-window budget. `consume_or_abort` is the ergonomic path: it aborts `ERateLimited` and the whole transaction reverts if the budget is exhausted. + +### Publish and call it + +Publish the package and record the IDs from the output: + +```bash +sui client publish +``` + +```bash +export PKG=0x... # published package +export ADMIN_CAP=0x... # AdminCap transferred to the publisher +export ME=0x... # your address +``` + +Create and fund the vault, then issue yourself a withdraw capability. The shared `Clock` is the object at `0x6`: + +```bash +sui client ptb \ + --move-call $PKG::simple_vault::create @0x6 +``` + +```bash +export VAULT=0x... # shared Vault from the create output + +sui client ptb \ + --split-coins gas "[5000]" \ + --assign funding \ + --move-call $PKG::simple_vault::deposit @$VAULT funding.0 \ + --move-call $PKG::simple_vault::issue_withdraw_cap @$ADMIN_CAP @$ME +``` + +Withdraw within the window and forward the coin in the same PTB: + +```bash +export WITHDRAW_CAP=0x... # WithdrawCap from the previous output + +sui client ptb \ + --move-call $PKG::simple_vault::withdraw @$VAULT @$WITHDRAW_CAP 250 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ME +``` + +A withdrawal that overruns the remaining per-window budget aborts `ERateLimited` (error code `0`) and the whole PTB reverts: + +```bash +sui client ptb \ + --move-call $PKG::simple_vault::withdraw @$VAULT @$WITHDRAW_CAP 1000 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ME +# Aborts: consume_or_abort -> ERateLimited. The window only had 750 left. +``` + +## Example 2: Mixing a global limiter with per-user limits + +The same global window as before, but now each `WithdrawCap` carries its *own* `RateLimiter` — a token bucket capping that specific holder. A withdrawal must satisfy **both**: the holder's personal bucket and the vault's global window. This shows two limiters of different variants composed across two objects. + +```mermaid +flowchart LR + Caller["Withdrawer"] -->|"withdraw(amount)"| Cap["WithdrawCap
personal_limiter: Bucket"] + Cap -->|"consume_or_abort (per-user)"| Vault["Vault
global_limiter: FixedWindow"] + Vault -->|"consume_or_abort (global)"| Funds["split funds"] +``` + +```move +module my_protocol::tiered_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; + +/// Shared vault with one global withdrawal limiter shared by every holder. +public struct Vault has key { + id: UID, + global_limiter: RateLimiter, + funds: Balance, +} + +/// Held by the publisher; authorizes issuing `WithdrawCap`s with per-holder limits. +public struct AdminCap has key, store { id: UID } + +/// Presented on every withdrawal. Carries a personal bucket limiter that caps this holder. +public struct WithdrawCap has key, store { + id: UID, + personal_limiter: RateLimiter, +} + +/// On publish, hand the publisher an `AdminCap` for issuing withdraw capabilities. +fun init(ctx: &mut TxContext) { + transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender()); +} + +/// Share a vault whose global budget is 1_000 SUI per hour. +public fun create(clock: &Clock, ctx: &mut TxContext) { + let global_limiter = rate_limiter::new_fixed_window( + 1_000, // capacity per window + 3_600_000, // 1 hour window + clock.timestamp_ms(), + 1_000, // start full + clock, + ); + transfer::share_object(Vault { id: object::new(ctx), global_limiter, funds: balance::zero() }); +} + +/// Deposit funds into the vault (no rate limit on the way in). +public fun deposit(self: &mut Vault, payment: Coin) { + self.funds.join(payment.into_balance()); +} + +/// Issue a withdraw 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_withdraw_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, + refill_amount, + refill_interval_ms, + per_user_cap, // start full + clock.timestamp_ms(), + clock, + ); + transfer::transfer(WithdrawCap { id: object::new(ctx), personal_limiter }, recipient); +} + +/// Withdraw `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 `funds`. +public fun withdraw( + self: &mut Vault, + cap: &mut WithdrawCap, + 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.funds.split(amount), ctx) +} + +/// This holder's currently-available personal allowance (projects refill on read). +public fun personal_allowance(cap: &WithdrawCap, clock: &Clock): u64 { + cap.personal_limiter.available(clock) +} + +/// The vault's currently-available global allowance (projects window rollover on read). +public fun global_allowance(self: &Vault, clock: &Clock): u64 { + self.global_limiter.available(clock) +} +``` + +The two limiters are fully independent: the personal bucket refills smoothly over time, while the global window resets on its own hourly boundary. A holder is bounded by whichever is tighter at the moment of the call. + +### Publish and call it + +```bash +sui client publish +``` + +```bash +export PKG=0x... +export ADMIN_CAP=0x... +export ME=0x... +``` + +Create and fund the vault, then issue a capability with a personal cap of 100, refilling 10 per second: + +```bash +sui client ptb \ + --move-call $PKG::tiered_vault::create @0x6 +``` + +```bash +export VAULT=0x... + +sui client ptb \ + --split-coins gas "[5000]" \ + --assign funding \ + --move-call $PKG::tiered_vault::deposit @$VAULT funding.0 \ + --move-call $PKG::tiered_vault::issue_withdraw_cap @$ADMIN_CAP @$ME 100 10 1000 @0x6 +``` + +A withdrawal of 80 fits under the personal cap of 100 (and the global 1,000): + +```bash +export WITHDRAW_CAP=0x... + +sui client ptb \ + --move-call $PKG::tiered_vault::withdraw @$VAULT @$WITHDRAW_CAP 80 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ME +``` + +A further withdrawal of 50 aborts `ERateLimited`: the personal bucket has only about 20 left, even though the global window still allows it. + +```bash +sui client ptb \ + --move-call $PKG::tiered_vault::withdraw @$VAULT @$WITHDRAW_CAP 50 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ME +# Aborts: the personal bucket is the binding limit here. +``` + +## Example 3: Many limiters in one type (a mage duel) + +Rate limiting is not only for DeFi. This example models a two-player duel where each `Mage` packs **four** limiters of mixed variants, one per game resource: + +- `health` (`Bucket`) — consuming is taking damage; refill is passive regeneration. +- `mana` (`Bucket`) — consuming is paying a spell's cost; refill is mana regeneration. +- `spell_a_cd` (`Cooldown`, capacity 1) — gated after a single cast. +- `spell_b_cd` (`Cooldown`, capacity 3) — gated after three casts. + +A mage casts one of two spells at its opponent. Each spell costs mana, deals damage, and burns one charge of that spell's cooldown. When a mage's health reaches zero it is defeated, and because `Mage` is a `store + drop` value, removing it from the duel destroys it. + +```move +module my_protocol::mage_duel; + +use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; +use sui::clock::Clock; +use std::string::String; + +#[error(code = 0)] +const EDuelOver: vector = b"The duel already has a winner"; + +const MAX_HEALTH: u64 = 100; +const MAX_MANA: u64 = 60; +const CD_MS: u64 = 10_000; // 10s cooldown after a spell is exhausted + +// Spell A: cheap, light hit, single charge before cooldown. +const SPELL_A_COST: u64 = 10; +const SPELL_A_DAMAGE: u64 = 15; +// Spell B: pricier, heavier hit, three charges before cooldown. +const SPELL_B_COST: u64 = 20; +const SPELL_B_DAMAGE: u64 = 30; + +/// A single combatant. A plain `store + drop` value — destroyed by being dropped on defeat. +public struct Mage has store, drop { + name: String, + health: RateLimiter, + mana: RateLimiter, + spell_a_cd: RateLimiter, + spell_b_cd: RateLimiter, +} + +/// Shared arena holding both mages by value. Index 0 and 1 identify the combatants. +public struct Duel has key { + id: UID, + mages: vector, +} + +/// Start a duel between two freshly-spawned, full-health mages. +public fun start(name_0: String, name_1: String, clock: &Clock, ctx: &mut TxContext) { + let mages = vector[new_mage(name_0, clock), new_mage(name_1, clock)]; + transfer::share_object(Duel { id: object::new(ctx), mages }); +} + +/// Mage at `attacker_idx` casts spell A at its opponent. +public fun cast_spell_a(self: &mut Duel, attacker_idx: u64, clock: &Clock) { + cast(self, attacker_idx, true, clock); +} + +/// Mage at `attacker_idx` casts spell B at its opponent. +public fun cast_spell_b(self: &mut Duel, attacker_idx: u64, clock: &Clock) { + cast(self, attacker_idx, false, clock); +} + +/// Resolve a cast: burn a cooldown charge, pay mana, then damage the opponent. Aborts (reverting +/// the whole transaction) if the spell is on cooldown or the caster cannot afford the mana. +fun cast(self: &mut Duel, attacker_idx: u64, is_spell_a: bool, clock: &Clock) { + assert!(self.mages.length() == 2, EDuelOver); + let target_idx = 1 - attacker_idx; + + // Attacker pays: a cooldown charge and the spell's mana cost. + let attacker = &mut self.mages[attacker_idx]; + if (is_spell_a) { + attacker.spell_a_cd.consume_or_abort(1, clock); + attacker.mana.consume_or_abort(SPELL_A_COST, clock); + } else { + attacker.spell_b_cd.consume_or_abort(1, clock); + attacker.mana.consume_or_abort(SPELL_B_COST, clock); + }; + + // Opponent takes damage. Clamp to remaining health so an overkill blow doesn't get rejected + // by the limiter's all-or-nothing consume; guard the zero case (a zero-unit consume aborts). + let damage = if (is_spell_a) SPELL_A_DAMAGE else SPELL_B_DAMAGE; + let target = &mut self.mages[target_idx]; + let dealt = damage.min(target.health.available(clock)); + if (dealt > 0) target.health.consume_or_abort(dealt, clock); + + // Defeated when no health remains: pull the mage out of the duel and let it drop. + if (target.health.available(clock) == 0) { + self.mages.remove(target_idx); + } +} + +/// A mage's current health (projects regeneration on read). `idx` must be a live combatant. +public fun health_of(self: &Duel, idx: u64, clock: &Clock): u64 { + self.mages[idx].health.available(clock) +} + +/// A mage's current mana (projects regeneration on read). `idx` must be a live combatant. +public fun mana_of(self: &Duel, idx: u64, clock: &Clock): u64 { + self.mages[idx].mana.available(clock) +} + +/// Whether the duel has ended (one mage has been defeated). +public fun is_over(self: &Duel): bool { + self.mages.length() < 2 +} + +/// Spawn a full-health, full-mana mage with both spells ready. +fun new_mage(name: String, clock: &Clock): Mage { + let now = clock.timestamp_ms(); + Mage { + name, + // Health as a bucket: starts full, regenerates 1 every 2s up to MAX_HEALTH. + health: rate_limiter::new_bucket(MAX_HEALTH, 1, 2_000, MAX_HEALTH, now, clock), + // Mana as a bucket: starts full, regenerates 5 every second up to MAX_MANA. + mana: rate_limiter::new_bucket(MAX_MANA, 5, 1_000, MAX_MANA, now, clock), + // Spell A: 1 charge, then a CD_MS cooldown. Starts ready (granted seed). + spell_a_cd: rate_limiter::new_cooldown(1, CD_MS, 1, 0, clock), + // Spell B: 3 charges, then a CD_MS cooldown. Starts ready (granted seed). + spell_b_cd: rate_limiter::new_cooldown(3, CD_MS, 3, 0, clock), + } +} +``` + +Two things are worth highlighting: + +- **Different cooldown capacities, same variant.** Spell A's `Cooldown` has capacity 1, so it gates after a single cast; spell B's has capacity 3, so it allows three casts before gating. The two cooldowns are independent — using one never touches the other. +- **The clamp before damage.** `try_consume` and `consume_or_abort` are all-or-nothing, so consuming more than `available` is rejected outright. For health, an overkill blow should still be lethal, so the code clamps damage to `available(clock)` and guards the zero case — the documented footgun of passing `0` to a consume. + +### Publish and call it + +```bash +sui client publish +``` + +```bash +export PKG=0x... +``` + +Start a duel, then have mage 0 cast spell A at mage 1: + +```bash +sui client ptb \ + --move-call $PKG::mage_duel::start "'Alice'" "'Bob'" @0x6 +``` + +```bash +export DUEL=0x... # shared Duel from the start output + +sui client ptb \ + --move-call $PKG::mage_duel::cast_spell_a @$DUEL 0 @0x6 +``` + +Spell A has a single charge, so casting it again before its cooldown elapses aborts `ERateLimited`: + +```bash +sui client ptb \ + --move-call $PKG::mage_duel::cast_spell_a @$DUEL 0 @0x6 +# Aborts: spell A is on cooldown after one use. +``` + +Spell B has its own, independent cooldown with three charges, so it is still castable: + +```bash +sui client ptb \ + --move-call $PKG::mage_duel::cast_spell_b @$DUEL 0 @0x6 +``` + +## Example 4: A cooldown that delays an action (staking) + +A `Cooldown` does not have to throttle a repeated action — seeded as an *armed* gate, it can sit in front of a single action and delay it. This staking vault has no yield; its only job is to enforce an unbonding delay. Initiating an unstake hands the user an `UnstakeTicket` whose cooldown gate is armed to release `unbond_delay_ms` in the future. The user can only `claim` once that gate elapses. + +```mermaid +sequenceDiagram + participant User + participant Vault as StakingVault + participant Ticket as UnstakeTicket + User->>Vault: initiate_unstake(receipt, clock) + Vault-->>User: UnstakeTicket (gate armed: now + delay) + User->>Ticket: claim(clock) before delay + Ticket-->>User: aborts ERateLimited + User->>Ticket: claim(clock) after delay + Ticket-->>User: Coin +``` + +```move +module my_protocol::staking_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; + +/// Shared staking pool. `unbond_delay_ms` is the cooldown applied before staked funds can be claimed. +public struct StakingVault has key { + id: UID, + funds: Balance, + unbond_delay_ms: u64, +} + +/// Proof of a staked position, held by the staker. +public struct StakeReceipt has key, store { + id: UID, + amount: u64, +} + +/// Issued when unstaking begins. Releases the reserved coins only after its cooldown gate elapses. +public struct UnstakeTicket has key, store { + id: UID, + coins: Balance, + gate: RateLimiter, +} + +/// Share a staking vault with the given unbonding delay. +public fun create(unbond_delay_ms: u64, ctx: &mut TxContext) { + transfer::share_object(StakingVault { + id: object::new(ctx), + funds: balance::zero(), + unbond_delay_ms, + }); +} + +/// Stake `payment`, returning a receipt for the staked amount. +public fun stake(self: &mut StakingVault, payment: Coin, ctx: &mut TxContext): StakeReceipt { + let amount = payment.value(); + self.funds.join(payment.into_balance()); + StakeReceipt { id: object::new(ctx), amount } +} + +/// Begin unstaking: burn the receipt, reserve the coins into a ticket, and arm a cooldown that +/// releases `unbond_delay_ms` from now. +public fun initiate_unstake( + self: &mut StakingVault, + receipt: StakeReceipt, + clock: &Clock, + ctx: &mut TxContext, +): UnstakeTicket { + let StakeReceipt { id, amount } = receipt; + id.delete(); + + let coins = self.funds.split(amount); + // Armed cooldown (gated seed): no charge available now, gate releases at now + delay. + let gate = rate_limiter::new_cooldown( + 1, // capacity: a single claim + self.unbond_delay_ms, // cooldown_ms + 0, // initial_available: nothing claimable yet + clock.timestamp_ms() + self.unbond_delay_ms, // cooldown_end_ms: release time + clock, + ); + UnstakeTicket { id: object::new(ctx), coins, gate } +} + +/// Claim unstaked coins. Consuming the gate aborts `ERateLimited` until the unbonding cooldown +/// has elapsed; once it has, the gate releases and the coins are returned. +public fun claim(ticket: UnstakeTicket, clock: &Clock, ctx: &mut TxContext): Coin { + let UnstakeTicket { id, coins, mut gate } = ticket; + gate.consume_or_abort(1, clock); + id.delete(); + coin::from_balance(coins, ctx) +} + +/// Whether the ticket's cooldown has elapsed and the coins can be claimed now. +public fun is_claimable(ticket: &UnstakeTicket, clock: &Clock): bool { + ticket.gate.available(clock) > 0 +} +``` + +The armed-gate seed (`initial_available == 0`, `cooldown_end_ms > now`) is the key. Until the release time, `available(clock)` projects to `0` and `consume_or_abort` aborts; once `now >= cooldown_end_ms`, the gate releases, `available(clock)` projects to `capacity` (here `1`), and the claim succeeds. The limiter holds no funds itself — it only decides *when* the surrounding `UnstakeTicket` may release them. + +### Publish and call it + +```bash +sui client publish +``` + +```bash +export PKG=0x... +export ME=0x... +``` + +Create a vault with a short unbonding delay (3 seconds, to observe the gate release), stake some SUI, and keep the receipt: + +```bash +sui client ptb \ + --move-call $PKG::staking_vault::create 3000 +``` + +```bash +export STAKING_VAULT=0x... + +sui client ptb \ + --split-coins gas "[1000]" \ + --assign stake_coin \ + --move-call $PKG::staking_vault::stake @$STAKING_VAULT stake_coin.0 \ + --assign receipt \ + --transfer-objects "[receipt]" @$ME +``` + +Initiate the unstake to receive a ticket with an armed cooldown: + +```bash +export RECEIPT=0x... + +sui client ptb \ + --move-call $PKG::staking_vault::initiate_unstake @$STAKING_VAULT @$RECEIPT @0x6 \ + --assign ticket \ + --transfer-objects "[ticket]" @$ME +``` + +Claiming immediately aborts `ERateLimited` — the unbonding cooldown has not elapsed: + +```bash +export UNSTAKE_TICKET=0x... + +sui client ptb \ + --move-call $PKG::staking_vault::claim @$UNSTAKE_TICKET @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ME +# Aborts: the gate is still armed. +``` + +After the delay elapses, the same call succeeds and returns the coins: + +```bash +sui client ptb \ + --move-call $PKG::staking_vault::claim @$UNSTAKE_TICKET @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ME +``` + +## Reconfiguration + +There are no in-place `reconfigure_*` functions. To change a limiter's configuration or runtime state, snapshot the current values through the getters, build a fresh `RateLimiter`, and overwrite the field. The anchor getters (`last_refill_ms`, `window_start_ms`) return the *projected* anchor at `now` and pair with `available(clock)`, so reading both and reconstructing preserves the limiter's phase. Gate any reconfigure entry function with whatever authorization you require — here, the same `AdminCap` used to issue capabilities. + +Rate guarantees hold *between* reconstructions, not *across* one: a backdated anchor pre-credits elapsed time on the first projection. Anchor at `clock.timestamp_ms()` unless you are deliberately preserving phase. + +## Operational checklist + +- Embed `RateLimiter` as a field of an object you own; never expect it to be a standalone object. +- Choose the variant deliberately: `Bucket` for a smooth ceiling with bursts, `FixedWindow` for hard per-window quotas, `Cooldown` for burst-then-pause or a one-shot delay. +- Run the limiter check *before* the side effect so a denied call has no partial effects. +- Use `consume_or_abort` to revert on refusal, or `try_consume` to branch on it. +- Guard against passing `0` to a consume; clamp with `available(clock)` and an `if (n > 0)` check where overkill is possible. +- Gate the `&mut` to your object with a capability, [`openzeppelin_access`](/contracts-sui/1.x/access), governance, or a multisig — the module authorizes no one. +- Reconfigure by reconstruction, anchoring at `clock.timestamp_ms()` unless you intend to preserve phase. + +For key concepts and the common-mistakes reference, see the [Rate Limiter module guide](/contracts-sui/1.x/rate-limiter). For function signatures, parameters, and errors, see the [Utilities API reference](/contracts-sui/1.x/api/utils). 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..93ac9c74 --- /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 token-bucket math into a reusable primitive and adds two strategies (fixed window and cooldown) that share one consume and inspect 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, 1_000, clock.timestamp_ms(), 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 | Aborts `EInvalidAmount` (`available()` is `0`, and a zero-unit consume is rejected) | Guard: `let n = rl.available(clock); if (n > 0) { rl.try_consume(n, clock); }` | +| Calling `try_consume` / `consume_or_abort` with `amount == 0` | Aborts `EInvalidAmount` | 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. 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. `available(clock)` is also non-mutating. + +**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 vault, 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..13ac9d3e --- /dev/null +++ b/content/contracts-sui/1.x/utils.mdx @@ -0,0 +1,41 @@ +--- +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. + +Rate Limiter is the first module in the package; more utilities will be added over time. + +## 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" } ] } From 704c4800be8653b4bde7249bc2f61ac6fe814383 Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Thu, 4 Jun 2026 13:44:18 +0200 Subject: [PATCH 2/5] remove mention of rate limiter as 'first module in utils' --- content/contracts-sui/1.x/utils.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/content/contracts-sui/1.x/utils.mdx b/content/contracts-sui/1.x/utils.mdx index 13ac9d3e..472ff9ac 100644 --- a/content/contracts-sui/1.x/utils.mdx +++ b/content/contracts-sui/1.x/utils.mdx @@ -8,8 +8,6 @@ The example code snippets used in this guide are experimental and have not been 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. -Rate Limiter is the first module in the package; more utilities will be added over time. - ## Usage Add the dependency in `Move.toml`: From 3196efb059612f994a1a9a1c8f3bd9dccdae08de Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Tue, 9 Jun 2026 17:17:24 +0200 Subject: [PATCH 3/5] update docs, including example and example CLI commands --- content/contracts-sui/1.x/api/utils.mdx | 25 +- .../contracts-sui/1.x/guides/rate-limiter.mdx | 733 ++++++------------ content/contracts-sui/1.x/rate-limiter.mdx | 6 +- 3 files changed, 275 insertions(+), 489 deletions(-) diff --git a/content/contracts-sui/1.x/api/utils.mdx b/content/contracts-sui/1.x/api/utils.mdx index 4b972c91..69e5333c 100644 --- a/content/contracts-sui/1.x/api/utils.mdx +++ b/content/contracts-sui/1.x/api/utils.mdx @@ -25,9 +25,9 @@ Types Functions -- [`new_bucket(capacity, refill_amount, refill_interval_ms, initial_available, last_refill_ms, clock)`](#rate_limiter-new_bucket) +- [`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, initial_available, cooldown_end_ms, clock)`](#rate_limiter-new_cooldown) +- [`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) @@ -57,6 +57,7 @@ Errors - [`EWindowAnchorInFuture`](#rate_limiter-EWindowAnchorInFuture) - [`ECooldownArmedWithTokens`](#rate_limiter-ECooldownArmedWithTokens) - [`EBucketAnchorInFuture`](#rate_limiter-EBucketAnchorInFuture) +- [`ECooldownDeadlineOverflow`](#rate_limiter-ECooldownDeadlineOverflow) #### Types [!toc] [#rate_limiter-Types] @@ -75,13 +76,13 @@ The variant fields are not directly accessible from outside the module; read the #### 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`. -`initial_available` is the starting balance and must be `<= capacity`; `0` forces a wait for the first refill. `last_refill_ms` anchors the refill schedule; for a greenfield limiter pass `clock.timestamp_ms()`. +`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`. @@ -117,7 +118,7 @@ A caller can spend the full quota at the end of one window and again at the star @@ -139,9 +140,9 @@ Aborts with `ECooldownArmedWithTokens` if `initial_available > 0` and `cooldown_ id="rate_limiter-try_consume" kind="public" > -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. +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 `EInvalidAmount` if `amount` is `0`. +Aborts with `ECooldownDeadlineOverflow` if consuming arms a `Cooldown` gate and `now + cooldown_ms` would overflow `u64`. -`try_consume(self.available(clock), clock)` aborts `EInvalidAmount` when `available()` returns `0`. Guard with `if (n > 0) { self.try_consume(n, clock); }`. +`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); }`. @@ -287,7 +290,7 @@ Raised when a variant-typed getter is called on a limiter of a different variant -Raised by `try_consume` or `consume_or_abort` when `amount == 0`. +Raised by `consume_or_abort` when `amount == 0`. `try_consume` returns `false` instead of aborting. @@ -321,3 +324,7 @@ Raised by `new_cooldown` when `initial_available > 0` and `cooldown_end_ms > now 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 index d7728db4..a22d437d 100644 --- a/content/contracts-sui/1.x/guides/rate-limiter.mdx +++ b/content/contracts-sui/1.x/guides/rate-limiter.mdx @@ -16,16 +16,38 @@ flowchart LR 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. -This guide builds four examples that showcase the range of the primitive: +## What we will build -1. A globally throttled vault — one limiter shared by all withdrawers. -2. A vault that mixes the global limiter with a per-user limit carried in each capability. -3. A mage duel — many limiters of different variants inside one type, outside of DeFi. -4. A staking vault — a cooldown that adds a delay *before* an action can execute. +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 -It helps to be familiar with Sui Move shared objects, capabilities, the shared `Clock` at `0x6`, and programmable transaction blocks (PTBs). The examples reference those concepts throughout. +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 @@ -42,195 +64,96 @@ Then import the module from your Move code: use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; ``` -## Example 1: A globally throttled vault +## The token to dispense -The simplest integration: one `RateLimiter` field on a shared `Vault` throttles *all* withdrawals collectively. Here it is a `FixedWindow` — a hard quota of 1,000 units per rolling hour. Withdrawing requires presenting a `WithdrawCap`, so the limiter governs throughput while the capability governs who may withdraw. +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. This part is plain Sui — it does not touch `rate_limiter` — so the rate-limiting logic stays the focus. -```move -module my_protocol::simple_vault; +```move title="sources/rare_coin.move" +module rate_limiter_example::rare_coin; -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; - -/// Shared vault holding pooled funds behind one global withdrawal limiter. -public struct Vault has key { - id: UID, - limiter: RateLimiter, - funds: Balance, -} - -/// Held by the publisher; authorizes issuing `WithdrawCap`s. -public struct AdminCap has key, store { id: UID } +public struct RARE_COIN has drop {} -/// Presented on every withdrawal. Holding one marks a legitimate withdrawer. -public struct WithdrawCap has key, store { id: UID } - -/// On publish, hand the publisher an `AdminCap` for issuing withdraw capabilities. -fun init(ctx: &mut TxContext) { - transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender()); -} - -/// Share a vault whose withdrawals are capped at 1_000 SUI per hour, globally across all holders. -public fun create(clock: &Clock, ctx: &mut TxContext) { - // FixedWindow: a hard quota of 1_000 per rolling hour, anchored at now, starting full. - let limiter = rate_limiter::new_fixed_window( - 1_000, // capacity (units per window) - 3_600_000, // window_ms (1 hour) - clock.timestamp_ms(), // window_start_ms (anchor at now) - 1_000, // initial_available (start full) - clock, +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, ); - transfer::share_object(Vault { id: object::new(ctx), limiter, funds: balance::zero() }); -} -/// Deposit funds into the vault (no rate limit on the way in). -public fun deposit(self: &mut Vault, payment: Coin) { - self.funds.join(payment.into_balance()); -} - -/// Issue a withdraw capability to `recipient`. Admin-gated so the global budget is only -/// shared among legitimate holders. -public fun issue_withdraw_cap(_: &AdminCap, recipient: address, ctx: &mut TxContext) { - transfer::transfer(WithdrawCap { id: object::new(ctx) }, recipient); -} - -/// Withdraw `amount`, charging the global limiter first. The rate-limit check runs before the -/// balance split, so a denied withdrawal never touches `funds`. -public fun withdraw( - self: &mut Vault, - _: &WithdrawCap, - amount: u64, - clock: &Clock, - ctx: &mut TxContext, -): Coin { - self.limiter.consume_or_abort(amount, clock); - coin::from_balance(self.funds.split(amount), ctx) -} - -/// How much the vault will allow to be withdrawn right now (projects window rollover on read). -public fun withdrawable_now(self: &Vault, clock: &Clock): u64 { - self.limiter.available(clock) + let coins = treasury_cap.mint(10_000, ctx); + currency.make_supply_fixed(treasury_cap); + let metadata_cap = currency.finalize(ctx); + transfer::public_freeze_object(metadata_cap); + transfer::public_transfer(coins, ctx.sender()); } ``` -Because the `Vault` is shared, every caller draws from the same per-window budget. `consume_or_abort` is the ergonomic path: it aborts `ERateLimited` and the whole transaction reverts if the budget is exhausted. - -### Publish and call it +## The faucet module -Publish the package and record the IDs from the output: - -```bash -sui client publish -``` +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. -```bash -export PKG=0x... # published package -export ADMIN_CAP=0x... # AdminCap transferred to the publisher -export ME=0x... # your address -``` - -Create and fund the vault, then issue yourself a withdraw capability. The shared `Clock` is the object at `0x6`: - -```bash -sui client ptb \ - --move-call $PKG::simple_vault::create @0x6 -``` - -```bash -export VAULT=0x... # shared Vault from the create output - -sui client ptb \ - --split-coins gas "[5000]" \ - --assign funding \ - --move-call $PKG::simple_vault::deposit @$VAULT funding.0 \ - --move-call $PKG::simple_vault::issue_withdraw_cap @$ADMIN_CAP @$ME -``` +```move title="sources/faucet.move" +module rate_limiter_example::faucet; -Withdraw within the window and forward the coin in the same PTB: - -```bash -export WITHDRAW_CAP=0x... # WithdrawCap from the previous output - -sui client ptb \ - --move-call $PKG::simple_vault::withdraw @$VAULT @$WITHDRAW_CAP 250 @0x6 \ - --assign coin \ - --transfer-objects "[coin]" @$ME -``` - -A withdrawal that overruns the remaining per-window budget aborts `ERateLimited` (error code `0`) and the whole PTB reverts: - -```bash -sui client ptb \ - --move-call $PKG::simple_vault::withdraw @$VAULT @$WITHDRAW_CAP 1000 @0x6 \ - --assign coin \ - --transfer-objects "[coin]" @$ME -# Aborts: consume_or_abort -> ERateLimited. The window only had 750 left. -``` - -## Example 2: Mixing a global limiter with per-user limits +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; -The same global window as before, but now each `WithdrawCap` carries its *own* `RateLimiter` — a token bucket capping that specific holder. A withdrawal must satisfy **both**: the holder's personal bucket and the vault's global window. This shows two limiters of different variants composed across two objects. +// === Constants === -```mermaid -flowchart LR - Caller["Withdrawer"] -->|"withdraw(amount)"| Cap["WithdrawCap
personal_limiter: Bucket"] - Cap -->|"consume_or_abort (per-user)"| Vault["Vault
global_limiter: FixedWindow"] - Vault -->|"consume_or_abort (global)"| Funds["split funds"] -``` +const HOUR: u64 = 60 * 60 * 1000; -```move -module my_protocol::tiered_vault; +const HOURLY_LIMIT: u64 = 100; -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; +// === Structs === -/// Shared vault with one global withdrawal limiter shared by every holder. -public struct Vault has key { +/// Shared faucet with one global claim limiter shared by every holder. +public struct Faucet has key { id: UID, + balance: Balance, global_limiter: RateLimiter, - funds: Balance, } -/// Held by the publisher; authorizes issuing `WithdrawCap`s with per-holder limits. +/// 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 withdrawal. Carries a personal bucket limiter that caps this holder. -public struct WithdrawCap has key, store { +/// Presented on every claim. Carries a personal bucket limiter that caps this holder. +public struct ClaimCap has key, store { id: UID, personal_limiter: RateLimiter, } -/// On publish, hand the publisher an `AdminCap` for issuing withdraw capabilities. -fun init(ctx: &mut TxContext) { - transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender()); -} +// === Public Functions === -/// Share a vault whose global budget is 1_000 SUI per hour. -public fun create(clock: &Clock, ctx: &mut TxContext) { +/// 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( - 1_000, // capacity per window - 3_600_000, // 1 hour window - clock.timestamp_ms(), - 1_000, // start full + 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(Vault { id: object::new(ctx), global_limiter, funds: balance::zero() }); -} -/// Deposit funds into the vault (no rate limit on the way in). -public fun deposit(self: &mut Vault, payment: Coin) { - self.funds.join(payment.into_balance()); + transfer::share_object(Faucet { + id: object::new(ctx), + balance: funds.into_balance(), + global_limiter, + }); + AdminCap { id: object::new(ctx) } } -/// Issue a withdraw capability with a personal token-bucket limit: at most `per_user_cap` +/// 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_withdraw_cap( +public fun issue_claim_cap( _: &AdminCap, recipient: address, per_user_cap: u64, @@ -240,438 +163,294 @@ public fun issue_withdraw_cap( ctx: &mut TxContext, ) { let personal_limiter = rate_limiter::new_bucket( - per_user_cap, + per_user_cap, // capacity refill_amount, refill_interval_ms, - per_user_cap, // start full - clock.timestamp_ms(), + clock.timestamp_ms(), // last_refill_ms (anchor at now) + per_user_cap, // initial_available (start full) clock, ); - transfer::transfer(WithdrawCap { id: object::new(ctx), personal_limiter }, recipient); + transfer::transfer(ClaimCap { id: object::new(ctx), personal_limiter }, recipient); } -/// Withdraw `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 `funds`. -public fun withdraw( - self: &mut Vault, - cap: &mut WithdrawCap, +/// 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 { +): 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.funds.split(amount), ctx) + 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: &WithdrawCap, clock: &Clock): u64 { +public fun personal_allowance(cap: &ClaimCap, clock: &Clock): u64 { cap.personal_limiter.available(clock) } -/// The vault's currently-available global allowance (projects window rollover on read). -public fun global_allowance(self: &Vault, clock: &Clock): u64 { +/// 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) } ``` -The two limiters are fully independent: the personal bucket refills smoothly over time, while the global window resets on its own hourly boundary. A holder is bounded by whichever is tighter at the moment of the call. +A few things worth highlighting: -### Publish and call it +- **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. -```bash -sui client publish -``` +## 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 -export PKG=0x... -export ADMIN_CAP=0x... -export ME=0x... +sui client switch --env testnet ``` -Create and fund the vault, then issue a capability with a personal cap of 100, refilling 10 per second: +### 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 -sui client ptb \ - --move-call $PKG::tiered_vault::create @0x6 +ADDR=$(sui client active-address) + +sui client publish --build-env testnet --gas-budget 200000000 --json > /tmp/pub.json ``` ```bash -export VAULT=0x... +PKG=$(jq -r '.objectChanges[] | select(.type=="published") | .packageId' /tmp/pub.json) -sui client ptb \ - --split-coins gas "[5000]" \ - --assign funding \ - --move-call $PKG::tiered_vault::deposit @$VAULT funding.0 \ - --move-call $PKG::tiered_vault::issue_withdraw_cap @$ADMIN_CAP @$ME 100 10 1000 @0x6 +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) ``` -A withdrawal of 80 fits under the personal cap of 100 (and the global 1,000): +### 2. Register the coin currency -```bash -export WITHDRAW_CAP=0x... +Finalize the `RARE_COIN` registration in the on-chain coin registry (`0xc`): -sui client ptb \ - --move-call $PKG::tiered_vault::withdraw @$VAULT @$WITHDRAW_CAP 80 @0x6 \ - --assign coin \ - --transfer-objects "[coin]" @$ME +```bash +sui client ptb --move-call 0x2::coin_registry::finalize_registration "<$PKG::rare_coin::RARE_COIN>" @0xc @$CURRENCY ``` -A further withdrawal of 50 aborts `ERateLimited`: the personal bucket has only about 20 left, even though the global window still allows it. +Confirm the full supply landed with you: ```bash -sui client ptb \ - --move-call $PKG::tiered_vault::withdraw @$VAULT @$WITHDRAW_CAP 50 @0x6 \ - --assign coin \ - --transfer-objects "[coin]" @$ME -# Aborts: the personal bucket is the binding limit here. +sui client balance --coin-type $PKG::rare_coin::RARE_COIN ``` -## Example 3: Many limiters in one type (a mage duel) +``` +╭──────────────────────────────────────────────────╮ +│ Balance of coins owned by this address │ +├──────────────────────────────────────────────────┤ +│ ╭──────────────────────────────────────────────╮ │ +│ │ coin balance (raw) balance │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ Rare Coin 10000 10.00K RARE_COIN │ │ +│ ╰──────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────╯ +``` -Rate limiting is not only for DeFi. This example models a two-player duel where each `Mage` packs **four** limiters of mixed variants, one per game resource: +### 3. Create and fund the faucet -- `health` (`Bucket`) — consuming is taking damage; refill is passive regeneration. -- `mana` (`Bucket`) — consuming is paying a spell's cost; refill is mana regeneration. -- `spell_a_cd` (`Cooldown`, capacity 1) — gated after a single cast. -- `spell_b_cd` (`Cooldown`, capacity 3) — gated after three casts. +`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`. -A mage casts one of two spells at its opponent. Each spell costs mana, deals damage, and burns one charge of that spell's cooldown. When a mage's health reaches zero it is defeated, and because `Mage` is a `store + drop` value, removing it from the duel destroys it. +```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 +``` -```move -module my_protocol::mage_duel; +Grab the shared `Faucet` and the owned `AdminCap` that transaction created: -use openzeppelin_utils::rate_limiter::{Self, RateLimiter}; -use sui::clock::Clock; -use std::string::String; - -#[error(code = 0)] -const EDuelOver: vector = b"The duel already has a winner"; - -const MAX_HEALTH: u64 = 100; -const MAX_MANA: u64 = 60; -const CD_MS: u64 = 10_000; // 10s cooldown after a spell is exhausted - -// Spell A: cheap, light hit, single charge before cooldown. -const SPELL_A_COST: u64 = 10; -const SPELL_A_DAMAGE: u64 = 15; -// Spell B: pricier, heavier hit, three charges before cooldown. -const SPELL_B_COST: u64 = 20; -const SPELL_B_DAMAGE: u64 = 30; - -/// A single combatant. A plain `store + drop` value — destroyed by being dropped on defeat. -public struct Mage has store, drop { - name: String, - health: RateLimiter, - mana: RateLimiter, - spell_a_cd: RateLimiter, - spell_b_cd: RateLimiter, -} +```bash +FAUCET=$(jq -r '.objectChanges[] | select(.type=="created" and (.objectType|test("::faucet::Faucet"))) | .objectId' /tmp/new.json) -/// Shared arena holding both mages by value. Index 0 and 1 identify the combatants. -public struct Duel has key { - id: UID, - mages: vector, -} +ADMIN=$(jq -r '.objectChanges[] | select(.type=="created" and (.objectType|test("::faucet::AdminCap"))) | .objectId' /tmp/new.json) +``` -/// Start a duel between two freshly-spawned, full-health mages. -public fun start(name_0: String, name_1: String, clock: &Clock, ctx: &mut TxContext) { - let mages = vector[new_mage(name_0, clock), new_mage(name_1, clock)]; - transfer::share_object(Duel { id: object::new(ctx), mages }); -} +The faucet now holds the full balance, and its global window starts at the full `100`/hour allowance: -/// Mage at `attacker_idx` casts spell A at its opponent. -public fun cast_spell_a(self: &mut Duel, attacker_idx: u64, clock: &Clock) { - cast(self, attacker_idx, true, clock); -} +```bash +sui client object $FAUCET --json | jq -r '.content.balance' +``` -/// Mage at `attacker_idx` casts spell B at its opponent. -public fun cast_spell_b(self: &mut Duel, attacker_idx: u64, clock: &Clock) { - cast(self, attacker_idx, false, clock); -} +``` +10000 +``` -/// Resolve a cast: burn a cooldown charge, pay mana, then damage the opponent. Aborts (reverting -/// the whole transaction) if the spell is on cooldown or the caster cannot afford the mana. -fun cast(self: &mut Duel, attacker_idx: u64, is_spell_a: bool, clock: &Clock) { - assert!(self.mages.length() == 2, EDuelOver); - let target_idx = 1 - attacker_idx; - - // Attacker pays: a cooldown charge and the spell's mana cost. - let attacker = &mut self.mages[attacker_idx]; - if (is_spell_a) { - attacker.spell_a_cd.consume_or_abort(1, clock); - attacker.mana.consume_or_abort(SPELL_A_COST, clock); - } else { - attacker.spell_b_cd.consume_or_abort(1, clock); - attacker.mana.consume_or_abort(SPELL_B_COST, clock); - }; - - // Opponent takes damage. Clamp to remaining health so an overkill blow doesn't get rejected - // by the limiter's all-or-nothing consume; guard the zero case (a zero-unit consume aborts). - let damage = if (is_spell_a) SPELL_A_DAMAGE else SPELL_B_DAMAGE; - let target = &mut self.mages[target_idx]; - let dealt = damage.min(target.health.available(clock)); - if (dealt > 0) target.health.consume_or_abort(dealt, clock); - - // Defeated when no health remains: pull the mage out of the duel and let it drop. - if (target.health.available(clock) == 0) { - self.mages.remove(target_idx); - } -} +```bash +sui client object $FAUCET --json | jq -r '.content.global_limiter.available' +``` -/// A mage's current health (projects regeneration on read). `idx` must be a live combatant. -public fun health_of(self: &Duel, idx: u64, clock: &Clock): u64 { - self.mages[idx].health.available(clock) -} +``` +100 +``` -/// A mage's current mana (projects regeneration on read). `idx` must be a live combatant. -public fun mana_of(self: &Duel, idx: u64, clock: &Clock): u64 { - self.mages[idx].mana.available(clock) -} +### 4. Issue yourself a claim capability -/// Whether the duel has ended (one mage has been defeated). -public fun is_over(self: &Duel): bool { - self.mages.length() < 2 -} +Issue a `ClaimCap` with a personal token-bucket cap of `10` coins, refilling `10` every hour, starting full: -/// Spawn a full-health, full-mana mage with both spells ready. -fun new_mage(name: String, clock: &Clock): Mage { - let now = clock.timestamp_ms(); - Mage { - name, - // Health as a bucket: starts full, regenerates 1 every 2s up to MAX_HEALTH. - health: rate_limiter::new_bucket(MAX_HEALTH, 1, 2_000, MAX_HEALTH, now, clock), - // Mana as a bucket: starts full, regenerates 5 every second up to MAX_MANA. - mana: rate_limiter::new_bucket(MAX_MANA, 5, 1_000, MAX_MANA, now, clock), - // Spell A: 1 charge, then a CD_MS cooldown. Starts ready (granted seed). - spell_a_cd: rate_limiter::new_cooldown(1, CD_MS, 1, 0, clock), - // Spell B: 3 charges, then a CD_MS cooldown. Starts ready (granted seed). - spell_b_cd: rate_limiter::new_cooldown(3, CD_MS, 3, 0, clock), - } -} +```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') ``` -Two things are worth highlighting: - -- **Different cooldown capacities, same variant.** Spell A's `Cooldown` has capacity 1, so it gates after a single cast; spell B's has capacity 3, so it allows three casts before gating. The two cooldowns are independent — using one never touches the other. -- **The clamp before damage.** `try_consume` and `consume_or_abort` are all-or-nothing, so consuming more than `available` is rejected outright. For health, an overkill blow should still be lethal, so the code clamps damage to `available(clock)` and guards the zero case — the documented footgun of passing `0` to a consume. - -### Publish and call it +The personal bucket starts full at `10`: ```bash -sui client publish +sui client object $CAP --json | jq -r '.content.personal_limiter.available' ``` -```bash -export PKG=0x... +``` +10 ``` -Start a duel, then have mage 0 cast spell A at mage 1: +### 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::mage_duel::start "'Alice'" "'Bob'" @0x6 + --move-call $PKG::faucet::claim @$FAUCET @$CAP 5 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ADDR \ + --gas-budget 100000000 ``` -```bash -export DUEL=0x... # shared Duel from the start output - -sui client ptb \ - --move-call $PKG::mage_duel::cast_spell_a @$DUEL 0 @0x6 +``` +... +│ Status: Success │ +... +╭────────────────────────────────────────────────────────────────────╮ +│ Balance Changes │ +├────────────────────────────────────────────────────────────────────┤ +│ CoinType: ...::rare_coin::RARE_COIN Amount: 5 │ +╰────────────────────────────────────────────────────────────────────╯ ``` -Spell A has a single charge, so casting it again before its cooldown elapses aborts `ERateLimited`: +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 ptb \ - --move-call $PKG::mage_duel::cast_spell_a @$DUEL 0 @0x6 -# Aborts: spell A is on cooldown after one use. +sui client object $FAUCET --json | jq -r '.content.balance' ``` -Spell B has its own, independent cooldown with three charges, so it is still castable: +``` +9995 +``` ```bash -sui client ptb \ - --move-call $PKG::mage_duel::cast_spell_b @$DUEL 0 @0x6 +sui client object $CAP --json | jq -r '.content.personal_limiter.available' ``` -## Example 4: A cooldown that delays an action (staking) - -A `Cooldown` does not have to throttle a repeated action — seeded as an *armed* gate, it can sit in front of a single action and delay it. This staking vault has no yield; its only job is to enforce an unbonding delay. Initiating an unstake hands the user an `UnstakeTicket` whose cooldown gate is armed to release `unbond_delay_ms` in the future. The user can only `claim` once that gate elapses. - -```mermaid -sequenceDiagram - participant User - participant Vault as StakingVault - participant Ticket as UnstakeTicket - User->>Vault: initiate_unstake(receipt, clock) - Vault-->>User: UnstakeTicket (gate armed: now + delay) - User->>Ticket: claim(clock) before delay - Ticket-->>User: aborts ERateLimited - User->>Ticket: claim(clock) after delay - Ticket-->>User: Coin +``` +5 ``` -```move -module my_protocol::staking_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; - -/// Shared staking pool. `unbond_delay_ms` is the cooldown applied before staked funds can be claimed. -public struct StakingVault has key { - id: UID, - funds: Balance, - unbond_delay_ms: u64, -} - -/// Proof of a staked position, held by the staker. -public struct StakeReceipt has key, store { - id: UID, - amount: u64, -} - -/// Issued when unstaking begins. Releases the reserved coins only after its cooldown gate elapses. -public struct UnstakeTicket has key, store { - id: UID, - coins: Balance, - gate: RateLimiter, -} - -/// Share a staking vault with the given unbonding delay. -public fun create(unbond_delay_ms: u64, ctx: &mut TxContext) { - transfer::share_object(StakingVault { - id: object::new(ctx), - funds: balance::zero(), - unbond_delay_ms, - }); -} - -/// Stake `payment`, returning a receipt for the staked amount. -public fun stake(self: &mut StakingVault, payment: Coin, ctx: &mut TxContext): StakeReceipt { - let amount = payment.value(); - self.funds.join(payment.into_balance()); - StakeReceipt { id: object::new(ctx), amount } -} - -/// Begin unstaking: burn the receipt, reserve the coins into a ticket, and arm a cooldown that -/// releases `unbond_delay_ms` from now. -public fun initiate_unstake( - self: &mut StakingVault, - receipt: StakeReceipt, - clock: &Clock, - ctx: &mut TxContext, -): UnstakeTicket { - let StakeReceipt { id, amount } = receipt; - id.delete(); - - let coins = self.funds.split(amount); - // Armed cooldown (gated seed): no charge available now, gate releases at now + delay. - let gate = rate_limiter::new_cooldown( - 1, // capacity: a single claim - self.unbond_delay_ms, // cooldown_ms - 0, // initial_available: nothing claimable yet - clock.timestamp_ms() + self.unbond_delay_ms, // cooldown_end_ms: release time - clock, - ); - UnstakeTicket { id: object::new(ctx), coins, gate } -} - -/// Claim unstaked coins. Consuming the gate aborts `ERateLimited` until the unbonding cooldown -/// has elapsed; once it has, the gate releases and the coins are returned. -public fun claim(ticket: UnstakeTicket, clock: &Clock, ctx: &mut TxContext): Coin { - let UnstakeTicket { id, coins, mut gate } = ticket; - gate.consume_or_abort(1, clock); - id.delete(); - coin::from_balance(coins, ctx) -} +```bash +sui client object $FAUCET --json | jq -r '.content.global_limiter.available' +``` -/// Whether the ticket's cooldown has elapsed and the coins can be claimed now. -public fun is_claimable(ticket: &UnstakeTicket, clock: &Clock): bool { - ticket.gate.available(clock) > 0 -} +``` +95 ``` -The armed-gate seed (`initial_available == 0`, `cooldown_end_ms > now`) is the key. Until the release time, `available(clock)` projects to `0` and `consume_or_abort` aborts; once `now >= cooldown_end_ms`, the gate releases, `available(clock)` projects to `capacity` (here `1`), and the claim succeeds. The limiter holds no funds itself — it only decides *when* the surrounding `UnstakeTicket` may release them. +### 6. The personal bucket binds -### Publish and call it +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 publish +sui client ptb \ + --move-call $PKG::faucet::claim @$FAUCET @$CAP 6 @0x6 \ + --assign coin \ + --transfer-objects "[coin]" @$ADDR \ + --gas-budget 100000000 ``` -```bash -export PKG=0x... -export ME=0x... ``` +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 -Create a vault with a short unbonding delay (3 seconds, to observe the gate release), stake some SUI, and keep the receipt: +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 -sui client ptb \ - --move-call $PKG::staking_vault::create 3000 +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') ``` -```bash -export STAKING_VAULT=0x... +The personal bucket has `1000` available, but the global window only has `95` left: -sui client ptb \ - --split-coins gas "[1000]" \ - --assign stake_coin \ - --move-call $PKG::staking_vault::stake @$STAKING_VAULT stake_coin.0 \ - --assign receipt \ - --transfer-objects "[receipt]" @$ME +```bash +sui client object $CAP2 --json | jq -r '.content.personal_limiter.available' ``` -Initiate the unstake to receive a ticket with an armed cooldown: +``` +1000 +``` ```bash -export RECEIPT=0x... +sui client object $FAUCET --json | jq -r '.content.global_limiter.available' +``` -sui client ptb \ - --move-call $PKG::staking_vault::initiate_unstake @$STAKING_VAULT @$RECEIPT @0x6 \ - --assign ticket \ - --transfer-objects "[ticket]" @$ME +``` +95 ``` -Claiming immediately aborts `ERateLimited` — the unbonding cooldown has not elapsed: +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 -export UNSTAKE_TICKET=0x... - sui client ptb \ - --move-call $PKG::staking_vault::claim @$UNSTAKE_TICKET @0x6 \ + --move-call $PKG::faucet::claim @$FAUCET @$CAP2 96 @0x6 \ --assign coin \ - --transfer-objects "[coin]" @$ME -# Aborts: the gate is still armed. + --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' ``` -After the delay elapses, the same call succeeds and returns the coins: +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 ptb \ - --move-call $PKG::staking_vault::claim @$UNSTAKE_TICKET @0x6 \ - --assign coin \ - --transfer-objects "[coin]" @$ME +sui client object $FAUCET --json | jq -r '.content.balance' ``` -## Reconfiguration - -There are no in-place `reconfigure_*` functions. To change a limiter's configuration or runtime state, snapshot the current values through the getters, build a fresh `RateLimiter`, and overwrite the field. The anchor getters (`last_refill_ms`, `window_start_ms`) return the *projected* anchor at `now` and pair with `available(clock)`, so reading both and reconstructing preserves the limiter's phase. Gate any reconfigure entry function with whatever authorization you require — here, the same `AdminCap` used to issue capabilities. +``` +9995 +``` -Rate guarantees hold *between* reconstructions, not *across* one: a backdated anchor pre-credits elapsed time on the first projection. Anchor at `clock.timestamp_ms()` unless you are deliberately preserving phase. +## What this showed -## Operational checklist +In one module, the faucet exercised the core integration pattern end to end: -- Embed `RateLimiter` as a field of an object you own; never expect it to be a standalone object. -- Choose the variant deliberately: `Bucket` for a smooth ceiling with bursts, `FixedWindow` for hard per-window quotas, `Cooldown` for burst-then-pause or a one-shot delay. -- Run the limiter check *before* the side effect so a denied call has no partial effects. -- Use `consume_or_abort` to revert on refusal, or `try_consume` to branch on it. -- Guard against passing `0` to a consume; clamp with `available(clock)` and an `if (n > 0)` check where overkill is possible. -- Gate the `&mut` to your object with a capability, [`openzeppelin_access`](/contracts-sui/1.x/access), governance, or a multisig — the module authorizes no one. -- Reconfigure by reconstruction, anchoring at `clock.timestamp_ms()` unless you intend to preserve phase. +- 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*. -For key concepts and the common-mistakes reference, see the [Rate Limiter module guide](/contracts-sui/1.x/rate-limiter). For function signatures, parameters, and errors, see the [Utilities API reference](/contracts-sui/1.x/api/utils). +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/rate-limiter.mdx b/content/contracts-sui/1.x/rate-limiter.mdx index 93ac9c74..908165b2 100644 --- a/content/contracts-sui/1.x/rate-limiter.mdx +++ b/content/contracts-sui/1.x/rate-limiter.mdx @@ -60,7 +60,7 @@ public struct Vault has key { 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, 1_000, clock.timestamp_ms(), clock); + 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() }); } @@ -88,8 +88,8 @@ The limiter check runs *before* the side effect, so a denied withdrawal never to | Mistake | What happens | How to fix | |---|---|---| -| `try_consume(rl.available(clock), clock)` when empty | Aborts `EInvalidAmount` (`available()` is `0`, and a zero-unit consume is rejected) | Guard: `let n = rl.available(clock); if (n > 0) { rl.try_consume(n, clock); }` | -| Calling `try_consume` / `consume_or_abort` with `amount == 0` | Aborts `EInvalidAmount` | Treat zero-unit work as a no-op in your own code; never pass `0`. | +| `try_consume(rl.available(clock), clock)` when empty | Returns `false` (`available()` is `0`, and a zero-unit consume is rejected) — easy to misread as a successful consume | 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). | From 15c3983447f4fdbedebb06feb5a909650040fe9d Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Wed, 10 Jun 2026 11:19:38 +0200 Subject: [PATCH 4/5] fix rate-limiter.mdx --- content/contracts-sui/1.x/rate-limiter.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/contracts-sui/1.x/rate-limiter.mdx b/content/contracts-sui/1.x/rate-limiter.mdx index 908165b2..ff43e270 100644 --- a/content/contracts-sui/1.x/rate-limiter.mdx +++ b/content/contracts-sui/1.x/rate-limiter.mdx @@ -8,7 +8,7 @@ The example code snippets used in this guide are experimental and have not been 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 token-bucket math into a reusable primitive and adds two strategies (fixed window and cooldown) that share one consume and inspect API. +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 @@ -88,7 +88,7 @@ The limiter check runs *before* the side effect, so a denied withdrawal never to | 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) — easy to misread as a successful consume | Guard: `let n = rl.available(clock); if (n > 0) { rl.try_consume(n, clock); }` | +| `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)). | @@ -108,13 +108,13 @@ The module has no `enabled` toggle by design. Implement pause in your own object 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. 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. `available(clock)` is also non-mutating. +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 vault, 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 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). From a7365208408a6d0709782ab08ef5ff13f3e93a08 Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Wed, 10 Jun 2026 13:33:50 +0200 Subject: [PATCH 5/5] update rate limiter guide --- content/contracts-sui/1.x/guides/rate-limiter.mdx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/content/contracts-sui/1.x/guides/rate-limiter.mdx b/content/contracts-sui/1.x/guides/rate-limiter.mdx index a22d437d..c6382b31 100644 --- a/content/contracts-sui/1.x/guides/rate-limiter.mdx +++ b/content/contracts-sui/1.x/guides/rate-limiter.mdx @@ -66,7 +66,7 @@ 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. This part is plain Sui — it does not touch `rate_limiter` — so the rate-limiting logic stays the focus. +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; @@ -86,8 +86,7 @@ fun init(witness: RARE_COIN, ctx: &mut TxContext) { let coins = treasury_cap.mint(10_000, ctx); currency.make_supply_fixed(treasury_cap); - let metadata_cap = currency.finalize(ctx); - transfer::public_freeze_object(metadata_cap); + currency.finalize_and_delete_metadata_cap(ctx); transfer::public_transfer(coins, ctx.sender()); } ```