PackagesUtils

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, whether for withdrawal throttles, daily caps, or action cooldowns. rate_limiter generalizes the proven rate limiting math into a reusable primitive and exposes three strategies (bucket, fixed window and cooldown) that share a single API.

Use cases

Use rate_limiter when your protocol needs:

  • A throughput ceiling on a shared resource, such as withdrawals from a vault or flow through a bridge pathway.
  • Independent per-user or per-object budgets that refill over time.
  • A hard per-window quota, such as "100 mints per hour".
  • A cooldown that gates the reuse of an action after a burst.
  • A delay that must elapse before an action such as a claim or an unstake can execute.
  • A regenerating resource in non-financial logic, such as health, mana, stamina, or energy.

Import

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.

VariantBehaviorWhen to pick it
BucketHolds 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).
FixedWindowUp to capacity units per fixed window of window_ms, anchored at a caller-chosen start (typically creation time); resets to capacity at each boundary.Hard per-window quotas, e.g. "100 mints per hour".
CooldownUp 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:

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<SUI>,
}

public fun create(clock: &Clock, ctx: &mut TxContext) {
    // Bucket: cap 1_000 units, refilling 100 units every 6 s, starting full.
    // (units are whatever you meter; here, MIST)
    let limiter = rate_limiter::new_bucket(1_000, 100, 6_000, clock.timestamp_ms(), 1_000, clock);
    transfer::share_object(Vault { id: object::new(ctx), limiter, funds: balance::zero() });
}

public fun withdraw(self: &mut Vault, amount: u64, clock: &Clock, ctx: &mut TxContext): Coin<SUI> {
    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.

Gating reuse with a cooldown

Cooldown allows up to capacity uses, then locks for cooldown_ms before the batch refreshes. The consume call is identical to the bucket (only the constructor changes), and available surfaces the remaining uses without mutating:

module my_game::ability;

use openzeppelin_utils::rate_limiter::{Self, RateLimiter};
use sui::clock::Clock;

public struct Hero has key {
    id: UID,
    dash: RateLimiter, // 3 dashes, then a 30 s lockout
}

public fun mint(clock: &Clock, ctx: &mut TxContext) {
    // Cooldown: 3 uses granted now, no gate armed yet (cooldown_end_ms = 0).
    let dash = rate_limiter::new_cooldown(3, 30_000, 0, 3, clock);
    transfer::transfer(Hero { id: object::new(ctx), dash }, ctx.sender());
}

public fun dash(self: &mut Hero, clock: &Clock) {
    self.dash.consume_or_abort(1, clock); // aborts ERateLimited while gated
    // ... apply the dash ...
}

/// Dashes still available right now; 0 while the cooldown is active.
public fun dashes_left(self: &Hero, clock: &Clock): u64 {
    self.dash.available(clock)
}

The same shape arms a one-shot delay in front of an action: construct with initial_available = 0 and cooldown_end_ms = clock.timestamp_ms() + delay, and every consume is gated until the delay elapses.

Reconfiguring a limiter

There are no in-place setters. To change a limiter's configuration, snapshot its live state through the getters, build a fresh RateLimiter, and overwrite the field, since drop lets the old value go. Gate the entry function with the same authority that guards your other privileged operations:

/// Raise the vault's withdrawal ceiling, preserving the current balance and refill phase.
public fun set_capacity(self: &mut Vault, new_capacity: u64, clock: &Clock) {
    // Snapshot the live state...
    let refill_amount = self.limiter.refill_amount();
    let refill_interval_ms = self.limiter.refill_interval_ms();
    let last_refill_ms = self.limiter.last_refill_ms(clock); // projected: preserves refill phase
    let available = self.limiter.available(clock).min(new_capacity); // clamp if lowering capacity

    // ...then overwrite the field with a fresh limiter built from it.
    self.limiter = rate_limiter::new_bucket(
        new_capacity,
        refill_amount,
        refill_interval_ms,
        last_refill_ms,
        available,
        clock,
    );
}

Reading last_refill_ms(clock) and available(clock) together keeps the refill phase intact across the swap. Other policies, such as re-anchoring, a full reset, or switching variant entirely, are just different constructor calls over the same snapshot.

Preserving the anchor while also changing the refill rate re-prices the elapsed time under the new rate. A long gap since last_refill_ms can mint up to capacity tokens the instant the new limiter is constructed. If you change refill_amount or refill_interval_ms, re-anchor to clock.timestamp_ms() (and pick initial_available explicitly) instead of carrying the old anchor forward. Preserving the anchor is safe only when the rate is unchanged, as in the set_capacity example above.

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, with no global state, no contention, and 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 (the lone exception is a pathological Cooldown config whose next deadline would overflow u64, which aborts ECooldownDeadlineOverflow). 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 setters; you change a limiter by overwriting the field with a fresh one (see Reconfiguring a limiter). 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, such as a capability, openzeppelin_access, governance, or a multisig. The module makes no access-control claim.

Common mistakes

MistakeWhat happensHow to fix
try_consume(rl.available(clock), clock) when emptyReturns false (available() is 0, and a zero-unit consume is rejected), which is surprising if you expect "consume all available" to succeed as a no-opGuard: let n = rl.available(clock); if (n > 0) { rl.try_consume(n, clock); }
Calling consume_or_abort with amount == 0Aborts 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 > 0Returns a stale value (the gate is only consulted when available == 0)Only interpret cooldown_end_ms() when available(clock) == 0.
Expecting a partially drained Cooldown to refill on each cooldownIt does not. The gate arms and the batch refreshes to capacity only once the balance hits exactly 0. Capacity 10 with 7 spent reports 3 forever until those 3 are consumed and the gate then elapsesDrain the batch fully before the cooldown applies; the refresh is per-emptied-batch, not per-elapsed-interval.
Seeding new_cooldown with initial_available > 0 and cooldown_end_ms > nowAborts ECooldownArmedWithTokensUse one of the valid seed combinations (see the API reference).
Passing a future last_refill_ms / window_start_msAborts EBucketAnchorInFuture / EWindowAnchorInFuturePass clock.timestamp_ms() (or an earlier value).
Expecting FixedWindow to prevent boundary burstsA 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.
Preserving the bucket anchor while changing the refill rateElapsed time is re-priced under the new rate, minting up to capacity phantom tokens the instant the limiter is rebuiltWhen changing refill_amount / refill_interval_ms, re-anchor to clock.timestamp_ms() and set initial_available explicitly; only preserve the anchor when the rate is unchanged.
Assuming the limiter authorizes callersIt does not; anyone with &mut to the field can consume and reconfigureGate 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. The simplest pause is in your own object: a bool checked before the consume. There is no literal deny-all config (every constructor requires positive parameters; capacity == 0 aborts EZeroCapacity, and a Bucket/FixedWindow seeded empty just refills over time), but a Cooldown seeded with initial_available = 0 and a far-future cooldown_end_ms stays gated and denies every consume until you reconstruct it.

Does the library emit events? No. Observability is left to you, since you hold the context an event needs (which user, which object). Emit your own event after a successful consume if you want indexing.

Can I probe the limiter without affecting it? Yes. You can use the available(clock) getter (projects pending refills). Also, a failed try_consume (returns false) is observably a no-op across all three variants, with no anchor advance, no balance change, and 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. Some variant-typed getters also take a &Clock and return a projected anchor (last_refill_ms(clock) for Bucket and window_start_ms(clock) for FixedWindow), so don't assume every variant getter is clock-free. capacity() and available(clock) are variant-agnostic and need no guard.

Learn more

For function-level signatures, parameters, and errors, see the Utilities API reference.