`](#prefunded_sale-Quote)
+- [`CancelReason`](#prefunded_sale-CancelReason)
+
+Functions
+
+- [`create_sale(curve_params, hard_cap, soft_cap, opens_at_ms, closes_at_ms, ctx)`](#prefunded_sale-create_sale)
+- [`deposit(sale, inventory)`](#prefunded_sale-deposit)
+- [`set_per_buyer_cap(sale, per_buyer_cap, ctx)`](#prefunded_sale-set_per_buyer_cap)
+- [`set_vesting_schedule(sale, schedule)`](#prefunded_sale-set_vesting_schedule)
+- [`pair_refund_vault(sale, vault, vault_cap)`](#prefunded_sale-pair_refund_vault)
+- [`enable_allowlist(sale, ctx)`](#prefunded_sale-enable_allowlist)
+- [`mint_activation_ticket(sale, w, required_inventory)`](#prefunded_sale-mint_activation_ticket)
+- [`share_and_activate(sale, vault, ticket, clock)`](#prefunded_sale-share_and_activate)
+- [`purchase(sale, quote, allow, clock, ctx)`](#prefunded_sale-purchase)
+- [`finalize(sale, vault, clock)`](#prefunded_sale-finalize)
+- [`cancel_after_close(sale, vault, clock)`](#prefunded_sale-cancel_after_close)
+- [`cancel_emergency(sale, cap, vault, clock)`](#prefunded_sale-cancel_emergency)
+- [`claim(sale, receipt, ctx)`](#prefunded_sale-claim)
+- [`claim_all(sale, receipts, ctx)`](#prefunded_sale-claim_all)
+- [`claim_into_vesting(sale, receipt, ctx)`](#prefunded_sale-claim_into_vesting)
+- [`claim_all_into_vesting(sale, receipts, ctx)`](#prefunded_sale-claim_all_into_vesting)
+- [`withdraw_proceeds(sale, cap)`](#prefunded_sale-withdraw_proceeds)
+- [`withdraw_unsold_inventory(sale, cap)`](#prefunded_sale-withdraw_unsold_inventory)
+- [`refund(sale, vault, receipt, ctx)`](#prefunded_sale-refund)
+- [`refund_all(sale, vault, receipts, ctx)`](#prefunded_sale-refund_all)
+- [`mint_quote(sale, w, payment, allocation)`](#prefunded_sale-mint_quote)
+- [`mint_quote_unversioned(sale, w, payment, allocation)`](#prefunded_sale-mint_quote_unversioned)
+- [`raised(sale)`](#prefunded_sale-raised)
+- [`curve_params(sale)`](#prefunded_sale-curve_params)
+- [`hard_cap(sale)`](#prefunded_sale-hard_cap)
+- [`soft_cap(sale)`](#prefunded_sale-soft_cap)
+- [`opens_at_ms(sale)`](#prefunded_sale-opens_at_ms)
+- [`closes_at_ms(sale)`](#prefunded_sale-closes_at_ms)
+- [`requires_allowlist(sale)`](#prefunded_sale-requires_allowlist)
+- [`vesting_schedule(sale)`](#prefunded_sale-vesting_schedule)
+- [`inventory_total(sale)`](#prefunded_sale-inventory_total)
+- [`total_allocated(sale)`](#prefunded_sale-total_allocated)
+- [`inventory_remaining(sale)`](#prefunded_sale-inventory_remaining)
+- [`proceeds_amount(sale)`](#prefunded_sale-proceeds_amount)
+- [`is_open(sale, clock)`](#prefunded_sale-is_open)
+- [`has_reached_soft_cap(sale)`](#prefunded_sale-has_reached_soft_cap)
+- [`has_reached_hard_cap(sale)`](#prefunded_sale-has_reached_hard_cap)
+- [`is_init(sale)`](#prefunded_sale-is_init)
+- [`is_active(sale)`](#prefunded_sale-is_active)
+- [`is_finalized(sale)`](#prefunded_sale-is_finalized)
+- [`is_cancelled(sale)`](#prefunded_sale-is_cancelled)
+- [`refund_vault_id(sale)`](#prefunded_sale-refund_vault_id)
+- [`cap_sale_id(c)`](#prefunded_sale-cap_sale_id)
+- [`sale_id(q)`](#prefunded_sale-sale_id)
+- [`payment(q)`](#prefunded_sale-payment)
+- [`allocation(q)`](#prefunded_sale-allocation)
+- [`max_sale_duration_ms()`](#prefunded_sale-max_sale_duration_ms)
+
+Events
+
+- [`SaleCreated`](#prefunded_sale-SaleCreated)
+- [`InventoryDeposited`](#prefunded_sale-InventoryDeposited)
+- [`PerBuyerCapSet`](#prefunded_sale-PerBuyerCapSet)
+- [`VestingScheduleSet`](#prefunded_sale-VestingScheduleSet)
+- [`RefundVaultPaired`](#prefunded_sale-RefundVaultPaired)
+- [`AllowlistEnabled`](#prefunded_sale-AllowlistEnabled)
+- [`SaleActivated`](#prefunded_sale-SaleActivated)
+- [`Purchased`](#prefunded_sale-Purchased)
+- [`SaleFinalized`](#prefunded_sale-SaleFinalized)
+- [`SaleCancelled`](#prefunded_sale-SaleCancelled)
+- [`Claimed`](#prefunded_sale-Claimed)
+- [`Refunded`](#prefunded_sale-Refunded)
+- [`ProceedsWithdrawn`](#prefunded_sale-ProceedsWithdrawn)
+- [`InventoryWithdrawn`](#prefunded_sale-InventoryWithdrawn)
+
+Errors
+
+- [`EWrongAdminCap`](#prefunded_sale-EWrongAdminCap)
+- [`EBuyerOnly`](#prefunded_sale-EBuyerOnly)
+- [`EEmergencyCancelAfterClose`](#prefunded_sale-EEmergencyCancelAfterClose)
+- [`EInvalidTimeRange`](#prefunded_sale-EInvalidTimeRange)
+- [`ESaleWindowClosed`](#prefunded_sale-ESaleWindowClosed)
+- [`ESaleWindowStillOpen`](#prefunded_sale-ESaleWindowStillOpen)
+- [`EActivationAfterClose`](#prefunded_sale-EActivationAfterClose)
+- [`EHardCapZero`](#prefunded_sale-EHardCapZero)
+- [`EInvalidCapsOrdering`](#prefunded_sale-EInvalidCapsOrdering)
+- [`EZeroPayment`](#prefunded_sale-EZeroPayment)
+- [`ERaisedOverflow`](#prefunded_sale-ERaisedOverflow)
+- [`EHardCapExceeded`](#prefunded_sale-EHardCapExceeded)
+- [`EInsufficientInventoryAtActivate`](#prefunded_sale-EInsufficientInventoryAtActivate)
+- [`EInsufficientInventory`](#prefunded_sale-EInsufficientInventory)
+- [`EPerBuyerCapExceeded`](#prefunded_sale-EPerBuyerCapExceeded)
+- [`EPerEntryCapExceeded`](#prefunded_sale-EPerEntryCapExceeded)
+- [`ESoftCapNotMet`](#prefunded_sale-ESoftCapNotMet)
+- [`ESaleAlreadyComplete`](#prefunded_sale-ESaleAlreadyComplete)
+- [`EAllowlistRequired`](#prefunded_sale-EAllowlistRequired)
+- [`EAllowlistNotRequired`](#prefunded_sale-EAllowlistNotRequired)
+- [`EAllowlistAlreadyEnabled`](#prefunded_sale-EAllowlistAlreadyEnabled)
+- [`EVaultAlreadyPaired`](#prefunded_sale-EVaultAlreadyPaired)
+- [`EVaultRequiredForActivate`](#prefunded_sale-EVaultRequiredForActivate)
+- [`EWrongVault`](#prefunded_sale-EWrongVault)
+- [`EVaultNotActive`](#prefunded_sale-EVaultNotActive)
+- [`EVaultNotEmpty`](#prefunded_sale-EVaultNotEmpty)
+- [`EReceiptSaleMismatch`](#prefunded_sale-EReceiptSaleMismatch)
+- [`EQuoteSaleMismatch`](#prefunded_sale-EQuoteSaleMismatch)
+- [`ETicketSaleMismatch`](#prefunded_sale-ETicketSaleMismatch)
+- [`EPerBuyerCapAlreadySet`](#prefunded_sale-EPerBuyerCapAlreadySet)
+- [`EPerBuyerCapZero`](#prefunded_sale-EPerBuyerCapZero)
+- [`EVestingScheduleAlreadySet`](#prefunded_sale-EVestingScheduleAlreadySet)
+- [`EClaimRequiresVesting`](#prefunded_sale-EClaimRequiresVesting)
+- [`ENoVestingScheduleAttached`](#prefunded_sale-ENoVestingScheduleAttached)
+- [`ENotInit`](#prefunded_sale-ENotInit)
+- [`ENotActive`](#prefunded_sale-ENotActive)
+- [`ENotFinalized`](#prefunded_sale-ENotFinalized)
+- [`ENotCancelled`](#prefunded_sale-ENotCancelled)
+- [`ENotTerminal`](#prefunded_sale-ENotTerminal)
+- [`ESaleDurationTooLong`](#prefunded_sale-ESaleDurationTooLong)
+- [`EZeroAllocation`](#prefunded_sale-EZeroAllocation)
+- [`EStaleQuote`](#prefunded_sale-EStaleQuote)
+- [`ESoftCapNotSet`](#prefunded_sale-ESoftCapNotSet)
+- [`ESaleNotClosed`](#prefunded_sale-ESaleNotClosed)
+- [`ESoftCapReached`](#prefunded_sale-ESoftCapReached)
+- [`EWrongVaultCap`](#prefunded_sale-EWrongVaultCap)
+
+#### Types [!toc] [#prefunded_sale-Types]
+
+
+The sale object. Holds the inventory, collected proceeds, caps, window, phase, the paired vault's id and controller cap, the optional per-buyer `contributions` table, and the optional vesting schedule. `key`-only: created owned in `Init`, shared on activation, and never deleted.
+
+`VestingWitness` is the `drop`-only schedule witness (e.g. `vesting_wallet_linear::Linear`); pinning it in the type is what makes an attached vesting lockup unbypassable. For a non-vesting sale the vesting slots are inert.
+
+
+
+The lifecycle phase: `Init` (owned, under setup), `Active` (shared, accepting purchases within the window), `Finalized` (successful close), or `Cancelled` (failed close). `Finalized` and `Cancelled` are terminal. Query it with the `is_init` / `is_active` / `is_finalized` / `is_cancelled` predicates.
+
+
+
+Admin capability for a single sale, bound to it by id. Gates `cancel_emergency`, `withdraw_proceeds`, and `withdraw_unsold_inventory`. Losing it never strands buyer funds - all buyer-facing flows are permissionless.
+
+
+
+Witness-gated, single-use carrier for `share_and_activate`. Has no abilities, so it must be minted and consumed in the same PTB. Pins the `sale_id` and the curve's committed `required_inventory`.
+
+
+
+Hot-potato carrying a curve-priced quote for a single purchase: the buyer's payment `Balance` welded to the curve-computed `allocation`, pinned to a `sale_id`. Has no abilities, so it can only be minted by the sale's curve module (via `mint_quote` or `mint_quote_unversioned`) and consumed by `purchase`, in the same PTB. Cannot be stored, copied, replayed, or transferred. A quote minted via `mint_quote` also carries the sale's state version at mint time, and `purchase` rejects it (`EStaleQuote`) if an intervening same-PTB purchase has advanced that version.
+
+
+
+The reason a sale was cancelled, carried by `SaleCancelled`: `SoftCapMissed` (the window closed below the minimum raise) or `AdminEmergency` (an admin cancelled while the sale was still open).
+
+
+#### Functions [!toc] [#prefunded_sale-Functions]
+
+
+Creates a sale in `Init` phase, returning it as an owned value plus its admin cap. The caller threads the sale through the setup calls below and then `share_and_activate`. `curve_params` is opaque to the sale; build it with the curve module's `params` constructor. `soft_cap == 0` means no soft cap.
+
+Aborts with `EHardCapZero` if `hard_cap == 0`.
+
+Aborts with `EInvalidCapsOrdering` if `soft_cap > hard_cap`.
+
+Aborts with `EInvalidTimeRange` if `opens_at_ms >= closes_at_ms`.
+
+Aborts with `ESaleDurationTooLong` if `closes_at_ms - opens_at_ms` exceeds `max_sale_duration_ms()`.
+
+Emits `SaleCreated`.
+
+
+
+Adds sale tokens to inventory. May be called multiple times during `Init`. A zero-value deposit is a no-op. Authority is implicit: the sale is owned.
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+Emits `InventoryDeposited` (only when the amount is non-zero).
+
+
+
+Configures a cumulative per-buyer payment cap, enforced inside `purchase` against each buyer's running total. One-shot.
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+Aborts with `EPerBuyerCapAlreadySet` if a per-buyer cap is already configured.
+
+Aborts with `EPerBuyerCapZero` if `per_buyer_cap == 0`.
+
+Emits `PerBuyerCapSet`.
+
+
+
+Attaches an issuer-defined vesting schedule, supplied as a `VestingSchedule` bundle minted by the curve module that owns `VestingWitness` (for the built-in linear curve, `vesting_wallet_linear::vesting_schedule`). Once set, plain `claim` aborts and redemption must go through `claim_into_vesting`. The schedule is unbypassable - the wallet is built under the sale's pinned `VestingWitness`; because only that module can construct the bundle, the witness and params pinned in the sale's type are guaranteed to form a coherent pair. One-shot, `Init`-only.
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+Aborts with `EVestingScheduleAlreadySet` if a schedule is already configured.
+
+Emits `VestingScheduleSet`.
+
+
+
+Pairs a refund vault with the sale, required before activation. The cap is consumed into the sale (never returned); from then on only the sale's gated functions drive the vault. The vault must be `Active` and empty.
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+Aborts with `EVaultAlreadyPaired` if a vault has already been paired.
+
+Aborts with `EWrongVaultCap` if `vault_cap` does not control `vault`.
+
+Aborts with `EVaultNotActive` if `vault` is not in the `Active` state.
+
+Aborts with `EVaultNotEmpty` if `vault` holds a non-zero balance.
+
+Emits `RefundVaultPaired`.
+
+
+
+Switches the sale into compliance-gated mode and returns the single `AllowlistAdmin`, to be wrapped in the consumer's compliance module. After this, every `purchase` must consume an `AllowEntry`. One-shot.
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+Aborts with `EAllowlistAlreadyEnabled` if the allowlist is already enabled.
+
+Emits `AllowlistEnabled`.
+
+
+
+Witness-gated constructor for the `ActivationTicket` that `share_and_activate` consumes. Requires a value of type `Curve`, whose constructor is private to the declaring curve module, so only that module can mint a ticket for its sale. Curve modules wrap it (e.g. `fixed_rate_curve::activation_ticket`).
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+
+
+Transitions `Init -> Active` and shares both the sale and its paired vault, consuming all three arguments. Taking the vault by value and sharing it here is what makes the permissionless refund guarantee structural: the paths that need `&mut vault` can never be bricked by a forgotten share step. Activation after `closes_at_ms` is rejected.
+
+Aborts with `ETicketSaleMismatch` if `ticket` was minted for a different sale.
+
+Aborts with `ENotInit` if the sale is not in `Init` phase.
+
+Aborts with `EVaultRequiredForActivate` if no refund vault has been paired.
+
+Aborts with `EWrongVault` if `vault` is not the one paired with this sale.
+
+Aborts with `EActivationAfterClose` if `now >= closes_at_ms`.
+
+Aborts with `EInsufficientInventoryAtActivate` if `inventory < required_inventory`.
+
+Emits `SaleActivated`.
+
+
+
+Buys sale tokens, delivering a `Receipt` to `ctx.sender()` and adding the payment to proceeds. Applies the quote's `allocation` verbatim (the curve is trusted), bounded only by unallocated inventory and overflow. The hard cap is all-or-nothing - a payment past `hard_cap` reverts in full. Pass `Some(entry)` iff the sale requires an allowlist.
+
+Aborts with `ENotActive` if the sale is not in `Active` phase.
+
+Aborts with `EQuoteSaleMismatch` if `quote` was minted for a different sale.
+
+Aborts with `EStaleQuote` if the quote is freshness-enforced and an intervening same-PTB `purchase` advanced the sale's state after the quote was minted.
+
+Aborts with `ESaleWindowClosed` if `now` is outside `[opens_at_ms, closes_at_ms]`.
+
+Aborts with `EAllowlistRequired` / `EAllowlistNotRequired` if the entry is missing when required, or supplied when not.
+
+Aborts with `allowlist::EWrongSaleId` / `allowlist::EWrongBuyer` if the entry was issued for a different sale or buyer.
+
+Aborts with `ERaisedOverflow` if `raised + paid` would exceed `u64::MAX`.
+
+Aborts with `EHardCapExceeded` if `raised + paid` would exceed `hard_cap`.
+
+Aborts with `EPerEntryCapExceeded` if `paid` exceeds the entry's `max_amount`.
+
+Aborts with `EPerBuyerCapExceeded` if `paid` exceeds the buyer's remaining per-buyer cap.
+
+Aborts with `EInsufficientInventory` if `allocation` exceeds unallocated inventory (only reachable via a dishonest curve).
+
+Emits `Purchased`.
+
+
+
+Closes the sale as a success and flips the paired vault to `Closed`. **Permissionless.** Allowed once the window has closed with the soft cap met, or as soon as the hard cap is reached (closing early). Proceeds stay in the sale for the admin to withdraw.
+
+Aborts with `ENotActive` if the sale is not in `Active` phase.
+
+Aborts with `ESaleWindowStillOpen` if the window is still open and the hard cap is not reached.
+
+Aborts with `ESoftCapNotMet` if `raised < soft_cap`.
+
+Aborts with `EWrongVault` if `vault` is not the one paired with this sale.
+
+Emits `SaleFinalized`.
+
+
+
+Closes the sale as a soft-cap miss. **Permissionless.** Drains proceeds into the vault and flips it to `Refunding`. Allowed once the window has closed with a configured soft cap that was missed.
+
+Aborts with `ENotActive` if the sale is not in `Active` phase.
+
+Aborts with `ESaleNotClosed` if the window has not yet closed.
+
+Aborts with `ESoftCapNotSet` if no soft cap is configured.
+
+Aborts with `ESoftCapReached` if the soft cap was reached (`raised >= soft_cap`).
+
+Aborts with `EWrongVault` if `vault` is not the one paired with this sale.
+
+Emits `SaleCancelled`.
+
+
+
+Emergency cancellation. **Admin-only.** Drains proceeds into the vault and flips it to `Refunding`. Allowed only while the window is open and the sale has not met its goal - the guards prevent rugging a successful sale. Pre-open cancel is permitted.
+
+Aborts with `EWrongAdminCap` if `cap` was issued for a different sale.
+
+Aborts with `ENotActive` if the sale is not in `Active` phase.
+
+Aborts with `EEmergencyCancelAfterClose` if `now > closes_at_ms`.
+
+Aborts with `ESaleAlreadyComplete` if `raised >= hard_cap`.
+
+Aborts with `ESoftCapReached` if a soft cap is configured and `raised >= soft_cap`.
+
+Aborts with `EWrongVault` if `vault` is not the one paired with this sale.
+
+Emits `SaleCancelled`.
+
+
+
+Redeems a receipt for its `allocation`, returned as `Balance` split from inventory, and destroys the receipt. A vesting sale must use `claim_into_vesting` instead.
+
+Aborts with `EClaimRequiresVesting` if the sale has a vesting schedule attached.
+
+Aborts with `ENotFinalized` if the sale is not in `Finalized` phase.
+
+Aborts with `EReceiptSaleMismatch` if `receipt` was issued by a different sale.
+
+Aborts with `EBuyerOnly` if `ctx.sender()` is not the receipt's buyer.
+
+Emits `Claimed`.
+
+
+
+Batches `claim` over several receipts, summing their allocations into one balance. Aborts the whole call if any receipt is invalid.
+
+Aborts with `EClaimRequiresVesting` if the sale has a vesting schedule attached.
+
+Aborts with `ENotFinalized` if the sale is not in `Finalized` phase.
+
+Aborts with `EReceiptSaleMismatch` if any receipt was issued by a different sale.
+
+Aborts with `EBuyerOnly` if `ctx.sender()` is not the buyer of any receipt.
+
+Emits `Claimed` per receipt.
+
+
+
+The only redemption path for a vesting sale. Redeems a receipt into a funded `VestingWallet` (from `openzeppelin_finance`) with `beneficiary` forced to the buyer and the sale's fixed schedule, plus the wallet's `DestroyCap`. Every type argument is inferred from the sale.
+
+Aborts with `ENoVestingScheduleAttached` if the sale has no vesting schedule (use `claim`).
+
+Aborts with `ENotFinalized` if the sale is not in `Finalized` phase.
+
+Aborts with `EReceiptSaleMismatch` if `receipt` was issued by a different sale.
+
+Aborts with `EBuyerOnly` if `ctx.sender()` is not the receipt's buyer.
+
+Emits `Claimed`.
+
+
+
+Batch variant of `claim_into_vesting`: redeems several receipts into one funded `VestingWallet`, summing their allocations. Aborts the whole call if any receipt is invalid.
+
+Aborts with `ENoVestingScheduleAttached` if the sale has no vesting schedule (use `claim_all`).
+
+Aborts with `ENotFinalized` if the sale is not in `Finalized` phase.
+
+Aborts with `EReceiptSaleMismatch` if any receipt was issued by a different sale.
+
+Aborts with `EBuyerOnly` if `ctx.sender()` is not the buyer of any receipt.
+
+Emits `Claimed` per receipt.
+
+
+
+Withdraws all collected proceeds. **Admin-only**, `Finalized`-only. Idempotent: a second call returns an empty balance and emits nothing.
+
+Aborts with `EWrongAdminCap` if `cap` was issued for a different sale.
+
+Aborts with `ENotFinalized` if the sale is not in `Finalized` phase.
+
+Emits `ProceedsWithdrawn` (only when the amount is non-zero).
+
+
+
+Withdraws strictly the unallocated inventory (`inventory - total_allocated`); tokens backing outstanding receipts stay put. **Admin-only**, valid in `Finalized` or `Cancelled`. Idempotent.
+
+Aborts with `EWrongAdminCap` if `cap` was issued for a different sale.
+
+Aborts with `ENotTerminal` if the sale is neither `Finalized` nor `Cancelled`.
+
+Emits `InventoryWithdrawn` (only when the amount is non-zero).
+
+
+
+Refunds a buyer's payment from the paired vault, returning exactly the receipt's `paid` amount and destroying the receipt. **Permissionless** but buyer-bound. Never depends on admin liveness.
+
+Aborts with `ENotCancelled` if the sale is not in `Cancelled` phase.
+
+Aborts with `EReceiptSaleMismatch` if `receipt` was issued by a different sale.
+
+Aborts with `EBuyerOnly` if `ctx.sender()` is not the receipt's buyer.
+
+Aborts with `EWrongVault` if `vault` is not the one paired with this sale.
+
+Emits `Refunded`.
+
+
+
+Batches `refund` over several receipts, summing their payments into one balance. Aborts the whole call (releasing nothing) if any receipt is invalid. **Permissionless** but buyer-bound.
+
+Aborts with `ENotCancelled` if the sale is not in `Cancelled` phase.
+
+Aborts with `EReceiptSaleMismatch` if any receipt was issued by a different sale.
+
+Aborts with `EBuyerOnly` if `ctx.sender()` is not the buyer of any receipt. An empty `receipts` vector never triggers this.
+
+Aborts with `EWrongVault` if `vault` is not the one paired with this sale.
+
+Emits `Refunded` per receipt.
+
+
+
+Witness-gated, **freshness-enforced** quote constructor. The curve module calls this from its own `quote(..)`, passing the fully-computed `allocation`; the sale applies it verbatim and performs no pricing arithmetic of its own. Requires a `Curve` value, so a caller cannot mint a quote without the declaring curve module. The returned quote stamps the sale's current state version; `purchase` rejects it with `EStaleQuote` if an intervening same-PTB purchase has since advanced that version. A curve pricing from mutable sale state uses this; a rate-immune curve uses `mint_quote_unversioned`.
+
+Aborts with `EZeroPayment` if `payment` has zero value.
+
+Aborts with `EZeroAllocation` if `allocation` is zero.
+
+
+
+Witness-gated quote constructor that **opts out of freshness**. Identical to `mint_quote` except the returned quote carries no state-version stamp, so `purchase` never rejects it as stale - letting several quotes be minted and purchased in one PTB. Use only for a curve whose price does not depend on mutable sale state (e.g. `fixed_rate_curve`); a state-dependent curve must use `mint_quote`.
+
+Aborts with `EZeroPayment` if `payment` has zero value.
+
+Aborts with `EZeroAllocation` if `allocation` is zero.
+
+
+
+The total payment raised so far, in `PaymentCoin` units.
+
+
+
+The stored curve configuration. Opaque to the sale; interpreted by the declaring curve module.
+
+
+
+The configured hard cap (maximum raise), in `PaymentCoin` units.
+
+
+
+The configured soft cap (minimum raise to finalize); `0` means none.
+
+
+
+The start of the purchase window (ms).
+
+
+
+The end of the purchase window (ms).
+
+
+
+Whether purchases require an `AllowEntry` (allowlist mode).
+
+
+
+`Some(schedule)` if a vesting schedule was attached during `Init`, otherwise `None`. The bundle is the one supplied to `set_vesting_schedule`.
+
+
+
+Total inventory currently held (allocated plus unallocated).
+
+
+
+Sale tokens promised to outstanding (unredeemed) receipts.
+
+
+
+Unallocated inventory: `inventory_total - total_allocated`.
+
+
+
+Payment currently held as proceeds (before withdrawal or cancel).
+
+
+
+Whether a `purchase` would pass its phase and window checks right now.
+
+
+
+Whether `raised >= soft_cap`. Always `true` when no soft cap is configured.
+
+
+
+Whether `raised >= hard_cap` (sold out and able to finalize early).
+
+
+
+Whether the sale is in the `Init` phase (owned, under setup).
+
+
+
+Whether the sale is in the `Active` phase (shared and not yet terminal). Distinct from `is_open`, which additionally requires the current time to be within the purchase window.
+
+
+
+Whether the sale closed successfully (terminal `Finalized` phase).
+
+
+
+Whether the sale was cancelled (terminal `Cancelled` phase).
+
+
+
+`Some(id)` of the paired refund vault once one has been paired via `pair_refund_vault` (always the case from activation onward), otherwise `None`.
+
+
+
+The id of the sale this admin cap controls.
+
+
+
+The id of the sale this quote was minted for.
+
+
+
+The payment balance carried by this quote.
+
+
+
+The curve-computed allocation this quote will deliver on purchase.
+
+
+
+The maximum permitted sale duration in milliseconds - the `closes_at_ms - opens_at_ms` bound `create_sale` enforces (aborting with `ESaleDurationTooLong`).
+
+
+#### Events [!toc] [#prefunded_sale-Events]
+
+Every event is stamped with `sale_id`. Payloads are phantom-typed on `SaleCoin` and `PaymentCoin` so the event type identifies the coins involved.
+
+
+Emitted by `create_sale`.
+
+
+
+Emitted by `deposit` when sale tokens are added to inventory.
+
+
+
+Emitted by `set_per_buyer_cap`.
+
+
+
+Emitted by `set_vesting_schedule`.
+
+
+
+Emitted by `pair_refund_vault`.
+
+
+
+Emitted by `enable_allowlist`.
+
+
+
+Emitted by `share_and_activate` when the sale goes live.
+
+
+
+Emitted by `purchase` for each successful buy.
+
+
+
+Emitted by `finalize` when the sale closes successfully.
+
+
+
+Emitted by `cancel_after_close` and `cancel_emergency`. `reason` is a `CancelReason`.
+
+
+
+Emitted for each receipt redeemed through `claim` / `claim_all` and the vesting variants.
+
+
+
+Emitted by `refund` when a buyer recovers their payment.
+
+
+
+Emitted by `withdraw_proceeds`.
+
+
+
+Emitted by `withdraw_unsold_inventory`.
+
+
+#### Errors [!toc] [#prefunded_sale-Errors]
+
+
+The supplied `SaleAdminCap` was issued for a different sale.
+
+
+
+A redemption path was called by an address other than the receipt's buyer.
+
+
+
+`cancel_emergency` was called after the window closed; use `cancel_after_close`.
+
+
+
+`create_sale` was given `opens_at_ms >= closes_at_ms`.
+
+
+
+A `purchase` was attempted outside `[opens_at_ms, closes_at_ms]`.
+
+
+
+A close was attempted while the window is still open and the hard cap is not reached.
+
+
+
+`share_and_activate` was called after `closes_at_ms` had already passed.
+
+
+
+`create_sale` was given `hard_cap == 0`.
+
+
+
+`create_sale` was given `soft_cap > hard_cap`.
+
+
+
+A quote was requested for a zero-value payment.
+
+
+
+A purchase would push `raised + paid` past `u64::MAX`.
+
+
+
+A purchase would push `raised` past `hard_cap`. The cap is all-or-nothing.
+
+
+
+At activation, `inventory` did not cover the backing the curve's `ActivationTicket` requires.
+
+
+
+A purchase's `allocation` exceeded unallocated inventory. Only reachable via a dishonest curve.
+
+
+
+A purchase would push the buyer's cumulative payment past the per-buyer cap.
+
+
+
+A purchase's payment exceeded the consumed `AllowEntry`'s `max_amount`.
+
+
+
+`finalize` was called with `raised < soft_cap`.
+
+
+
+`cancel_emergency` was called on a sold-out sale (`raised >= hard_cap`); it must `finalize`.
+
+
+
+The sale requires an `AllowEntry` but `purchase` was called without one.
+
+
+
+The sale does not require an `AllowEntry` but `purchase` was given one.
+
+
+
+`enable_allowlist` was called a second time on the same sale.
+
+
+
+`pair_refund_vault` was called after a vault had already been paired.
+
+
+
+`share_and_activate` was called before a refund vault was paired.
+
+
+
+The vault passed to a sale operation is not the one paired with this sale.
+
+
+
+The vault offered to `pair_refund_vault` was not in the `Active` state.
+
+
+
+The vault offered to `pair_refund_vault` held a non-zero balance.
+
+
+
+A receipt passed to `claim` / `refund` was issued by a different sale.
+
+
+
+A quote passed to `purchase` was minted for a different sale.
+
+
+
+An activation ticket passed to `share_and_activate` was minted for a different sale.
+
+
+
+`set_per_buyer_cap` was called a second time on the same sale.
+
+
+
+`set_per_buyer_cap` was given `0`, which would block every buyer.
+
+
+
+`set_vesting_schedule` was called a second time on the same sale.
+
+
+
+Plain `claim` was called on a vesting sale; redeem via `claim_into_vesting`.
+
+
+
+`claim_into_vesting` was called on a sale with no vesting schedule; use `claim`.
+
+
+
+A setup operation required the `Init` phase but the sale was past it.
+
+
+
+An operation required the `Active` phase: the sale was not yet activated, or has already closed.
+
+
+
+An operation required the `Finalized` phase (e.g. a claim before finalize).
+
+
+
+An operation required the `Cancelled` phase (e.g. a refund before cancel).
+
+
+
+An operation required a terminal phase (`Finalized` or `Cancelled`).
+
+
+
+`create_sale` was given a window longer than `max_sale_duration_ms()`.
+
+
+
+A quote was minted with a zero `allocation`; a paying buyer must receive tokens.
+
+
+
+A freshness-enforced quote was consumed by `purchase` after an intervening same-PTB purchase advanced the sale's state since the quote was minted.
+
+
+
+`cancel_after_close` was called on a sale with no soft cap configured; such a sale can only `finalize`.
+
+
+
+`cancel_after_close` was called while the purchase window was still open.
+
+
+
+A cancel was attempted on a sale that has reached its soft cap; a goal-reaching sale can only `finalize`.
+
+
+
+The vault cap passed to `pair_refund_vault` does not control the provided vault.
+
+
+### `fixed_rate_curve` [toc] [#fixed_rate_curve]
+
+
+
+```move
+use openzeppelin_sale::fixed_rate_curve;
+```
+
+The built-in pricing curve: `allocation = paid * rate`, fixed for the whole sale. Declares the `FixedRateCurve` witness and its `Params`, and the integrator API around them. Because the witness constructor is module-private, only this module can mint a `Quote` or `ActivationTicket` for a `FixedRateCurve` sale - so such a sale is un-priceable by any other code. The `params` constructor is the seam a protocol driving `prefunded_sale::create_sale` directly uses to build the config.
+
+Types
+
+- [`FixedRateCurve`](#fixed_rate_curve-FixedRateCurve)
+- [`Params`](#fixed_rate_curve-Params)
+
+Functions
+
+- [`params(rate)`](#fixed_rate_curve-params)
+- [`activation_ticket(sale)`](#fixed_rate_curve-activation_ticket)
+- [`quote(sale, balance)`](#fixed_rate_curve-quote)
+- [`rate(sale)`](#fixed_rate_curve-rate)
+
+Errors
+
+- [`ERateZero`](#fixed_rate_curve-ERateZero)
+- [`ERequiredInventoryOverflow`](#fixed_rate_curve-ERequiredInventoryOverflow)
+- [`EAllocationOverflow`](#fixed_rate_curve-EAllocationOverflow)
+
+#### Types [!toc] [#fixed_rate_curve-Types]
+
+
+The curve witness. Field-less, `drop`-only, with a module-private constructor, so no other module can mint a `Quote` or activation ticket. This is the security boundary that makes the curve first-party and trusted.
+
+
+
+The fixed-rate parameters stored on the sale via `curve_params`: `rate`, the sale tokens (smallest units) allocated per 1 payment-coin smallest unit. Obtain a validated value via `params`.
+
+
+#### Functions [!toc] [#fixed_rate_curve-Functions]
+
+
+Builds a validated `Params`. The only way to obtain a `Params` outside this module, so a protocol driving `create_sale` directly can build the config itself.
+
+Aborts with `ERateZero` if `rate == 0`.
+
+
+
+Mints the `ActivationTicket` that `share_and_activate` consumes, committing the inventory backing this curve requires: `hard_cap * rate`. `Init`-only.
+
+Aborts with `ERequiredInventoryOverflow` if `hard_cap * rate` would exceed `u64::MAX`.
+
+Aborts with `prefunded_sale::ENotInit` if the sale is not in `Init` phase.
+
+
+
+Mints a `Quote` for a buyer's payment `balance`, allocating `balance.value() * rate`. Routes through `prefunded_sale::mint_quote_unversioned` - this curve's price is immune to sale-state changes, so several quotes can be minted and purchased in one PTB. The `Quote` carries the balance through to `purchase`, welding pricing and funds together.
+
+Aborts with `EAllocationOverflow` if `balance.value() * rate` would exceed `u64::MAX`.
+
+Aborts with `prefunded_sale::EZeroPayment` if `balance` has zero value.
+
+
+
+The configured fixed rate: sale tokens allocated per 1 payment-coin unit.
+
+
+#### Errors [!toc] [#fixed_rate_curve-Errors]
+
+
+`params` was called with `rate == 0`, which allocates nothing for any payment.
+
+
+
+`hard_cap * rate` would exceed `u64::MAX`, so the required inventory backing cannot be represented.
+
+
+
+`balance.value() * rate` would exceed `u64::MAX` in `quote`, so the allocation this curve prices cannot be represented.
+
+
+### `refund_vault` [toc] [#refund_vault]
+
+
+
+```move
+use openzeppelin_sale::refund_vault;
+```
+
+A generic refundable escrow over `Balance`, with no knowledge of sales - usable standalone. Every mutation requires the matching `RefundVaultCap
`. State transitions one way: `Active -> Refunding` (depositors claim individually) or `Active -> Closed` (the controller withdraws all). When paired with a sale, `pair_refund_vault` consumes the cap into the sale, and from then on only the sale's gated functions drive the vault.
+
+Types
+
+- [`RefundVault
`](#refund_vault-RefundVault)
+- [`RefundVaultCap
`](#refund_vault-RefundVaultCap)
+- [`VaultState`](#refund_vault-VaultState)
+
+Functions
+
+- [`new(ctx)`](#refund_vault-new)
+- [`share(vault)`](#refund_vault-share)
+- [`deposit(vault, cap, funds)`](#refund_vault-deposit)
+- [`flip_to_refunding(vault, cap)`](#refund_vault-flip_to_refunding)
+- [`flip_to_closed(vault, cap)`](#refund_vault-flip_to_closed)
+- [`release_balance(vault, cap, amount)`](#refund_vault-release_balance)
+- [`withdraw_all(vault, cap)`](#refund_vault-withdraw_all)
+- [`state(vault)`](#refund_vault-state)
+- [`value(vault)`](#refund_vault-value)
+- [`cap_vault_id(cap)`](#refund_vault-cap_vault_id)
+- [`is_active(vault)`](#refund_vault-is_active)
+- [`is_refunding(vault)`](#refund_vault-is_refunding)
+- [`is_closed(vault)`](#refund_vault-is_closed)
+
+Events
+
+- [`RefundVaultCreated`](#refund_vault-RefundVaultCreated)
+- [`VaultDeposited`](#refund_vault-VaultDeposited)
+- [`VaultStateChanged`](#refund_vault-VaultStateChanged)
+- [`VaultReleased`](#refund_vault-VaultReleased)
+
+Errors
+
+- [`ENotActiveState`](#refund_vault-ENotActiveState)
+- [`ENotRefundingState`](#refund_vault-ENotRefundingState)
+- [`ENotClosedState`](#refund_vault-ENotClosedState)
+- [`EWrongVaultCap`](#refund_vault-EWrongVaultCap)
+- [`EInsufficientLocked`](#refund_vault-EInsufficientLocked)
+- [`EZeroRelease`](#refund_vault-EZeroRelease)
+
+#### Types [!toc] [#refund_vault-Types]
+
+
+A refundable escrow holding a locked `Balance` and a lifecycle state. `key`-only; share it with `share`.
+
+
+
+Controller capability, bound to a vault by id and phantom-typed on `P` so it cannot be paired with a vault or sale of a different payment coin.
+
+
+
+The vault's lifecycle state: `Active` (accepting deposits), `Refunding` (per-amount releases), or `Closed` (a single full withdrawal). Transitions are one-way from `Active`.
+
+
+#### Functions [!toc] [#refund_vault-Functions]
+
+
+Creates a fresh, empty vault in `Active` state and its controller cap. The typical paired-sale flow is `new` -> pair with a sale -> the sale shares it on activation.
+
+Emits `RefundVaultCreated`.
+
+
+
+Shares a vault. Provided because `RefundVault` is `key`-only, so external modules cannot share it directly.
+
+
+
+Adds funds to the locked balance. A zero-value deposit is a no-op. Vault must be `Active`.
+
+Aborts with `EWrongVaultCap` if `cap` does not control `vault`.
+
+Aborts with `ENotActiveState` if `vault` is not `Active`.
+
+Emits `VaultDeposited` (only when the amount is non-zero).
+
+
+
+Transitions `Active -> Refunding`, enabling per-amount releases.
+
+Aborts with `EWrongVaultCap` if `cap` does not control `vault`.
+
+Aborts with `ENotActiveState` if `vault` is not `Active`.
+
+Emits `VaultStateChanged`.
+
+
+
+Transitions `Active -> Closed`, enabling `withdraw_all`.
+
+Aborts with `EWrongVaultCap` if `cap` does not control `vault`.
+
+Aborts with `ENotActiveState` if `vault` is not `Active`.
+
+Emits `VaultStateChanged`.
+
+
+
+Releases a specific `amount` from the locked balance. Vault must be `Refunding`, and `amount` must be non-zero.
+
+Aborts with `EWrongVaultCap` if `cap` does not control `vault`.
+
+Aborts with `ENotRefundingState` if `vault` is not `Refunding`.
+
+Aborts with `EZeroRelease` if `amount` is zero.
+
+Aborts with `EInsufficientLocked` if `amount` exceeds the locked balance.
+
+Emits `VaultReleased`.
+
+
+
+Withdraws the entire locked balance. Vault must be `Closed`.
+
+Aborts with `EWrongVaultCap` if `cap` does not control `vault`.
+
+Aborts with `ENotClosedState` if `vault` is not `Closed`.
+
+Emits `VaultReleased` (only when the amount is non-zero).
+
+
+
+The vault's current state.
+
+
+
+The locked balance amount.
+
+
+
+The id of the vault this cap controls.
+
+
+
+True if the vault is `Active`.
+
+
+
+True if the vault is `Refunding`.
+
+
+
+True if the vault is `Closed`.
+
+
+#### Events [!toc] [#refund_vault-Events]
+
+Every event is stamped with `vault_id`.
+
+
+Emitted by `new`. Carries the `cap_id` of the controller cap minted alongside the vault.
+
+
+
+Emitted by `deposit` when funds are added.
+
+
+
+Emitted by `flip_to_refunding` and `flip_to_closed`.
+
+
+
+Emitted by `release_balance` and `withdraw_all` when funds leave the vault.
+
+
+#### Errors [!toc] [#refund_vault-Errors]
+
+
+A state-gated operation (deposit, `flip_to_*`) required the `Active` state.
+
+
+
+`release_balance` was called while the vault was not `Refunding`.
+
+
+
+`withdraw_all` was called while the vault was not `Closed`.
+
+
+
+The supplied cap does not control this vault.
+
+
+
+A release requested more than the vault's locked balance.
+
+
+
+`release_balance` was called with a zero `amount`.
+
+
+### `allowlist` [toc] [#allowlist]
+
+
+
+```move
+use openzeppelin_sale::allowlist;
+```
+
+A typed slot for compliance hooks. The library ships **no** verification logic; it defines two types your compliance module wires its own scheme against: `AllowlistAdmin`, the owned authority to approve buyers (issued once by `enable_allowlist`), and `AllowEntry`, a single-use, ability-less compliance ticket your module mints per verified buyer and `purchase` consumes in the same PTB.
+
+Types
+
+- [`AllowEntry`](#allowlist-AllowEntry)
+- [`AllowlistAdmin`](#allowlist-AllowlistAdmin)
+
+Functions
+
+- [`new_entry(admin, buyer, max_amount)`](#allowlist-new_entry)
+- [`admin_sale_id(admin)`](#allowlist-admin_sale_id)
+
+Errors
+
+- [`EWrongSaleId`](#allowlist-EWrongSaleId)
+- [`EWrongBuyer`](#allowlist-EWrongBuyer)
+
+#### Types [!toc] [#allowlist-Types]
+
+
+A single-use compliance ticket. No abilities, so it cannot be stored, copied, replayed, or transferred - it must be minted and consumed in the same transaction. Carries the `sale_id`, the approved `buyer`, and a per-entry `max_amount` (`0` = no per-entry cap).
+
+
+
+The authority to mint `AllowEntry` for a specific sale. Owned and transferable so the consumer can wrap it in an access-controlled compliance module. Losing it bricks the allowlist sale.
+
+
+#### Functions [!toc] [#allowlist-Functions]
+
+
+Mints a fresh entry, called by the compliance module after running its verification. `max_amount` is **per-entry, not cumulative** (`0` = no per-entry cap); for a cumulative bound use the sale's `set_per_buyer_cap`.
+
+
+
+The id of the sale this admin gates entries for.
+
+
+#### Errors [!toc] [#allowlist-Errors]
+
+
+The consumed `AllowEntry` was issued for a different sale than the one consuming it.
+
+
+
+The consumed `AllowEntry`'s `buyer` does not match the transaction sender.
+
+
+### `receipt` [toc] [#receipt]
+
+
+
+```move
+use openzeppelin_sale::receipt;
+```
+
+The per-buyer claim ticket issued by every sale flavor. Records one purchase - the issuing sale, buyer, amount paid, token allocation, and purchase time - and is the object the buyer later redeems via `claim` or `refund`. Construction, delivery, and consumption are package-internal; integrators only read it through the getters below.
+
+`Receipt` has the `key` ability **only** (no `store`), so it cannot be transferred with `public_transfer`, wrapped, or stored as a field elsewhere. The single transfer path is delivery to the buyer at purchase time, and `claim` / `refund` assert the sender is the recorded buyer - no wallet rotation, and KYC at purchase carries through to distribution.
+
+Types
+
+- [`Receipt`](#receipt-Receipt)
+
+Functions
+
+- [`sale_id(r)`](#receipt-sale_id)
+- [`buyer(r)`](#receipt-buyer)
+- [`paid(r)`](#receipt-paid)
+- [`allocation(r)`](#receipt-allocation)
+- [`purchased_at_ms(r)`](#receipt-purchased_at_ms)
+
+#### Types [!toc] [#receipt-Types]
+
+
+The non-transferable, buyer-bound claim ticket. `key`-only. One receipt per purchase; a buyer with several purchases holds several receipts.
+
+
+#### Functions [!toc] [#receipt-Functions]
+
+
+The id of the sale that issued this receipt.
+
+
+
+The address that purchased, and the only address that may redeem this receipt.
+
+
+
+The payment amount backing this receipt. A refund pays back exactly this.
+
+
+
+The sale-token allocation promised by this receipt. A claim returns exactly this.
+
+
+
+The timestamp (ms) at which the purchase happened.
+
diff --git a/content/contracts-sui/1.x/api/timelock.mdx b/content/contracts-sui/1.x/api/timelock.mdx
new file mode 100644
index 00000000..11c0ae4a
--- /dev/null
+++ b/content/contracts-sui/1.x/api/timelock.mdx
@@ -0,0 +1,828 @@
+---
+title: Timelock API Reference
+---
+
+This page documents the public API of `openzeppelin_timelock` for OpenZeppelin Contracts for Sui `v1.x`.
+
+### `timelock` [toc] [#timelock]
+
+
+
+```move
+use openzeppelin_timelock::timelock;
+```
+
+Hash-keyed operation timelock with typed, onchain operation parameters and a typed hot-potato execution ticket. It is a Sui-native take on OpenZeppelin's [`TimelockController`](https://docs.openzeppelin.com/contracts/5.x/api/governance#TimelockController). A proposer schedules an operation with its typed params stored onchain under a deterministic, off-chain reproducible id. After the per-op delay elapses and before the grace window closes (the ready window is half-open, `[ready_at_ms, expires_at_ms)`, and both bounds are locked at schedule time), an executor executes it, minting an `ExecutionTicket`, a no-ability hot potato that must be consumed by `consume` in the same PTB. `Done` is sticky, so operations are non-replayable.
+
+Authorization comes from a hard dependency on `openzeppelin_access`: every gated entry takes `&Auth` and asserts `Role` matches one of the four role types bound into the `Timelock` at creation (`proposer_role` / `executor_role` / `canceller_role` / `admin_role`). Role types are immutable; membership is managed in the consumer's `AccessControl`. Configuration (`min_delay_ms`, `grace_period_ms`, `open_executor`) is mutated only through the admin-gated `schedule_* / execute_*` pairs, so every configuration change is itself timelocked.
+
+The raw entries (`schedule` / `execute` / `execute_open` / `cancel`) do NOT bind the call to a specific `Timelock` id. A consumer using them must pin the canonical timelock id itself, or an actor holding the roles can route a self-created zero-delay timelock through the consumer. Prefer the `*_with` entries, which take a stored `OperationCap` that enforces the canonical-timelock binding structurally. `Action` witness types must be `drop`-only, one per consume function, and their construction must never leak across the package boundary.
+
+Types
+
+- [`Timelock`](#timelock-Timelock)
+- [`OpTimestamp`](#timelock-OpTimestamp)
+- [`ExecutionTicket`](#timelock-ExecutionTicket)
+- [`OperationCap`](#timelock-OperationCap)
+- [`IdInput`](#timelock-IdInput)
+- [`OperationState`](#timelock-OperationState)
+- [`UpdateMinDelayWitness`](#timelock-UpdateMinDelayWitness)
+- [`UpdateGracePeriodWitness`](#timelock-UpdateGracePeriodWitness)
+- [`SetOpenExecutorWitness`](#timelock-SetOpenExecutorWitness)
+
+Functions
+
+- [`new(min_delay_ms, grace_period_ms, ctx)`](#timelock-new)
+- [`new_shared(min_delay_ms, grace_period_ms, ctx)`](#timelock-new_shared)
+- [`share(self)`](#timelock-share)
+- [`hash_operation(timelock_id, payload_digest, predecessor, salt)`](#timelock-hash_operation)
+- [`schedule(self, _proposer_auth, params, predecessor, salt, delay_ms, clock, ctx)`](#timelock-schedule)
+- [`execute(self, _executor_auth, id, clock, ctx)`](#timelock-execute)
+- [`execute_open(self, id, clock, ctx)`](#timelock-execute_open)
+- [`consume(self, ticket, _witness)`](#timelock-consume)
+- [`cancel(self, _canceller_auth, id, ctx)`](#timelock-cancel)
+- [`new_operation_cap(self)`](#timelock-new_operation_cap)
+- [`operation_cap_timelock_id(cap)`](#timelock-operation_cap_timelock_id)
+- [`destroy_operation_cap(cap)`](#timelock-destroy_operation_cap)
+- [`schedule_with(self, cap, proposer_auth, params, predecessor, salt, delay_ms, clock, ctx)`](#timelock-schedule_with)
+- [`execute_with(self, cap, executor_auth, id, clock, ctx)`](#timelock-execute_with)
+- [`execute_open_with(self, cap, id, clock, ctx)`](#timelock-execute_open_with)
+- [`cancel_with(self, cap, canceller_auth, id, ctx)`](#timelock-cancel_with)
+- [`schedule_update_min_delay(self, _admin_auth, new_min_delay_ms, predecessor, salt, delay_ms, clock, ctx)`](#timelock-schedule_update_min_delay)
+- [`execute_update_min_delay(self, _admin_auth, id, clock, ctx)`](#timelock-execute_update_min_delay)
+- [`schedule_update_grace_period(self, _admin_auth, new_grace_period_ms, predecessor, salt, delay_ms, clock, ctx)`](#timelock-schedule_update_grace_period)
+- [`execute_update_grace_period(self, _admin_auth, id, clock, ctx)`](#timelock-execute_update_grace_period)
+- [`schedule_set_open_executor(self, _admin_auth, value, predecessor, salt, delay_ms, clock, ctx)`](#timelock-schedule_set_open_executor)
+- [`execute_set_open_executor(self, _admin_auth, id, clock, ctx)`](#timelock-execute_set_open_executor)
+- [`min_delay_ms(self)`](#timelock-min_delay_ms)
+- [`grace_period_ms(self)`](#timelock-grace_period_ms)
+- [`is_open_executor(self)`](#timelock-is_open_executor)
+- [`max_delay_ms()`](#timelock-max_delay_ms)
+- [`domain_tag()`](#timelock-domain_tag)
+- [`proposer_role(self)`](#timelock-proposer_role)
+- [`executor_role(self)`](#timelock-executor_role)
+- [`canceller_role(self)`](#timelock-canceller_role)
+- [`admin_role(self)`](#timelock-admin_role)
+- [`is_operation(self, id)`](#timelock-is_operation)
+- [`is_operation_pending(self, id, clock)`](#timelock-is_operation_pending)
+- [`is_operation_ready(self, id, clock)`](#timelock-is_operation_ready)
+- [`is_operation_expired(self, id, clock)`](#timelock-is_operation_expired)
+- [`is_operation_done(self, id)`](#timelock-is_operation_done)
+- [`operation_state(self, id, clock)`](#timelock-operation_state)
+- [`operation_params(self, id)`](#timelock-operation_params)
+
+Events
+
+- [`TimelockCreated`](#timelock-TimelockCreated)
+- [`OperationScheduled`](#timelock-OperationScheduled)
+- [`OperationExecuted`](#timelock-OperationExecuted)
+- [`OperationCancelled`](#timelock-OperationCancelled)
+- [`MinDelayChanged`](#timelock-MinDelayChanged)
+- [`GracePeriodChanged`](#timelock-GracePeriodChanged)
+- [`OpenExecutorChanged`](#timelock-OpenExecutorChanged)
+
+Errors
+
+- [`EWrongRole`](#timelock-EWrongRole)
+- [`EInvalidConfig`](#timelock-EInvalidConfig)
+- [`EDelayTooShort`](#timelock-EDelayTooShort)
+- [`EOperationAlreadyExists`](#timelock-EOperationAlreadyExists)
+- [`EOperationUnset`](#timelock-EOperationUnset)
+- [`EOperationAlreadyDone`](#timelock-EOperationAlreadyDone)
+- [`EDelayNotElapsed`](#timelock-EDelayNotElapsed)
+- [`EOperationExpired`](#timelock-EOperationExpired)
+- [`EPredecessorNotDone`](#timelock-EPredecessorNotDone)
+- [`EPredecessorUnset`](#timelock-EPredecessorUnset)
+- [`EPredecessorIsSelf`](#timelock-EPredecessorIsSelf)
+- [`EOpenExecutorDisabled`](#timelock-EOpenExecutorDisabled)
+- [`EWrongTimelock`](#timelock-EWrongTimelock)
+- [`EScheduleOverflow`](#timelock-EScheduleOverflow)
+- [`EWrongParams`](#timelock-EWrongParams)
+- [`EWrongAction`](#timelock-EWrongAction)
+- [`EInvalidPredecessor`](#timelock-EInvalidPredecessor)
+
+#### Types [!toc] [#timelock-Types]
+
+
+The per-protocol timelock registry. Shared object.
+
+`key`-only (no `store`): the only legal disposition is sharing, so a `Timelock` is always a shared object and can never be wrapped or address-owned. It stores the configuration (`min_delay_ms`, `grace_period_ms`, `open_executor`), the four bound role types, and a table mapping operation ids (keccak256, 32 bytes) to per-op state; absence of an id means `Unset`. Typed operation params are stored as dynamic fields directly on its `id`, keyed by the operation id.
+
+
+
+Stored per-operation state: `Pending { ready_at_ms, expires_at_ms, predecessor, action }` or `Done`.
+
+Timestamps and predecessor are locked at schedule time, so later `min_delay_ms` / `grace_period_ms` changes never move an existing op. `action` records the scheduled `Action` type so `execute` can re-check it (the id commits the `Action`, but execute takes the id directly).
+
+
+
+Deferred authorization for one typed operation, carrying the scheduled params.
+
+Hot potato with NO abilities, so it must be consumed in the same transaction it is minted. Minted by `execute` / `execute_open` (or in-module for self-admin ops); destroyed only by `consume` (or in-module for self-admin ops).
+
+
+
+A stored handle that binds an operation kind `(Action, Params)` to ONE `Timelock` instance. Carries NO authority on its own; combine it with `&Auth` for role authorization.
+
+Mint one per operation kind at consumer `init` via `new_operation_cap` (against your canonical timelock) and store it inside the object you protect; the `*_with` entries then enforce the canonical-timelock binding for you, so you never hand-write the `object::id` assert. `store`-only: it cannot be copied or dropped, so it lives in your protected object.
+
+
+
+BCS preimage of every operation id, with fields `action`, `payload_digest`, `predecessor`, `salt`, `timelock_id` (in declaration order).
+
+Including `timelock_id` makes identical inputs hash to different ids across `Timelock` instances.
+
+
+
+Observable operation state, computed from the stored `OpTimestamp` plus the `Clock`: `Unset`, `Waiting { ready_at_ms, expires_at_ms }`, `Ready { ready_at_ms, expires_at_ms }`, `Expired { ready_at_ms, expires_at_ms }`, or `Done`.
+
+
+
+Module-private marker witness (the `Action`) for the self-administered `min_delay_ms` pipeline. Only this module can construct it, so only its own execute path can mint/redeem the corresponding `ExecutionTicket`.
+
+
+
+Module-private marker witness (the `Action`) for the self-administered `grace_period_ms` pipeline. Only this module can construct it, so only its own execute path can mint/redeem the corresponding `ExecutionTicket`.
+
+
+
+Module-private marker witness (the `Action`) for the self-administered `open_executor` pipeline. Only this module can construct it, so only its own execute path can mint/redeem the corresponding `ExecutionTicket`.
+
+
+#### Functions [!toc] [#timelock-Functions]
+
+
+Mints a fresh `Timelock` bound to the four consumer role types. `min_delay_ms` is the floor on every operation's delay (may be 0); `grace_period_ms` is the window, after an op becomes ready, during which it stays executable.
+
+Returns the unshared `Timelock`; the caller must dispose of it via `share` (or use `new_shared`).
+
+Aborts with `EInvalidConfig` if `min_delay_ms > MAX_DELAY_MS`, or if `grace_period_ms` is zero or greater than `MAX_DELAY_MS`.
+
+Emits a `TimelockCreated` event.
+
+
+
+Convenience constructor that shares the `Timelock` and returns its `ID`.
+
+Aborts with `EInvalidConfig` on the same config bounds as `new`.
+
+Emits a `TimelockCreated` event.
+
+
+
+Shares a `Timelock`. The only legal way to dispose of a value returned by `new`.
+
+
+
+Pure operation-id derivation from a payload digest. Off-chain tooling computes `payload_digest = keccak256(bcs(params))` and reproduces the id from there. Returns the 32-byte operation id.
+
+The id is `keccak256(DOMAIN_TAG || bcs(IdInput))`, byte-exact:
+
+- `DOMAIN_TAG` is the ASCII string `OZ_Timelock_1_Sui` (readable via `domain_tag`). It is not length-prefixed: the preimage starts with its raw 17 bytes.
+- `IdInput` fields are BCS-encoded in declaration order: `action`, `payload_digest`, `predecessor`, `salt`, `timelock_id`.
+- `action` is `type_name::with_original_ids()`: the fully-qualified type string (`::::`, 64 lowercase hex chars, no `0x` prefix, type arguments included), resolved with the ORIGINAL package address so ids stay stable across package upgrades. Its BCS is a ULEB128 length prefix followed by those ASCII bytes.
+- `payload_digest`, `predecessor`, and `salt` are ULEB128-length-prefixed byte vectors (an empty `predecessor` encodes as the single byte `0x00`); `timelock_id` is the raw 32 address bytes, unprefixed.
+
+Cross-check any off-chain implementation against this function before relying on predicted ids.
+
+
+
+Schedules an operation, storing its typed `params` onchain. Caller must hold the proposer role. `predecessor` is the id of an op that must be `Done` before this one (or empty for none); `salt` disambiguates otherwise-identical operations; `delay_ms` must be at least `min_delay_ms`. Returns the operation id (pass to `execute` / `cancel`). This raw entry does not bind the `Timelock` id. Prefer `schedule_with`.
+
+Aborts with `EWrongRole` if `Role` is not the bound `proposer_role`.
+
+Aborts with `EDelayTooShort` if `delay_ms < min_delay_ms`.
+
+Aborts with `EScheduleOverflow` if `now + delay_ms` (or that sum plus `grace_period_ms`) overflows u64.
+
+Aborts with `EInvalidPredecessor` if `predecessor` is non-empty and not a 32-byte id.
+
+Aborts with `EPredecessorIsSelf` if `predecessor` equals the computed id (defense in depth; unreachable in practice because the id hashes over `predecessor`).
+
+Aborts with `EOperationAlreadyExists` if the id is already scheduled.
+
+Emits an `OperationScheduled` event.
+
+
+
+Executes a ready operation by id. Caller must hold the executor role. Marks the op `Done` (sticky), removes the stored params, and returns an `ExecutionTicket` carrying them: a no-ability hot potato that must be consumed by `consume` in the same PTB. This raw entry does not bind the `Timelock` id. Prefer `execute_with`.
+
+Aborts with `EWrongRole` if `Role` is not the bound `executor_role`.
+
+Aborts with `EOperationUnset` if no operation with this id exists.
+
+Aborts with `EOperationAlreadyDone` if the operation has already been executed.
+
+Aborts with `EDelayNotElapsed` if the operation's delay has not elapsed yet.
+
+Aborts with `EOperationExpired` if the operation's grace window has closed.
+
+Aborts with `EPredecessorUnset` if the operation names a predecessor that is not in the timelock.
+
+Aborts with `EPredecessorNotDone` if the operation names a predecessor that is not yet executed.
+
+Aborts with `EWrongAction` if `Action` does not match the type the operation was scheduled with.
+
+Aborts with `EWrongParams` if `Params` does not match the type the operation was scheduled with.
+
+Emits an `OperationExecuted` event.
+
+
+
+Executes a ready operation in open-executor mode (no `Auth` required). Open mode lifts only the executor-role gate on minting the ticket; consumption stays witness-gated, so a caller who cannot construct `Action` cannot `consume` the returned ticket. Since it has no abilities, their transaction aborts and reverts the state change atomically.
+
+Aborts with `EOpenExecutorDisabled` if `open_executor` is `false`.
+
+Aborts on the same operation-state conditions as `execute`.
+
+Emits an `OperationExecuted` event.
+
+
+
+Redeems an execution ticket. Two gates fire: timelock-binding and witness-by-value (`Action` is consumed, so only a module that can construct `Action` can call this). Returns `(op_id, params)`, the operation id and the exact typed params committed at schedule time; there is no payload to re-supply or mismatch.
+
+Aborts with `EWrongTimelock` if the ticket was minted by a different `Timelock`.
+
+
+
+Cancels a scheduled operation by id, dropping its stored params. Caller must hold the canceller role. Allowed on `Waiting` / `Ready` / `Expired` operations; not on `Done`. The operation's `Params` type must be named so the stored params can be cleaned up. This raw entry does not bind the `Timelock` id. Prefer `cancel_with`.
+
+Aborts with `EWrongRole` if `Role` is not the bound `canceller_role`.
+
+Aborts with `EOperationUnset` if no such operation exists.
+
+Aborts with `EOperationAlreadyDone` if the operation was already executed.
+
+Aborts with `EWrongParams` if `Params` does not match the type the operation was scheduled with.
+
+Emits an `OperationCancelled` event.
+
+
+
+Mints an `OperationCap` binding `(Action, Params)` to this `Timelock`. Permissionless and authority-free: call it once at consumer `init` against your canonical timelock and store the result in the object you protect.
+
+
+
+Returns the `Timelock` id an `OperationCap` is bound to.
+
+
+
+Destroys an `OperationCap`, e.g. when decommissioning the object that stored it. An `OperationCap` has `store` but not `drop`, so it cannot be discarded implicitly; this is the explicit disposal. The cap carries no authority; a fresh one is always mintable via `new_operation_cap`.
+
+
+
+Like `schedule`, but the `OperationCap` enforces the canonical-timelock binding, and `Action` / `Params` infer from the cap (zero explicit type args at the call site). Recommended over the raw entry. Returns the operation id.
+
+Aborts with `EWrongTimelock` if `cap` is not bound to `self`; plus the same aborts as `schedule`.
+
+Emits an `OperationScheduled` event.
+
+
+
+Like `execute`, but the `OperationCap` enforces the canonical-timelock binding. Recommended over the raw entry. Returns an `ExecutionTicket` that must be consumed in the same PTB.
+
+Aborts with `EWrongTimelock` if `cap` is not bound to `self`; plus the same aborts as `execute`.
+
+Emits an `OperationExecuted` event.
+
+
+
+Like `execute_open`, but the `OperationCap` enforces the canonical-timelock binding. Returns an `ExecutionTicket` that must be consumed in the same PTB.
+
+Aborts with `EWrongTimelock` if `cap` is not bound to `self`; plus the same aborts as `execute_open`.
+
+Emits an `OperationExecuted` event.
+
+
+
+Like `cancel`, but the `OperationCap` enforces the canonical-timelock binding. Recommended over the raw entry.
+
+Aborts with `EWrongTimelock` if `cap` is not bound to `self`; plus the same aborts as `cancel`.
+
+Emits an `OperationCancelled` event.
+
+
+
+Schedules a `min_delay_ms` change (stored as the operation's params). Admin-gated. Returns the operation id (pass to `execute_update_min_delay`).
+
+Aborts with `EWrongRole` if `Role` is not the bound `admin_role`.
+
+Aborts with `EInvalidConfig` if `new_min_delay_ms > MAX_DELAY_MS`.
+
+Aborts on the scheduling conditions of `schedule` (`EDelayTooShort`, `EScheduleOverflow`, `EInvalidPredecessor`, `EPredecessorIsSelf` (unreachable in practice), `EOperationAlreadyExists`).
+
+Emits an `OperationScheduled` event.
+
+
+
+Executes a scheduled `min_delay_ms` change by id. Admin-gated. Applies the stored value; the ticket is minted and consumed in-module (the marker witness is only constructible here).
+
+Aborts with `EWrongRole` if `Role` is not the bound `admin_role`.
+
+Aborts on the same conditions as `execute`, apart from its role check.
+
+Aborts with `EInvalidConfig` if the stored `new_min_delay_ms` exceeds `MAX_DELAY_MS`. The bound is re-asserted at apply time because the op can be staged through the generic `schedule`.
+
+Emits an `OperationExecuted` event, and a `MinDelayChanged` event when the configured value actually changes (a no-op update emits no `MinDelayChanged`).
+
+
+
+Schedules a `grace_period_ms` change. Admin-gated. Returns the operation id (pass to `execute_update_grace_period`).
+
+Aborts with `EWrongRole` if `Role` is not the bound `admin_role`.
+
+Aborts with `EInvalidConfig` if `new_grace_period_ms` is zero or greater than `MAX_DELAY_MS`.
+
+Aborts on the scheduling conditions of `schedule` (`EDelayTooShort`, `EScheduleOverflow`, `EInvalidPredecessor`, `EPredecessorIsSelf` (unreachable in practice), `EOperationAlreadyExists`).
+
+Emits an `OperationScheduled` event.
+
+
+
+Executes a scheduled `grace_period_ms` change by id. Admin-gated. Applies the stored value.
+
+Aborts with `EWrongRole` if `Role` is not the bound `admin_role`.
+
+Aborts on the same conditions as `execute`, apart from its role check.
+
+Aborts with `EInvalidConfig` if the stored `new_grace_period_ms` is zero or exceeds `MAX_DELAY_MS`. The bound is re-asserted at apply time because the op can be staged through the generic `schedule` (a zero grace period would brick the timelock with an empty ready window).
+
+Emits an `OperationExecuted` event, and a `GracePeriodChanged` event when the configured value actually changes (a no-op update emits no `GracePeriodChanged`).
+
+
+
+Schedules an `open_executor` toggle. Admin-gated. `value` is the setting applied when the scheduled op executes. Returns the operation id (pass to `execute_set_open_executor`).
+
+Aborts with `EWrongRole` if `Role` is not the bound `admin_role`.
+
+Aborts on the scheduling conditions of `schedule` (`EDelayTooShort`, `EScheduleOverflow`, `EInvalidPredecessor`, `EPredecessorIsSelf` (unreachable in practice), `EOperationAlreadyExists`).
+
+Emits an `OperationScheduled` event.
+
+
+
+Executes a scheduled `open_executor` toggle by id. Admin-gated.
+
+Aborts with `EWrongRole` if `Role` is not the bound `admin_role`.
+
+Aborts on the same conditions as `execute`, apart from its role check.
+
+Emits an `OperationExecuted` event, and an `OpenExecutorChanged` event when the setting actually toggles (re-applying the current setting emits no `OpenExecutorChanged`).
+
+
+
+Returns the configured floor on every operation's delay.
+
+
+
+Returns the configured window, after an op becomes ready, during which it stays executable.
+
+
+
+Returns whether open-executor mode is enabled (anyone may call `execute_open`).
+
+
+
+Returns the upper bound on the configured `min_delay_ms` and `grace_period_ms` (60 days in milliseconds). Does NOT bound the per-call `delay_ms`.
+
+
+
+Returns the domain separation tag prefixed to every operation-id preimage: the ASCII string `OZ_Timelock_1_Sui`. `OZ_Timelock` names the primitive, `1` versions the preimage format, `Sui` pins the chain. Locked at publication.
+
+
+
+Returns the role type bound for scheduling.
+
+
+
+Returns the role type bound for executing.
+
+
+
+Returns the role type bound for cancelling.
+
+
+
+Returns the role type bound for the self-administered configuration pipeline.
+
+
+
+Returns `true` if an operation with this id exists in the timelock (any state but `Unset`: `Waiting`, `Ready`, `Expired`, or `Done`); `false` otherwise.
+
+
+
+Returns `true` if the operation is on the execution track: `Waiting` or `Ready`. `Expired`, `Done`, and `Unset` operations return `false`.
+
+Narrower than [`isOperationPending`](https://docs.openzeppelin.com/contracts/5.x/api/governance#TimelockController-isOperationPending-bytes32-) in OpenZeppelin's Solidity `TimelockController`, where operations never expire and pending doubles as the cancellability check. Here an `Expired` operation is no longer pending but is still cancellable. To gate cancellation or cleanup, use `is_operation(id) && !is_operation_done(id)` (or match on `operation_state`).
+
+
+
+Returns `true` if the operation is `Ready`: its delay has elapsed and its grace window is still open, so it can be executed now (predecessor permitting); `false` otherwise.
+
+
+
+Returns `true` if the operation is `Expired`: its grace window has closed, so it can no longer be executed and can only be cancelled; `false` otherwise.
+
+
+
+Returns `true` if the operation has been executed (`Done`); `false` otherwise.
+
+
+
+Returns the observable `OperationState` of an operation at the current clock time: `Unset`, `Waiting`, `Ready`, `Expired`, or `Done`.
+
+
+
+Borrows the typed params of a scheduled, not-yet-executed operation (`Waiting`, `Ready`, or `Expired`) for off-chain inspection and UIs.
+
+Aborts with `sui::dynamic_field::EFieldDoesNotExist` if the id has no stored params (`Unset` or already `Done`).
+
+Aborts with `sui::dynamic_field::EFieldTypeMismatch` if `Params` does not match the type the operation was scheduled with.
+
+
+#### Events [!toc] [#timelock-Events]
+
+
+Emitted by `new` when a `Timelock` is created, recording its initial config and the four bound role types.
+
+
+
+Emitted when an operation is committed by `schedule` or by the self-administered `schedule_update_min_delay` / `schedule_update_grace_period` / `schedule_set_open_executor` (whose `proposer` is an admin-role holder). `payload_digest` is `keccak256(bcs(params))`; the params themselves are not emitted.
+
+
+
+Emitted when an operation is executed and its ticket minted by `execute` / `execute_open` or by the self-administered `execute_update_min_delay` / `execute_update_grace_period` / `execute_set_open_executor`, which emit it alongside the corresponding `*Changed` event in the same call.
+
+
+
+Emitted by `cancel` / `cancel_with` when a pending (`Waiting` / `Ready` / `Expired`) operation is cancelled. `action` is the scheduled `Action` type of the cancelled operation.
+
+
+
+Emitted by `execute_update_min_delay` when the configured `min_delay_ms` actually changes; a no-op update (applying the already-configured value) emits no event.
+
+
+
+Emitted by `execute_update_grace_period` when the configured `grace_period_ms` actually changes; a no-op update (applying the already-configured value) emits no event.
+
+
+
+Emitted by `execute_set_open_executor` when open-executor mode actually toggles; re-applying the current setting emits no event.
+
+
+#### Errors [!toc] [#timelock-Errors]
+
+
+Raised when the `&Auth` role type does not match the role bound for this action.
+
+
+
+Raised when `min_delay_ms` or `grace_period_ms` is outside the permitted bounds.
+
+
+
+Raised when `schedule` is called with `delay_ms` below the configured `min_delay_ms`.
+
+
+
+Raised when an operation with this id is already scheduled.
+
+
+
+Raised when no operation with this id exists in the timelock.
+
+
+
+Raised when the operation has already been executed.
+
+
+
+Raised when the operation's delay has not elapsed yet.
+
+
+
+Raised when the operation's grace window has closed; it can no longer be executed.
+
+
+
+Raised when the named predecessor is scheduled but not yet executed.
+
+
+
+Raised when the named predecessor is not present in the timelock.
+
+
+
+Raised when an operation names itself as its predecessor. Defense in depth: the id is the keccak256 of a preimage that includes `predecessor`, so a match would require a hash fixed point and is unreachable in practice.
+
+
+
+Raised when `execute_open` is called while open-executor mode is disabled.
+
+
+
+Raised when the execution ticket (or the `OperationCap` on the `*_with` path) does not belong to this timelock.
+
+
+
+Raised when scheduling this operation would overflow the u64 deadline arithmetic (`now + delay_ms`, or that sum plus `grace_period_ms`).
+
+
+
+Raised when the supplied `Params` type does not match the type the operation was scheduled with. Reachable on either path: the raw `&Auth` path takes `Params` explicitly, and the `*_with` (cap) path pins it via the `OperationCap`. Supplying a cap for a different `(Action, Params)` than the op was scheduled with still mismatches.
+
+
+
+Raised when the supplied `Action` type does not match the type the operation was scheduled with. The `Action` is hashed into the operation id, but `execute` takes the id directly, so the binding is re-checked at execute time against the stored `Action`.
+
+
+
+Raised when `predecessor` is non-empty but not a 32-byte operation id, so it could never match a scheduled op. Rejected at schedule time rather than leaving a silently un-executable op.
+
diff --git a/content/contracts-sui/1.x/api/utils.mdx b/content/contracts-sui/1.x/api/utils.mdx
index 75116f5d..75ef868e 100644
--- a/content/contracts-sui/1.x/api/utils.mdx
+++ b/content/contracts-sui/1.x/api/utils.mdx
@@ -8,7 +8,7 @@ This page documents the public API of `openzeppelin_utils` for OpenZeppelin Cont
```move
diff --git a/content/contracts-sui/1.x/collections.mdx b/content/contracts-sui/1.x/collections.mdx
new file mode 100644
index 00000000..f7418c80
--- /dev/null
+++ b/content/contracts-sui/1.x/collections.mdx
@@ -0,0 +1,112 @@
+---
+title: Collections
+---
+
+The `openzeppelin_collections` package is the ordered-collections family for OpenZeppelin Contracts for Sui. It provides `SortedMap`, an ordered key/value collection kept in one sorted vector, and `SortedSet`, a thin set wrapper over `SortedMap`, mirroring Rust's `BTreeSet` relationship to `BTreeMap`.
+
+Both are UID-less value types, shaped like `sui::vec_map::VecMap` and `sui::vec_set::VecSet` but kept in key order: you embed them as fields in your own `has key` objects, and every operation touches exactly one stored object. Beyond point lookups they answer ordered questions such as head/tail, floor/ceiling, next/previous key, and sorted pages via `keys_from!`.
+
+Order is comparator-driven. The bare macros (`upsert!`, `contains!`, `remove!`, ...) use the built-in integer `<` for unsigned-integer keys; the `_by` variants take a strict less-than comparator for custom key types or descending order.
+
+
+Collections store no comparator. Order is defined per call by the `lt` closure you supply to the `_by` macros, and it must be a strict total order threaded consistently to every call on a given collection. The library cannot detect a violation: a non-strict (`<=`) or inconsistent comparator silently corrupts order, causing duplicate inserts, missed removes, and wrong membership answers. For integer keys, the bare (non-`_by`) macros remove this footgun entirely.
+
+
+## Usage
+
+The package is not yet published to the Move Registry (MVR), so install it as a git dependency pinned to the release tag in `Move.toml`:
+
+```toml
+[dependencies]
+openzeppelin_collections = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "collections", rev = "v1.5.1" }
+```
+
+Import the module you need:
+
+```move
+use openzeppelin_collections::sorted_map;
+// or
+use openzeppelin_collections::sorted_set;
+```
+
+## Examples
+
+### Price book with a `SortedMap`
+
+A shared book embeds a `SortedMap` (price to resting size). `u64` keys sort under the built-in integer `<`, so every call uses the bare macros.
+
+```move
+module my_protocol::price_book;
+
+use openzeppelin_collections::sorted_map::{Self, SortedMap};
+
+public struct PriceBook has key {
+ id: UID,
+ levels: SortedMap, // price -> resting size, ascending (best = head)
+}
+
+public fun create_and_share(ctx: &mut TxContext) {
+ let book = PriceBook { id: object::new(ctx), levels: sorted_map::new() };
+ transfer::share_object(book);
+}
+
+/// Add `size` at `price`, merging into an existing level if present.
+public fun place(book: &mut PriceBook, price: u64, size: u64) {
+ if (book.levels.contains!(&price)) {
+ let level = book.levels.borrow_mut!(&price);
+ *level = *level + size;
+ } else {
+ book.levels.upsert!(price, size);
+ };
+}
+
+/// Best (lowest) price, or `none` if the book is empty.
+public fun best_price(book: &PriceBook): Option {
+ book.levels.head()
+}
+
+/// Up to `limit` prices ascending from the first price `>= from`. Resume a page by
+/// passing the last returned price back as `from` with `include = false`.
+public fun page(book: &PriceBook, from: u64, include: bool, limit: u64): vector {
+ book.levels.keys_from!(&from, include, limit)
+}
+```
+
+### Watchlist with a `SortedSet`
+
+A watchlist embeds a `SortedSet` and uses `upsert!`'s `bool` return (`true` only on a fresh insert, never aborting on a duplicate) to emit an event exactly the first time an id is watched.
+
+```move
+module my_app::watchlist;
+
+use openzeppelin_collections::sorted_set::{Self, SortedSet};
+use sui::event;
+
+public struct Watchlist has key {
+ id: UID,
+ ids: SortedSet,
+}
+
+public struct IdWatched has copy, drop { id: u64 }
+
+public fun create(ctx: &mut TxContext): Watchlist {
+ Watchlist { id: object::new(ctx), ids: sorted_set::new() }
+}
+
+/// Add a token id; emit only the first time it is watched.
+public fun watch(watchlist: &mut Watchlist, id: u64) {
+ if (watchlist.ids.upsert!(id)) {
+ event::emit(IdWatched { id });
+ }
+}
+```
+
+## Choosing between `sorted_map` and `sorted_set`
+
+- Use `sorted_map` when each key carries a value, such as price levels, tick registries, leaderboards, or payout vaults. Values may be resources like `Coin`: drain every entry, then call `destroy_empty`.
+- Use `sorted_set` when only ordered membership matters, such as watchlists, allow/deny lists, or deduplicated id registries. Its `upsert!` returns a `bool` instead of aborting on duplicates.
+- Both answer the same ordered queries (`head`/`tail`, `find_next!`/`find_prev!`, `keys_from!` pages) and keep a single-object footprint; byte size is the only capacity ceiling.
+
+## API Reference
+
+Use the full function-level reference here: [Collections API](/contracts-sui/1.x/api/collections).
diff --git a/content/contracts-sui/1.x/finance.mdx b/content/contracts-sui/1.x/finance.mdx
index a50de4cc..bd343e91 100644
--- a/content/contracts-sui/1.x/finance.mdx
+++ b/content/contracts-sui/1.x/finance.mdx
@@ -18,12 +18,12 @@ Add the dependency in `Move.toml`, pinned to a git revision:
```toml
[dependencies]
-openzeppelin_finance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.4.0" }
+openzeppelin_finance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.5.1" }
```
### Copy the source
-Alternatively, copy [`vesting_wallet.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.4.0/contracts/finance/sources/vesting_wallet.move) and [`vesting_wallet_linear.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.4.0/contracts/finance/sources/vesting_wallet_linear.move) directly into your package's `sources/` (renaming the module addresses to your own package). You then fully own the code - free to trim, fork, or extend the curve - at the cost of tracking upstream fixes yourself.
+Alternatively, copy [`vesting_wallet.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.5.1/contracts/finance/sources/vesting_wallet.move) and [`vesting_wallet_linear.move`](https://github.com/OpenZeppelin/contracts-sui/blob/v1.5.1/contracts/finance/sources/vesting_wallet_linear.move) directly into your package's `sources/` (renaming the module addresses to your own package). You then fully own the code - free to trim, fork, or extend the curve - at the cost of tracking upstream fixes yourself.
### Import
diff --git a/content/contracts-sui/1.x/guides/delegated-spending.mdx b/content/contracts-sui/1.x/guides/delegated-spending.mdx
index eaf49377..d01e16db 100644
--- a/content/contracts-sui/1.x/guides/delegated-spending.mdx
+++ b/content/contracts-sui/1.x/guides/delegated-spending.mdx
@@ -89,7 +89,7 @@ Add the allowance package to `Move.toml` under the generated `[dependencies]` se
```toml
[dependencies]
-openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.4.0" }
+openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.5.1" }
```
Then import the module from your Move code:
diff --git a/content/contracts-sui/1.x/index.mdx b/content/contracts-sui/1.x/index.mdx
index 3e336f93..42b4a2d7 100644
--- a/content/contracts-sui/1.x/index.mdx
+++ b/content/contracts-sui/1.x/index.mdx
@@ -10,6 +10,9 @@ title: Contracts for Sui 1.x
- `openzeppelin_finance` for vesting: locking a coin for a beneficiary and releasing it on a schedule, with a built-in linear-with-cliff curve and a curve-agnostic core for custom schedules.
- `openzeppelin_utils` for embeddable building blocks; its first module, `rate_limiter`, provides multi-strategy rate limiting.
- `openzeppelin_allowance` for cap-keyed spending allowances; its first module, `spend_vault`, escrows multi-coin funds behind owner-set, expiring budgets.
+- `openzeppelin_collections` for ordered key/value and set data structures (`sorted_map`, `sorted_set`).
+- `openzeppelin_timelock` for enforcing a minimum onchain delay between scheduling and executing privileged operations.
+- `openzeppelin_sale` for fixed-price token sales (presale / IDO): a prefunded inventory sold in a capped window, with refunds, optional compliance gating, and optional vesting.
## Quickstart
@@ -37,7 +40,7 @@ mvr add @openzeppelin-move/utils
You only need the dependencies your app actually uses. Add what you need and drop the others.
-Packages not yet on the Move Registry - currently `openzeppelin_allowance` and `openzeppelin_finance` - are added by git revision instead of `mvr add` (see the next step).
+Packages not yet on the Move Registry - currently `openzeppelin_allowance`, `openzeppelin_finance` and `openzeppelin_sale` - are added by git revision instead of `mvr add` (see the next step).
### 3. Verify `Move.toml`
@@ -51,11 +54,12 @@ openzeppelin_fp_math = { r.mvr = "@openzeppelin-move/fixed-point-math" }
openzeppelin_utils = { r.mvr = "@openzeppelin-move/utils" }
```
-Since `openzeppelin_allowance` and `openzeppelin_finance` aren't on MVR yet, add them manually, pinned to a git revision:
+Since `openzeppelin_allowance`, `openzeppelin_finance` and `openzeppelin_sale` aren't on MVR yet, add them manually, pinned to a git revision:
```toml
-openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.4.0" }
-openzeppelin_finance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.4.0" }
+openzeppelin_allowance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/allowance", rev = "v1.5.1" }
+openzeppelin_finance = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.5.1" }
+openzeppelin_sale = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/sale", rev = "v1.5.1" }
```
Alternatively, copy the module sources directly into your package's `sources/` so you fully own the code; see each package guide for the per-package options.
@@ -96,17 +100,21 @@ sui move test
- Need to vest a token grant, team/investor allocation, or payroll stream to a beneficiary over time? Use [Vesting Wallet](/contracts-sui/1.x/vesting-wallet).
- 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 enforce a minimum delay before privileged operations execute? Use [Timelock](/contracts-sui/1.x/timelock).
- Need to throttle withdrawals, meter per-user budgets, gate action reuse, or delay an action? Use [Rate Limiter](/contracts-sui/1.x/rate-limiter).
- Need to delegate bounded, expiring spending of escrowed coins to a keeper, service, or teammate, without signing each spend? Use [Spend Vault](/contracts-sui/1.x/spend-vault).
+- Need ordered maps or sets with deterministic iteration? Use [Collections](/contracts-sui/1.x/collections).
+- Need to run a fixed-price token sale or presale with caps, refunds, and optional KYC or vesting? Use [Sale](/contracts-sui/1.x/sale).
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), [Utilities](/contracts-sui/1.x/utils), [Allowance](/contracts-sui/1.x/allowance), [Finance](/contracts-sui/1.x/finance).
+- Package guides: [Integer Math](/contracts-sui/1.x/math), [Fixed-Point Math](/contracts-sui/1.x/fixed-point), [Access](/contracts-sui/1.x/access), [Collections](/contracts-sui/1.x/collections), [Timelock](/contracts-sui/1.x/timelock), [Utilities](/contracts-sui/1.x/utils), [Allowance](/contracts-sui/1.x/allowance), [Finance](/contracts-sui/1.x/finance), [Sale](/contracts-sui/1.x/sale).
- 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).
- Utilities modules: [Rate Limiter](/contracts-sui/1.x/rate-limiter).
- Allowance modules: [Spend Vault](/contracts-sui/1.x/spend-vault).
- Finance modules: [Vesting Wallet](/contracts-sui/1.x/vesting-wallet).
+- Sale modules: [Prefunded Sale](/contracts-sui/1.x/prefunded-sale).
- Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control), [Delegated Spending](/contracts-sui/1.x/guides/delegated-spending).
-- 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), [Allowance](/contracts-sui/1.x/api/allowance), [Finance](/contracts-sui/1.x/api/finance).
+- 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), [Collections](/contracts-sui/1.x/api/collections), [Timelock](/contracts-sui/1.x/api/timelock), [Utilities](/contracts-sui/1.x/api/utils), [Allowance](/contracts-sui/1.x/api/allowance), [Finance](/contracts-sui/1.x/api/finance), [Sale](/contracts-sui/1.x/api/sale).
diff --git a/content/contracts-sui/1.x/prefunded-sale.mdx b/content/contracts-sui/1.x/prefunded-sale.mdx
new file mode 100644
index 00000000..829ed7e8
--- /dev/null
+++ b/content/contracts-sui/1.x/prefunded-sale.mdx
@@ -0,0 +1,437 @@
+---
+title: Prefunded Sale
+---
+
+
+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_sale` package runs a fixed-price token sale against a **fixed, pre-deposited inventory**. The issuer funds the sale with a `Balance` up front; buyers pay a `PaymentCoin` during a time window and each receives a non-transferable `Receipt`. After the window closes the sale resolves one of two ways: **finalize** (success - buyers claim tokens, the issuer withdraws proceeds) or **cancel** (failure - buyers recover their payment from an escrow vault). The sale never mints and never holds a `TreasuryCap`; it only routes the inventory and payments deposited into it.
+
+Token sales are otherwise reimplemented ad hoc by every project that raises, each re-deriving the same escrow, cap, refund, and receipt bookkeeping - and each getting the failure path subtly wrong. `prefunded_sale` factors that into a reusable primitive whose guarantees are structural: buyers can always recover funds on a failed sale without the issuer's cooperation, the issuer can never rug a sale that has met its goal, and pricing is delegated to a small, auditable curve rather than baked into the escrow.
+
+## Use cases
+
+Use `prefunded_sale` when your project needs:
+
+- An open public round (first-come-first-served) selling a fixed token allocation at a fixed price.
+- An anti-whale public round that caps each buyer's total contribution.
+- A minimum-raise sale that automatically refunds everyone if the soft cap is missed.
+- A compliance-gated strategic round where every buyer clears your own KYC/allowlist scheme first.
+- A raise whose tokens vest on a schedule the buyer cannot bypass, instead of unlocking at claim.
+
+## Import
+
+```move
+use openzeppelin_sale::prefunded_sale;
+use openzeppelin_sale::fixed_rate_curve;
+use openzeppelin_sale::refund_vault;
+```
+
+## The modules
+
+Most integrators interact with `prefunded_sale` and `fixed_rate_curve`. The rest are supporting types that appear in signatures and are covered in their own sections below.
+
+| Module | Role |
+| --- | --- |
+| [`prefunded_sale`](#lifecycle) | The sale object and its full lifecycle. **Start here.** |
+| [`fixed_rate_curve`](#pricing-the-fixed-rate-curve) | Built-in pricing: `allocation = paid * rate`, fixed for the whole sale. |
+| [`refund_vault`](#the-refund-vault) | Refundable escrow that guarantees buyers can recover funds on cancel. |
+| [`allowlist`](#compliance-with-an-allowlist) | Typed compliance slot; you wire your own KYC scheme against it. |
+| [`receipt`](#receipts) | The non-transferable, buyer-bound claim ticket. |
+
+The lifecycle `Phase` enum (`Init -> Active`, then terminal `Finalized` or `Cancelled`) lives in `prefunded_sale` itself; query it with the `is_init` / `is_active` / `is_finalized` / `is_cancelled` predicates.
+
+## Lifecycle
+
+A sale moves through four phases. During `Init` it is an **owned** value held by its creator; on activation it becomes a **shared** object anyone can see and interact with.
+
+```text
+ create_sale ─┐
+ deposit │
+ set_per_buyer_cap │ Init - the sale is an OWNED value;
+ set_vesting_schedule | holding it by &mut is the authority.
+ enable_allowlist │ All setup happens here.
+ pair_refund_vault │
+ │
+ share_and_activate ────────┴──▶ Active - sale AND vault are SHARED.
+ │
+ purchase ×N (within [opens_at_ms, closes_at_ms])
+ │
+ ├──▶ finalize (permissionless; success)
+ │ claim / claim_all, withdraw_proceeds,
+ │ withdraw_unsold_inventory
+ │
+ ├──▶ cancel_after_close (permissionless; soft-cap miss)
+ │ refund, withdraw_unsold_inventory
+ │
+ └──▶ cancel_emergency (admin-only; in-window emergency)
+ refund, withdraw_unsold_inventory
+```
+
+`Finalized` and `Cancelled` are terminal. During `Init`, **all setup must complete before `share_and_activate`** - every setup function asserts the `Init` phase and aborts once the sale is `Active`.
+
+### Authority model
+
+- **Init setup** needs no capability. The sale is an owned value, so only the creator holding it by `&mut` can configure it.
+- **Permissionless once active:** `purchase`, `claim`, `claim_all`, `refund`, `finalize`, `cancel_after_close`. Buyers drive these once their conditions hold. **Buyer claims and refunds never depend on the issuer being online.**
+- **Admin-only** (via `SaleAdminCap`): `cancel_emergency` (in-window), `withdraw_proceeds`, `withdraw_unsold_inventory`. Losing this cap leaves the sale fully usable for buyers; only the issuer's emergency-cancel and withdrawal powers are forfeited.
+
+## Quickstart
+
+Everything below runs in `Init` and is typically threaded through one PTB. This launches a capped public round: a hard cap, an optional soft cap, and a per-buyer cap, priced by the built-in fixed-rate curve. `SALE` is the token being sold and `USDC` the payment coin.
+
+```move
+module my_project::launch;
+
+use openzeppelin_sale::prefunded_sale;
+use openzeppelin_sale::fixed_rate_curve::{Self, FixedRateCurve, Params as FrcParams};
+use openzeppelin_sale::refund_vault;
+use openzeppelin_finance::vesting_wallet_linear::{Linear, Params as VParams};
+use sui::clock::Clock;
+use sui::coin::Coin;
+
+/// Create a capped public round and hand the shared sale + vault to the world.
+public fun launch(
+ inventory: Coin, // pre-acquired sale tokens
+ rate: u64, // SALE smallest-units per 1 USDC smallest-unit
+ hard_cap: u64,
+ soft_cap: u64, // 0 = none
+ per_buyer_cap: u64,
+ opens_at_ms: u64,
+ closes_at_ms: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ // 1. Create the sale (Init). Returns the owned sale plus its admin cap.
+ // The (Linear, VParams) slots are the unused vesting witness/params - see Optional vesting.
+ let (mut sale, admin_cap) = prefunded_sale::create_sale<
+ FixedRateCurve, FrcParams, SALE, USDC, Linear, VParams,
+ >(
+ fixed_rate_curve::params(rate),
+ hard_cap,
+ soft_cap,
+ opens_at_ms,
+ closes_at_ms,
+ ctx,
+ );
+
+ // 2. Fund the inventory (deposit takes a Balance).
+ sale.deposit(inventory.into_balance());
+
+ // 3. Optional knobs - all Init-only and one-shot.
+ sale.set_per_buyer_cap(per_buyer_cap, ctx);
+
+ // 4. Pair a fresh, empty, Active vault, then activate. share_and_activate takes the
+ // vault by value and shares it together with the sale, so the permissionless
+ // refund paths can never be bricked by a forgotten share step.
+ let (vault, vault_cap) = refund_vault::new(ctx);
+ sale.pair_refund_vault(&vault, vault_cap);
+ let ticket = fixed_rate_curve::activation_ticket(&sale);
+ sale.share_and_activate(vault, ticket, clock); // consumes + shares sale AND vault
+
+ // 5. Park the admin cap somewhere recoverable (RBAC / multisig / governance).
+ transfer::public_transfer(admin_cap, ctx.sender());
+}
+```
+
+
+Deposit enough inventory to back the whole raise before activating. A fixed-rate curve requires `hard_cap * rate` tokens (`activation_ticket` computes this), and `share_and_activate` aborts with `EInsufficientInventoryAtActivate` if the inventory falls short. Provisioned honestly, *sold out* and *hard cap reached* coincide.
+
+
+## Buying
+
+A purchase is two calls threaded into one PTB: the curve mints a `Quote` that welds the payment to a curve-computed allocation, and `purchase` consumes it and delivers a `Receipt` to the sender. For a non-allowlist sale, pass `option::none()` for the entry.
+
+```move
+use openzeppelin_sale::prefunded_sale::PrefundedSale;
+use openzeppelin_sale::fixed_rate_curve::{Self, FixedRateCurve, Params as FrcParams};
+use openzeppelin_finance::vesting_wallet_linear::{Linear, Params as VParams};
+
+public fun buy(
+ sale: &mut PrefundedSale,
+ payment: Coin,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ let quote = fixed_rate_curve::quote(sale, payment.into_balance());
+ sale.purchase(quote, option::none(), clock, ctx);
+ // The Receipt is now owned by ctx.sender().
+}
+```
+
+
+The hard cap is enforced **all-or-nothing**: a purchase whose payment would push `raised` past `hard_cap` aborts in full with `EHardCapExceeded` - there is no partial fill of the remaining capacity. Near sell-out, size the payment to the remaining room (`hard_cap() - raised()`, read off-chain) before minting the quote. A payment for exactly the remaining capacity closes the sale; anything larger reverts.
+
+
+## Closing the sale
+
+A sale leaves `Active` exactly once, through one of three doors. `finalize` and `cancel_after_close` are **permissionless** - any caller can trigger them once the conditions hold, so closing never waits on the issuer.
+
+```move
+// Success. Permissionless. Callable once the window closes with the soft cap met,
+// or as soon as the hard cap is reached (which closes the sale early).
+sale.finalize(&mut vault, clock);
+
+// Failure: soft-cap miss. Permissionless once the window closes below soft cap.
+sale.cancel_after_close(&mut vault, clock);
+
+// Failure: emergency. Admin-only, in-window. Cannot cancel a goal-reaching sale.
+sale.cancel_emergency(&admin_cap, &mut vault, clock);
+```
+
+`cancel_emergency` is the issuer's only unilateral power over an active sale, and it is deliberately fenced: it aborts once the hard cap is reached (`ESaleAlreadyComplete`) or once a configured soft cap is met (`ESoftCapReached`), and it aborts after the window closes (`EEmergencyCancelAfterClose`, use `cancel_after_close` instead). A successful raise can only `finalize`.
+
+## Redeeming
+
+The redemption path depends on how the sale closed.
+
+### After a successful close
+
+Buyers redeem receipts for tokens; the issuer withdraws proceeds and any unsold slack. All of these return a `Balance`; settle it straight into a recipient's address balance with `balance::send_funds` - no `Coin` object is minted, and there is nothing to route or clean up.
+
+```move
+// Buyer redeems one receipt; the tokens settle into the buyer's address balance.
+let tokens: Balance = sale.claim(receipt, ctx);
+balance::send_funds(tokens, ctx.sender());
+
+// A buyer holding several receipts batches them into one balance, then settles once.
+let tokens: Balance = sale.claim_all(receipts, ctx);
+balance::send_funds(tokens, ctx.sender());
+
+// Admin (cap-gated) withdrawals. Pass any address to send_funds - the caller here,
+// a treasury, or a governance account.
+let proceeds: Balance = sale.withdraw_proceeds(&admin_cap);
+balance::send_funds(proceeds, ctx.sender());
+let unsold: Balance = sale.withdraw_unsold_inventory(&admin_cap); // only the slack
+balance::send_funds(unsold, ctx.sender());
+```
+
+`withdraw_unsold_inventory` releases strictly the unallocated portion (`inventory - total_allocated`); tokens backing outstanding receipts stay locked until their buyer claims. Both withdrawals are idempotent - a second call returns an empty balance.
+
+### After a cancel
+
+Every buyer recovers exactly what they paid, in any order, straight from the vault - no issuer involvement.
+
+```move
+let money_back: Balance = sale.refund(&mut vault, receipt, ctx);
+balance::send_funds(money_back, ctx.sender()); // straight into the buyer's address balance
+```
+
+On cancel the entire proceeds balance moves into the vault, so `vault.locked == raised`: refund solvency is guaranteed for every buyer.
+
+## Pricing: the fixed-rate curve
+
+The sale does not price a purchase itself. It is generic over a **curve** that computes each buyer's allocation, and applies the result verbatim, bounded only by unallocated inventory and overflow guards. The built-in `fixed_rate_curve` is the common case: `allocation = paid * rate`, with `rate` fixed at construction.
+
+```move
+// SALE smallest-units allocated per 1 USDC smallest-unit.
+let params = fixed_rate_curve::params(rate); // aborts ERateZero if rate == 0
+```
+
+What keeps this design safe is a **witness gate**. A `Quote` for a `PrefundedSale` can only be minted by `fixed_rate_curve::quote`, because minting requires a `FixedRateCurve` value whose constructor is private to that module. So a sale parameterized on the fixed-rate curve can be priced by no other code.
+
+
+**The curve is a trusted component.** The sale applies the curve's allocation verbatim - there is no independent `max_rate` check. A buggy or dishonest custom curve can over-allocate per payment up to the inventory ceiling (it can never create tokens beyond inventory, and no payment is taken without an atomic allocation, but it can make the sale sell out early). Treat any custom curve as security-critical and audit it alongside the sale. The provided `fixed_rate_curve` is honest by construction.
+
+
+## Sale shapes and caps
+
+Four orthogonal, independent configuration axes, all set during `Init`:
+
+- **Hard cap (required, `> 0`).** Bounds the maximum raise. Inventory backing is enforced at activation, so *sold-out* and *hard-cap-reached* coincide.
+- **Soft cap (optional, `0 = none`).** Minimum raise to `finalize`. If the window closes below it, anyone can `cancel_after_close` and every buyer can refund.
+- **Per-buyer cap (optional).** A cumulative cap on a single buyer's total payment, enforced against the running per-buyer total. Configure with `set_per_buyer_cap`.
+- **Allowlist (optional).** Compliance-gated mode: every `purchase` must consume an `AllowEntry`. Configure with `enable_allowlist`.
+
+Combined, these give the three shapes a fixed-price sale typically takes:
+
+| Shape | KYC | Soft cap | Per-buyer cap | Typical use |
+| --- | --- | --- | --- | --- |
+| Public round | no | no | no | Open public sale, first-come-first-served |
+| Capped public round | no | optional | yes | Anti-whale public sale |
+| Strategic round | yes | yes | yes | Compliance-gated raise |
+
+## Compliance with an allowlist
+
+The library ships **no** KYC logic. Instead, `allowlist` defines two typed slots you wire your own scheme against: an `AllowlistAdmin` (the authority to approve buyers) and a single-use `AllowEntry` (a per-purchase approval ticket). Calling `enable_allowlist` during `Init` switches the sale into compliance-gated mode and hands you the admin to wrap in your own module:
+
+```move
+// During Init. Wrap the returned admin inside your compliance module (a shared
+// object that runs KYC checks), then activate the sale as usual.
+let allow_admin = sale.enable_allowlist(ctx);
+```
+
+Your compliance module runs whatever verification it requires (a KYC table lookup, a merkle proof, a tier check) and, on success, mints an entry with `allowlist::new_entry`. The buyer threads that entry into the same PTB as `purchase`, which consumes it and asserts it was issued for *this* sale and *this* buyer:
+
+```move
+// Allowlist purchase: the entry is minted by your compliance module and consumed here.
+let quote = fixed_rate_curve::quote(sale, payment.into_balance());
+sale.purchase(quote, option::some(entry), clock, ctx);
+```
+
+`AllowEntry` has **no abilities**, so it cannot be stored, copied, replayed across transactions, or transferred - the only legal path is mint-then-consume in one PTB. Because a receipt is buyer-bound, KYC enforced at purchase carries all the way through to distribution: a verified buyer cannot forward a claim to an unverified address.
+
+
+Losing the `AllowlistAdmin` bricks an allowlist sale: no entries can be minted, so every `purchase` aborts. There is no library override (that would be a centralization vector). Hold the admin - like the `SaleAdminCap` - in a recoverable [`openzeppelin_access`](/contracts-sui/1.x/access), multisig, or governance container.
+
+
+## Optional vesting
+
+By default `claim` hands the buyer their tokens immediately. To lock them instead, attach an issuer-defined schedule during `Init` with `set_vesting_schedule`. When set, the plain `claim` path aborts and the only redemption route is `claim_into_vesting`, which returns a funded [`VestingWallet`](/contracts-sui/1.x/vesting-wallet) (from [`openzeppelin_finance`](/contracts-sui/1.x/finance)) plus the wallet's `DestroyCap`.
+
+```move
+use openzeppelin_finance::vesting_wallet::{VestingWallet, DestroyCap};
+use openzeppelin_finance::vesting_wallet_linear::{Linear, Params as VParams};
+
+// Redeem a receipt into a funded vesting wallet. Every type argument is inferred from
+// the sale, which pins the schedule witness and params fixed at create_sale.
+let (wallet, cap): (VestingWallet, DestroyCap) =
+ prefunded_sale::claim_into_vesting(&mut sale, receipt, ctx);
+
+transfer::public_share_object(wallet); // shared: anyone can poke release; funds land in the buyer's balance
+transfer::public_transfer(cap, ctx.sender()); // hold the cap to reclaim storage once the wallet is drained
+```
+
+The schedule is **issuer-defined and unbypassable**: the wallet is built with `beneficiary` forced to the buyer and the sale's fixed schedule, under a vesting witness pinned in the sale's type at `create_sale`. A buyer cannot substitute a permissive witness of their own to release early. To choose the schedule shape, set the `VestingWitness`/`VestingScheduleParams` type arguments (for example `vesting_wallet_linear::{Linear, Params}`) when you call `create_sale`, then attach a concrete schedule during `Init` with `set_vesting_schedule`:
+
+```move
+use openzeppelin_finance::vesting_wallet_linear;
+
+// Build a Linear schedule bundle and attach it. `vesting_schedule` welds the Linear
+// witness to validated stepped params, producing the `VestingSchedule`
+// that `set_vesting_schedule` (and the sale's pinned type) require.
+let schedule = vesting_wallet_linear::vesting_schedule(start_ms, cliff_ms, period_ms, steps);
+sale.set_vesting_schedule(schedule);
+```
+
+`set_vesting_schedule` takes the bundle by value; because only `vesting_wallet_linear` can mint a `VestingSchedule`, the witness and params pinned in the sale's type are guaranteed to form a coherent pair. For a non-vesting sale the slots are inert - pick any `drop` witness and never attach a schedule.
+
+## The refund vault
+
+Every sale is paired with a `RefundVault` **before activation**, even when `soft_cap == 0` - `cancel_emergency` always needs a refund destination. The vault must be **`Active` and empty** when paired (pre-existing funds would be stranded). `share_and_activate` then takes the vault by value and shares it together with the sale, so the permissionless refund paths can never be bricked by a forgotten sharing step.
+
+Once paired, the sale owns the vault's controller cap and drives its state: `finalize` flips it to `Closed`, a cancel flips it to `Refunding` and funds it with the proceeds. Buyers refund directly from the vault. The vault is a generic refundable escrow with no knowledge of sales, so it is also usable standalone.
+
+## Receipts
+
+Each `purchase` delivers one `Receipt` to the buyer. It has `key` only (no `store`), so it cannot be transferred, wrapped, or stored elsewhere, and `claim` / `refund` additionally assert `ctx.sender() == receipt.buyer`. Two consequences:
+
+- **No wallet rotation** between purchase and redemption - the buying address is the redeeming address.
+- **KYC enforced at purchase carries through to distribution** - a verified buyer cannot forward a claim to an unverified address.
+
+A buyer with several purchases holds several receipts; `claim_all` batches them into one call.
+
+## Key concepts
+
+- **Prefunded, never minting.** The sale draws from a fixed `Balance` inventory deposited up front and never holds a `TreasuryCap`. Inventory backing is checked at activation, so the raise can never over-sell the tokens on hand.
+
+- **Owned during setup, shared when live.** In `Init` the sale is an owned value and holding it by `&mut` is the only authority setup needs. `share_and_activate` shares the sale and its vault together, atomically.
+
+- **The curve is trusted; the sale is not the pricer.** Allocation comes from a witness-gated curve and is applied verbatim, bounded only by inventory and overflow. The witness gate is the security boundary - a first-party, audited curve is un-priceable by any other code.
+
+- **Buyer redemption never depends on issuer liveness.** `purchase`, `claim`, `refund`, `finalize`, and `cancel_after_close` are permissionless. Losing the `SaleAdminCap` forfeits only emergency-cancel and the issuer's withdrawals.
+
+- **Redemptions return a `Balance`, not a `Coin`.** `claim`, `claim_all`, `refund`, and the admin withdrawals hand back a `Balance` you credit to a recipient with `balance::send_funds` - it settles into that address's balance directly, minting no payout `Coin` object to route or clean up. The vesting path settles the same way when its wallet releases.
+
+- **Hot potatoes weld intent to funds.** `Quote` (payment + allocation) and `AllowEntry` (compliance approval) have no abilities, so they must be minted and consumed in the same PTB - no warehousing, no replay across transactions.
+
+- **The sale object is permanent.** There is no teardown: a terminal phase is not a delete, and even a fully-redeemed sale remains a shared object forever. Do not expect a storage rebate from winding a sale down.
+
+## Common mistakes
+
+| Mistake | What happens | How to fix |
+| --- | --- | --- |
+| Configuring (deposit / caps / allowlist / vesting) after `share_and_activate` | Aborts `ENotInit` | Do all setup in `Init`, before activating. |
+| Pairing a vault that already holds funds, or is shared/closed | Aborts `EVaultNotEmpty` / `EVaultNotActive` | Pair a fresh, empty, `Active` vault; `share_and_activate` shares it for you. |
+| Activating with under-provisioned inventory | Aborts `EInsufficientInventoryAtActivate` | Deposit `>= hard_cap * rate` before activating. |
+| Sending a payment larger than the remaining capacity near sell-out | Aborts `EHardCapExceeded` (no partial fill) | Size the payment to `hard_cap() - raised()` before quoting. |
+| Plain `claim` on a vesting sale, or `claim_into_vesting` on a non-vesting sale | Aborts `EClaimRequiresVesting` / `ENoVestingScheduleAttached` | Match the redemption path to whether a schedule is attached. |
+| Buying then redeeming from a different wallet | Aborts `EBuyerOnly` | Redeem from the purchasing address; receipts are buyer-bound. |
+| Losing the `SaleAdminCap` or `AllowlistAdmin` | Issuer loses withdrawals / the allowlist sale can no longer sell | Hold every cap in a recoverable RBAC / multisig / governance wrapper. |
+| Expecting `cancel_emergency` to stop a successful sale | Aborts `ESaleAlreadyComplete` / `ESoftCapReached` | A goal-reaching sale can only `finalize`; emergency cancel is fenced by design. |
+
+## Security notes
+
+- **The curve is trusted.** A custom curve is security-critical and must be audited with the sale. The witness gate makes a `FixedRateCurve` sale un-priceable by anything but the fixed-rate module.
+- **Buyer funds are never stranded by a lost cap.** All buyer-facing flows are permissionless; losing the `SaleAdminCap` forfeits only `cancel_emergency`, `withdraw_proceeds`, and `withdraw_unsold_inventory`.
+- **The issuer cannot rug a goal-reaching sale.** `cancel_emergency` is blocked once the hard cap (or a configured soft cap) is reached, and is window-bounded.
+- **Refund solvency is guaranteed.** On cancel the entire proceeds balance moves into the vault, so every buyer can recover exactly their payment, in any order.
+- **Stale receipts pin funds.** There is no grace-period sweep. An unclaimed receipt keeps its allocation pinned in inventory (Finalized) or its payment locked in the vault (Cancelled) indefinitely. This is buyer-protective by design.
+
+## PTB / TypeScript integration
+
+A buyer's purchase from the TypeScript SDK. The payment coin becomes a `Balance`, the curve mints the `Quote`, and `purchase` consumes it - all in one PTB. The `Receipt` is transferred to the sender automatically.
+
+```typescript
+import { Transaction } from '@mysten/sui/transactions';
+
+const PKG = '0x…'; // openzeppelin_sale package id
+const SALE = `${PROJECT_PKG}::coin::SALE`;
+const USDC = '0x…::usdc::USDC';
+const VWITNESS = `${FINANCE_PKG}::vesting_wallet_linear::Linear`;
+const VPARAMS = `${FINANCE_PKG}::vesting_wallet_linear::Params`;
+const CURVE = `${PKG}::fixed_rate_curve::FixedRateCurve`;
+const FRC_PARAMS = `${PKG}::fixed_rate_curve::Params`;
+
+const tx = new Transaction();
+
+// 1. Coin -> Balance
+const payBalance = tx.moveCall({
+ target: '0x2::coin::into_balance',
+ typeArguments: [USDC],
+ arguments: [tx.object(usdcCoinId)],
+});
+
+// 2. Mint the Quote from the curve module.
+const quote = tx.moveCall({
+ target: `${PKG}::fixed_rate_curve::quote`,
+ typeArguments: [SALE, USDC, VWITNESS, VPARAMS],
+ arguments: [tx.object(SALE_ID), payBalance],
+});
+
+// 3. allow = option::none>() for a non-allowlist sale.
+const noEntry = tx.moveCall({
+ target: '0x1::option::none',
+ typeArguments: [`${PKG}::allowlist::AllowEntry<${SALE}>`],
+ arguments: [],
+});
+
+// 4. Purchase. (typeArguments: Curve, CurveParams, SaleCoin, PaymentCoin, VestingWitness, VestingScheduleParams)
+tx.moveCall({
+ target: `${PKG}::prefunded_sale::purchase`,
+ typeArguments: [CURVE, FRC_PARAMS, SALE, USDC, VWITNESS, VPARAMS],
+ arguments: [tx.object(SALE_ID), quote, noEntry, tx.object('0x6')], // 0x6 = Clock
+});
+```
+
+For an **allowlist** sale, replace step 3 with a call into your compliance module's mint function (which returns an `AllowEntry`) and thread that into `purchase` - minted and consumed in the same PTB. `claim`, `refund`, `withdraw_proceeds`, and `withdraw_unsold_inventory` all return a `Balance`; a follow-up `0x2::balance::send_funds` settles it into the recipient's address balance with no coin object (use `0x2::coin::from_balance` only if you specifically need a `Coin`).
+
+## FAQ
+
+**Who can close the sale?**
+Anyone. `finalize` and `cancel_after_close` are permissionless once their conditions hold, so a sale never waits on the issuer to close. Only `cancel_emergency` is admin-gated, and it cannot touch a goal-reaching sale.
+
+**What happens to buyers if the issuer disappears?**
+On a successful sale, buyers `claim` their tokens without the issuer. On a failed sale, buyers `refund` directly from the vault. Neither path needs the `SaleAdminCap`; only the issuer's own proceeds/inventory withdrawals do.
+
+**Can I run a sale without a soft cap?**
+Yes. Pass `soft_cap = 0`. The sale can then finalize at any raise once the window closes (or the hard cap is hit), and `cancel_after_close` is unavailable. A refund vault is still required, because `cancel_emergency` needs one.
+
+**How do buyers avoid a failed transaction near sell-out?**
+The hard cap is all-or-nothing, so read `hard_cap() - raised()` off-chain and size the payment to at most the remaining room. A payment for exactly the remaining capacity closes the sale.
+
+**Does the module emit events?**
+Yes. `create_sale`, `deposit`, cap/vesting/allowlist setup, `share_and_activate`, `purchase`, `finalize`, cancels, claims, refunds, and both admin withdrawals each emit a stamped event for indexers.
+
+## Examples
+
+The full unit-test suite under [`contracts/sale/tests/`](https://github.com/OpenZeppelin/contracts-sui/tree/v1.5.1/contracts/sale/tests) doubles as an executable specification: `test_utils.move` shows the canonical `Init -> Active` setup, and the thematic files exercise every purchase, close, redemption, and failure path.
+
+
+These are unaudited illustrations of how the primitive is wired up, not production-ready code.
+
+
+## Learn more
+
+For function-level signatures, parameters, events, and errors, see the [Sale API reference](/contracts-sui/1.x/api/sale).
diff --git a/content/contracts-sui/1.x/sale.mdx b/content/contracts-sui/1.x/sale.mdx
new file mode 100644
index 00000000..328db04f
--- /dev/null
+++ b/content/contracts-sui/1.x/sale.mdx
@@ -0,0 +1,80 @@
+---
+title: Sale
+---
+
+
+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_sale` package runs a fixed-price token sale (presale / IDO) against a **fixed, pre-deposited inventory**. The issuer funds the sale with a `Balance` up front; buyers pay a `PaymentCoin` during a time window and each receives a non-transferable `Receipt`. After the window the sale resolves one of two ways: **finalize** (success - buyers claim their tokens, the issuer withdraws proceeds) or **cancel** (failure - buyers recover their payment from an escrow vault). Use it for capped public rounds, anti-whale public rounds, and compliance-gated strategic rounds.
+
+Pricing is not baked in. The sale is generic over a witness-gated **curve** that computes each buyer's allocation; a built-in fixed-rate curve (`allocation = paid * rate`) covers the common case, and the seam is open for custom curves. The sale never holds a `TreasuryCap` - it only ever routes the inventory and payments deposited into it.
+
+
+This is the **prefunded** flavor (`prefunded_sale`), which draws from a fixed inventory and never mints. A future minting flavor (holding a `TreasuryCap`) is a separate type sharing the same `Receipt` and lifecycle, and is out of scope for this package.
+
+
+## Usage
+
+There are two ways to pull the package into your project. Pick whichever fits how much you want to own the code.
+
+### Git dependency
+
+Add the dependency in `Move.toml`, pinned to a git revision:
+
+```toml
+[dependencies]
+openzeppelin_sale = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/sale", rev = "v1.5.1" }
+```
+
+The sale's vesting path depends on [`openzeppelin_finance`](/contracts-sui/1.x/finance); Move resolves it transitively, so you do not need to add it yourself unless you also use it directly.
+
+### Copy the source
+
+Alternatively, copy the module sources under [`contracts/sale/sources/`](https://github.com/OpenZeppelin/contracts-sui/tree/v1.5.1/contracts/sale/sources) directly into your package's `sources/` (renaming the module addresses to your own package). You then fully own the code - free to trim, fork, or swap the pricing curve - at the cost of tracking upstream fixes yourself. If you keep the optional vesting path, copy the [`openzeppelin_finance`](/contracts-sui/1.x/finance) vesting sources alongside it.
+
+### Import
+
+Once the package is available, import the modules you need:
+
+```move
+use openzeppelin_sale::prefunded_sale;
+use openzeppelin_sale::fixed_rate_curve;
+```
+
+## Modules
+
+
+
+ The full fixed-price sale: create, fund, and activate a sale; buyers purchase within a window; close by finalize or cancel; then claim, refund, or withdraw. Covers the built-in pricing curve, the refund vault, the compliance allowlist, receipts, and optional vesting.
+
+
+
+The guide covers all five modules of the package. Most integrators touch only `prefunded_sale` and `fixed_rate_curve`; the other three are supporting types you will see in signatures:
+
+| Module | What it is |
+| --- | --- |
+| `prefunded_sale` | The sale itself: setup, `purchase`, close, and redeem. Also home to the `Phase` lifecycle enum. **Start here.** |
+| `fixed_rate_curve` | The built-in pricing curve, `allocation = paid * rate`, fixed for the whole sale. **Most sales use this.** |
+| `refund_vault` | A generic refundable escrow over `Balance`. Every sale is paired with one; on cancel it pays buyers back individually. Usable standalone. |
+| `allowlist` | A typed compliance slot (`AllowlistAdmin` + single-use `AllowEntry`). Ships **no** KYC logic - you wire your own scheme against these types. |
+| `receipt` | The non-transferable, buyer-bound claim ticket minted by `purchase` and consumed by `claim` / `refund`. |
+
+## Choosing a sale shape
+
+A fixed-price sale is configured along four orthogonal, independent axes: a required **hard cap** (maximum raise), an optional **soft cap** (minimum raise to finalize), an optional **per-buyer cap**, and an optional **allowlist** (compliance gating). The three shapes they typically combine into:
+
+| Shape | KYC | Soft cap | Per-buyer cap | Typical use |
+| --- | --- | --- | --- | --- |
+| Public round | no | no | no | Open public sale, first-come-first-served |
+| Capped public round | no | optional | yes | Anti-whale public sale |
+| Strategic round | yes | yes | yes | Compliance-gated raise |
+
+This primitive is **not** a bonding curve, LBP, auction (Dutch / English / sealed-bid), or fair launch - those have different mechanics and belong in separate standards.
+
+## Next steps
+
+- [Prefunded Sale](/contracts-sui/1.x/prefunded-sale) for the full module guide: lifecycle, pricing, compliance, vesting, key concepts, and security notes.
+- [Sale API reference](/contracts-sui/1.x/api/sale) for function signatures, events, and errors across all modules.
+- [Finance](/contracts-sui/1.x/finance) for the vesting wallet behind `claim_into_vesting`.
+- [Access](/contracts-sui/1.x/access) for role-based authorization to hold the `SaleAdminCap` and `AllowlistAdmin` in a recoverable container.
diff --git a/content/contracts-sui/1.x/timelock.mdx b/content/contracts-sui/1.x/timelock.mdx
new file mode 100644
index 00000000..830e6ce0
--- /dev/null
+++ b/content/contracts-sui/1.x/timelock.mdx
@@ -0,0 +1,120 @@
+---
+title: Timelock
+---
+
+The `openzeppelin_timelock` package is a Sui-native take on OpenZeppelin's `TimelockController`: it enforces a minimum onchain delay between scheduling a privileged operation and executing it, giving users a window to react before the change takes effect.
+
+Because Move has no `target.call(data)`, the timelock stores each operation's typed parameters onchain and, at execution time, hands them back through a no-ability `ExecutionTicket` hot potato that the consumer redeems in the same transaction. The consumer dispatches to itself. An integration binds to a specific `Timelock` through a stored `OperationCap`, so the canonical-timelock check is enforced by the library rather than by hand-written asserts. Roles (proposer, executor, canceller, admin) come from a hard dependency on `openzeppelin_access::access_control`.
+
+
+Prefer the `OperationCap`-bound entries (`schedule_with`, `execute_with`, `cancel_with`). The raw `schedule` / `execute` / `cancel` entries do not bind the timelock id: if you use them you must assert `object::id(timelock)` against your canonical timelock in every wrapper, and forgetting it silently disables the delay. Also, `ExecutionTicket` has no abilities. Always follow `execute` with `consume` in the same PTB, or the transaction aborts.
+
+
+## Usage
+
+The package is not yet published to the Move Registry (MVR), so install it as a git dependency pinned to the release tag in `Move.toml`:
+
+```toml
+[dependencies]
+openzeppelin_timelock = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/timelock", rev = "v1.5.1" }
+openzeppelin_access = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/access", rev = "v1.5.1" }
+```
+
+The `openzeppelin_access` dependency is needed whenever your module names its types directly (e.g. `Auth`), as the example below does.
+
+Import the module:
+
+```move
+use openzeppelin_timelock::timelock::{Self, Timelock, OperationCap};
+```
+
+## Examples
+
+A single `Timelock` gating an AMM fee change. The `OperationCap` lives on the `Pool`, so the canonical-timelock binding is structural and call sites carry zero explicit type arguments:
+
+```move
+module my_protocol::amm;
+
+use openzeppelin_access::access_control::{Self, Auth};
+use openzeppelin_timelock::timelock::{Self, Timelock, OperationCap};
+use sui::clock::Clock;
+
+public struct AMM has drop {}
+
+public struct ProposerRole {}
+public struct ExecutorRole {}
+public struct CancellerRole {}
+public struct TimelockAdminRole {}
+
+// Drop-only witness: construction never leaves this module.
+public struct FeeChangeAction has drop {}
+
+public struct Pool has key {
+ id: UID,
+ fee_bps: u16,
+ // Binds fee-change operations to the canonical timelock.
+ fee_cap: OperationCap,
+}
+
+fun init(otw: AMM, ctx: &mut TxContext) {
+ // Roles default to root-role (`AMM`) administration: the default admin
+ // grants ProposerRole / ExecutorRole / CancellerRole memberships
+ // post-publish via `grant_role`.
+ let ac = access_control::new(otw, 7 * 86_400_000, ctx);
+
+ // `new` + `share` so the cap can be minted before the timelock is shared.
+ let tl = timelock::new(
+ 86_400_000, // 24h minimum delay
+ 604_800_000, // 7d grace period
+ ctx,
+ );
+ let fee_cap = tl.new_operation_cap();
+ tl.share();
+ transfer::share_object(Pool { id: object::new(ctx), fee_bps: 30, fee_cap });
+ transfer::public_share_object(ac);
+}
+
+// A proposer schedules; the OperationCap binds the call to the canonical timelock.
+public fun schedule_fee_change(
+ tl: &mut Timelock,
+ pool: &Pool,
+ proposer: &Auth,
+ new_fee_bps: u16,
+ salt: vector,
+ delay_ms: u64,
+ clock: &Clock,
+ ctx: &mut TxContext,
+): vector {
+ // Empty predecessor: this operation depends on no other.
+ tl.schedule_with(&pool.fee_cap, proposer, new_fee_bps, vector[], salt, delay_ms, clock, ctx)
+}
+
+// An executor cranks the ready operation; `consume` hands back the typed params.
+public fun execute_fee_change(
+ tl: &mut Timelock,
+ pool: &mut Pool,
+ executor: &Auth,
+ id: vector,
+ clock: &Clock,
+ ctx: &mut TxContext,
+) {
+ let ticket = tl.execute_with(&pool.fee_cap, executor, id, clock, ctx);
+ // The ticket MUST be consumed in the same PTB.
+ let (_op_id, new_fee_bps) = tl.consume(ticket, FeeChangeAction {});
+ pool.fee_bps = new_fee_bps;
+}
+```
+
+More complete integrations (an AMM with self-administered config, dual governance with two timelocks, and a timelocked package `UpgradeCap`) live in [`examples/timelock`](https://github.com/OpenZeppelin/contracts-sui/tree/v1.5.1/contracts/timelock/examples/timelock) on GitHub.
+
+## Key concepts
+
+- `Timelock` (shared object): the per-protocol registry holding each operation's state machine and typed params. Operation ids are deterministic keccak256 hashes, reproducible off-chain and isolated per instance.
+- `OperationCap` (stored): binds one operation kind to one `Timelock`. Minted at `init` and stored in the object you protect; carries no authority on its own.
+- `ExecutionTicket` (hot potato): minted by `execute` / `execute_with`, carries the typed params, and must be redeemed with `consume` (witness-gated) in the same PTB.
+- Roles: every gated entry takes `&Auth` from `openzeppelin_access` and checks it against the proposer / executor / canceller / admin role type bound at creation. Optional open-executor mode makes execution permissionless.
+- Delay and expiry: an operation is executable in the half-open window `[ready_at_ms, expires_at_ms)`, locked at schedule time. Later config changes never move an in-flight op, and `Done` is sticky, so operations are non-replayable. `min_delay_ms`, `grace_period_ms`, and `open_executor` are self-administered: every change is itself timelocked.
+
+## API Reference
+
+Use the full function-level reference here: [Timelock API](/contracts-sui/1.x/api/timelock).
diff --git a/content/contracts-sui/1.x/vesting-wallet.mdx b/content/contracts-sui/1.x/vesting-wallet.mdx
index d09e641b..52078002 100644
--- a/content/contracts-sui/1.x/vesting-wallet.mdx
+++ b/content/contracts-sui/1.x/vesting-wallet.mdx
@@ -206,6 +206,17 @@ let (inner, cap) = vesting_wallet::new(params, beneficiary, c
// Keep `cap` - it is required to tear the wallet down later.
```
+### Bundling a schedule for a consumer
+
+A protocol that **stores** a schedule to apply later - rather than building a wallet immediately - can accept a `VestingSchedule` instead of a bare `P`. The bundle welds a curve's `params` to its witness `W`; because only the curve module can mint one (`new_schedule` takes `W` by value), a consumer that accepts the bundle gets a compile-time guarantee that `W` and `P` form a coherent pair - something a bare `P` cannot provide. Build one from the linear curve with `vesting_schedule` (stepped) or `vesting_schedule_continuous`, or wrap a `Params` you already hold with `into_vesting_schedule`:
+
+```move
+// A coherent (Linear, Params) bundle, ready to hand to a consumer that pins both types.
+let schedule = vesting_wallet_linear::vesting_schedule(start_ms, cliff_ms, period_ms, steps);
+```
+
+`openzeppelin_sale::prefunded_sale` is the reference consumer: its `set_vesting_schedule` takes a `VestingSchedule`, so the schedule attached to a sale is guaranteed to match the witness and params pinned in the sale's own type - an issuer cannot misconfigure a sale into one whose tokens can never be released. Read the bundled params back with `vesting_wallet::params`.
+
### Custom schedules
To author a new curve, follow the `vesting_wallet_linear` pattern:
diff --git a/content/contracts-sui/index.mdx b/content/contracts-sui/index.mdx
index 83320ff4..fd408c01 100644
--- a/content/contracts-sui/index.mdx
+++ b/content/contracts-sui/index.mdx
@@ -26,15 +26,27 @@ import { latestStable } from "./latest-versions.js";
Cap-keyed, multi-coin spending allowances over an escrowed vault of funds.
+
+ Package-level guide for ordered data structures, including the comparator model and usage boundaries.
+
+
+ Vesting wallets with built-in linear-with-cliff and customizable release schedules.
+
9-decimal fixed-point types (`UD30x9`, `SD29x9`) for prices, fees, rates, and signed balance deltas.
Overflow-safe integer arithmetic with explicit rounding and decimal scaling helpers.
+
+ Package-level guide for the delayed-operation controller, including scheduling model, operation caps, and execution tickets.
+
Embeddable rate limiters for throttling on-chain actions.
+
+ Fixed-price token sales (presale / IDO) over a prefunded inventory, with caps, refunds, optional compliance gating, and optional vesting.
+
## API Reference
@@ -46,13 +58,25 @@ import { latestStable } from "./latest-versions.js";
Explore the complete allowance API, including the spend vault types, functions, events, and errors.
+
+ Explore the complete collections API, including all functions, macros, types, and expected error conditions for integration.
+
+
+ Explore the complete finance API, including the vesting wallet types, functions, events, and errors.
+
Explore the complete fixed-point math API, including its types, functions, and errors.
Explore the complete integer math API, including its functions, types, and errors.
+
+ Explore the complete timelock API, including all functions, types, emitted events, and expected error conditions for integration.
+
Explore the complete utilities API, including the rate limiter types, functions, and errors.
+
+ Explore the complete sale API, including its types, functions, events, and errors across all modules.
+
diff --git a/public/llms.txt b/public/llms.txt
index cb6d947f..9d4a1c3b 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -321,6 +321,10 @@ Each ecosystem section lists the smart-contract libraries and language-specific
- [Packages — Allowance — Spend Vault](https://docs.openzeppelin.com/contracts-sui/1.x/spend-vault)
- [Packages — Finance — Overview](https://docs.openzeppelin.com/contracts-sui/1.x/finance)
- [Packages — Finance — Vesting Wallet](https://docs.openzeppelin.com/contracts-sui/1.x/vesting-wallet)
+- [Packages — Sale — Overview](https://docs.openzeppelin.com/contracts-sui/1.x/sale)
+- [Packages — Sale — PrefundedSale](https://docs.openzeppelin.com/contracts-sui/1.x/prefunded-sale)
+- [Packages — Collections](https://docs.openzeppelin.com/contracts-sui/1.x/collections)
+- [Packages — Timelock](https://docs.openzeppelin.com/contracts-sui/1.x/timelock)
- [Packages — Utils — Overview](https://docs.openzeppelin.com/contracts-sui/1.x/utils)
- [Packages — Utils — Rate Limiter](https://docs.openzeppelin.com/contracts-sui/1.x/rate-limiter)
- [API Reference — Integer Math](https://docs.openzeppelin.com/contracts-sui/1.x/api/math)
@@ -328,6 +332,9 @@ Each ecosystem section lists the smart-contract libraries and language-specific
- [API Reference — Access](https://docs.openzeppelin.com/contracts-sui/1.x/api/access)
- [API Reference — Allowance](https://docs.openzeppelin.com/contracts-sui/1.x/api/allowance)
- [API Reference — Finance](https://docs.openzeppelin.com/contracts-sui/1.x/api/finance)
+- [API Reference — Sale](https://docs.openzeppelin.com/contracts-sui/1.x/api/sale)
+- [API Reference — Collections](https://docs.openzeppelin.com/contracts-sui/1.x/api/collections)
+- [API Reference — Timelock](https://docs.openzeppelin.com/contracts-sui/1.x/api/timelock)
- [API Reference — Utils](https://docs.openzeppelin.com/contracts-sui/1.x/api/utils)
## Midnight
diff --git a/src/navigation/sui/current.json b/src/navigation/sui/current.json
index 33b513af..a6a80bae 100644
--- a/src/navigation/sui/current.json
+++ b/src/navigation/sui/current.json
@@ -70,6 +70,16 @@
}
]
},
+ {
+ "type": "page",
+ "name": "Collections",
+ "url": "/contracts-sui/1.x/collections"
+ },
+ {
+ "type": "page",
+ "name": "Timelock",
+ "url": "/contracts-sui/1.x/timelock"
+ },
{
"type": "folder",
"name": "Finance",
@@ -120,6 +130,23 @@
"url": "/contracts-sui/1.x/spend-vault"
}
]
+ },
+ {
+ "type": "folder",
+ "name": "Sale",
+ "defaultOpen": true,
+ "index": {
+ "type": "page",
+ "name": "Overview",
+ "url": "/contracts-sui/1.x/sale"
+ },
+ "children": [
+ {
+ "type": "page",
+ "name": "Prefunded Sale",
+ "url": "/contracts-sui/1.x/prefunded-sale"
+ }
+ ]
}
]
},
@@ -142,6 +169,16 @@
"name": "Access",
"url": "/contracts-sui/1.x/api/access"
},
+ {
+ "type": "page",
+ "name": "Collections",
+ "url": "/contracts-sui/1.x/api/collections"
+ },
+ {
+ "type": "page",
+ "name": "Timelock",
+ "url": "/contracts-sui/1.x/api/timelock"
+ },
{
"type": "page",
"name": "Finance",
@@ -156,6 +193,11 @@
"type": "page",
"name": "Allowance",
"url": "/contracts-sui/1.x/api/allowance"
+ },
+ {
+ "type": "page",
+ "name": "Sale",
+ "url": "/contracts-sui/1.x/api/sale"
}
]
}