From dfedbd5bcf3032441ff1d450f531eefb77a21b88 Mon Sep 17 00:00:00 2001 From: 0xNeshi Date: Tue, 14 Jul 2026 17:24:41 +0200 Subject: [PATCH] Add Token Sale docs --- content/contracts-sui/1.x/api/sale.mdx | 1218 ++++++++++++++++++ content/contracts-sui/1.x/index.mdx | 19 +- content/contracts-sui/1.x/prefunded-sale.mdx | 425 ++++++ content/contracts-sui/1.x/sale.mdx | 80 ++ content/contracts-sui/index.mdx | 6 + src/navigation/sui/current.json | 22 + 6 files changed, 1767 insertions(+), 3 deletions(-) create mode 100644 content/contracts-sui/1.x/api/sale.mdx create mode 100644 content/contracts-sui/1.x/prefunded-sale.mdx create mode 100644 content/contracts-sui/1.x/sale.mdx diff --git a/content/contracts-sui/1.x/api/sale.mdx b/content/contracts-sui/1.x/api/sale.mdx new file mode 100644 index 00000000..60ec5c9a --- /dev/null +++ b/content/contracts-sui/1.x/api/sale.mdx @@ -0,0 +1,1218 @@ +--- +title: Sale API Reference +--- + +This page documents the public API of `openzeppelin_sale` for OpenZeppelin Contracts for Sui `v1.x`. + +The package centers on `prefunded_sale` - the fixed-price sale object and its lifecycle - and the built-in `fixed_rate_curve` that prices it. Three supporting modules complete it: `refund_vault` (the escrow that guarantees refunds), `allowlist` (the typed compliance slot), and `receipt` (the buyer-bound claim ticket). All functions that observe time take `&Clock` (the shared Sui `Clock` singleton at `0x6`). + +### `prefunded_sale` [toc] [#prefunded_sale] + + + +```move +use openzeppelin_sale::prefunded_sale; +``` + +A fixed-price, pre-funded token sale. Created as an owned value during `Init`, then shared on activation. The sale draws from a fixed `Balance` inventory and never holds a `TreasuryCap`. + +`PrefundedSale` carries six type parameters - `` - all fixed at `create_sale`. Signatures below abbreviate the receiver as `PrefundedSale<..>`; `SaleCoin`, `PaymentCoin`, `CurveParams`, and the vesting slots are named directly where a signature needs them. + +Types + +- [`PrefundedSale<..>`](#prefunded_sale-PrefundedSale) +- [`Phase`](#prefunded_sale-Phase) +- [`SaleAdminCap`](#prefunded_sale-SaleAdminCap) +- [`ActivationTicket`](#prefunded_sale-ActivationTicket) +- [`Quote`](#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_params(sale, params)`](#prefunded_sale-set_vesting_schedule_params) +- [`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) +- [`mint_quote(sale, w, payment, rate)`](#prefunded_sale-mint_quote) +- [`phase(sale)`](#prefunded_sale-phase) +- [`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_params(sale)`](#prefunded_sale-vesting_schedule_params) +- [`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) +- [`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) + +Events + +- [`SaleCreated`](#prefunded_sale-SaleCreated) +- [`InventoryDeposited`](#prefunded_sale-InventoryDeposited) +- [`PerBuyerCapSet`](#prefunded_sale-PerBuyerCapSet) +- [`VestingScheduleParamsSet`](#prefunded_sale-VestingScheduleParamsSet) +- [`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) +- [`EAllocationOverflow`](#prefunded_sale-EAllocationOverflow) +- [`EInsufficientInventory`](#prefunded_sale-EInsufficientInventory) +- [`EPerBuyerCapExceeded`](#prefunded_sale-EPerBuyerCapExceeded) +- [`EPerEntryCapExceeded`](#prefunded_sale-EPerEntryCapExceeded) +- [`ESoftCapNotMet`](#prefunded_sale-ESoftCapNotMet) +- [`ESoftCapMet`](#prefunded_sale-ESoftCapMet) +- [`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) + +#### 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. Read it with `phase`. + + + +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`) and consumed by `purchase`, in the same PTB. Cannot be stored, copied, replayed, or transferred. + + + +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`. + +Emits `SaleCreated`. + + + +Adds sale tokens to inventory. May be called multiple times during `Init`. Authority is implicit: the sale is owned. + +Aborts with `ENotInit` if the sale is not in `Init` phase. + +Emits `InventoryDeposited`. + + + +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. 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`. 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 `VestingScheduleParamsSet`. + + + +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 `EWrongVault` 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`). + + + +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 `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 `ESaleWindowStillOpen` if the window has not yet closed. + +Aborts with `ESoftCapMet` if no soft cap is configured or `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 `ESoftCapMet` 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`. + + + +Witness-gated quote constructor. The curve module calls this from its own `quote(..)` after running its pricing math; the `allocation` is `payment.value() * rate`. Requires a `Curve` value, so a caller cannot mint a quote without the declaring curve module. + +Aborts with `EZeroPayment` if `payment` has zero value. + +Aborts with `EAllocationOverflow` if `payment.value() * rate` would exceed `u64::MAX`. + + + +The sale's current lifecycle phase. + + + +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(params)` if a vesting schedule was attached during `Init`, otherwise `None`. + + + +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). + + + +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. + + +#### 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_params`. + + + +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 quote's `allocation` (`paid * rate`) would exceed `u64::MAX`. + + + +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`. + + + +A cancel was attempted on a sale that has met its goal, or has no soft cap set. + + + +`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_params` 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`). + + +### `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) + +#### 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`. + +Aborts with `ERequiredInventoryOverflow` if `hard_cap * rate` would exceed `u64::MAX`. + + + +Mints a `Quote` for a buyer's payment `balance`, allocating `balance.value() * rate`. The `Quote` carries the balance through to `purchase`, welding pricing and funds together for one PTB. + +Aborts with `prefunded_sale::EZeroPayment` if `balance` has zero value. + +Aborts with `prefunded_sale::EAllocationOverflow` if `balance.value() * rate` would exceed `u64::MAX`. + + + +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. + + +### `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) +- [`VaultDeposit`](#refund_vault-VaultDeposit) +- [`VaultStateChanged`](#refund_vault-VaultStateChanged) +- [`VaultRelease`](#refund_vault-VaultRelease) + +Errors + +- [`ENotActiveState`](#refund_vault-ENotActiveState) +- [`ENotRefundingState`](#refund_vault-ENotRefundingState) +- [`ENotClosedState`](#refund_vault-ENotClosedState) +- [`EWrongVaultCap`](#refund_vault-EWrongVaultCap) +- [`EInsufficientLocked`](#refund_vault-EInsufficientLocked) + +#### 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 `VaultDeposit` (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`. + +Aborts with `EWrongVaultCap` if `cap` does not control `vault`. + +Aborts with `ENotRefundingState` if `vault` is not `Refunding`. + +Aborts with `EInsufficientLocked` if `amount` exceeds the locked balance. + +Emits `VaultRelease`. + + + +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 `VaultRelease`. + + + +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`. + + + +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. + + +### `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/index.mdx b/content/contracts-sui/1.x/index.mdx index 368b80bc..f4cd4eea 100644 --- a/content/contracts-sui/1.x/index.mdx +++ b/content/contracts-sui/1.x/index.mdx @@ -2,12 +2,13 @@ title: Contracts for Sui 1.x --- -**OpenZeppelin Contracts for Sui v1.x** ships four core packages: +**OpenZeppelin Contracts for Sui v1.x** ships these core packages: - `openzeppelin_math` for deterministic integer arithmetic, configurable rounding, and decimal scaling. - `openzeppelin_fp_math` for 9-decimal fixed-point arithmetic (`UD30x9`, `SD29x9`) backed by `u128`. - `openzeppelin_access` for role-based authorization and ownership-transfer wrappers around privileged `key + store` objects. - `openzeppelin_utils` for embeddable building blocks; its first module, `rate_limiter`, provides multi-strategy rate limiting. +- `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 @@ -35,6 +36,8 @@ 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_sale` - are added by git revision instead of `mvr add` (see the next step). + ### 3. Verify `Move.toml` `mvr add` updates `Move.toml` automatically. With all three installed it should include: @@ -47,6 +50,14 @@ openzeppelin_fp_math = { r.mvr = "@openzeppelin-move/fixed-point-math" } openzeppelin_utils = { r.mvr = "@openzeppelin-move/utils" } ``` +Since `openzeppelin_sale` isn't on MVR yet, add it manually, pinned to a git revision: + +```toml +openzeppelin_sale = { git = "https://github.com/OpenZeppelin/contracts-sui.git", subdir = "contracts/finance", rev = "v1.5.0" } +``` + +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. + ### 4. Add a Minimal Module Create `sources/quickstart.move`: @@ -83,13 +94,15 @@ sui move test - Need fractional values like prices, fees, rates, or signed deltas? Use [Fixed-Point Math](/contracts-sui/1.x/fixed-point). - Need integer arithmetic with safe overflow and explicit rounding? Use [Integer Math](/contracts-sui/1.x/math). - Need to throttle withdrawals, meter per-user budgets, gate action reuse, or delay an action? Use [Rate Limiter](/contracts-sui/1.x/rate-limiter). +- 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). +- 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), [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). +- Sale modules: [Prefunded Sale](/contracts-sui/1.x/prefunded-sale). - Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control). -- API reference: [Integer Math](/contracts-sui/1.x/api/math), [Fixed-Point Math](/contracts-sui/1.x/api/fixed-point), [Access](/contracts-sui/1.x/api/access), [Utilities](/contracts-sui/1.x/api/utils). +- 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), [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..c8f7e7a9 --- /dev/null +++ b/content/contracts-sui/1.x/prefunded-sale.mdx @@ -0,0 +1,425 @@ +--- +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; read it with `phase()`. + +## 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_params├ 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 (`ESoftCapMet`), 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_params`. 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 params, 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`, and attach concrete params with `set_vesting_schedule_params`. 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` / `ESoftCapMet` | 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.0/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..fa80b82b --- /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.0" } +``` + +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.0/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/index.mdx b/content/contracts-sui/index.mdx index ba9f99a1..edf1cdef 100644 --- a/content/contracts-sui/index.mdx +++ b/content/contracts-sui/index.mdx @@ -32,6 +32,9 @@ import { latestStable } from "./latest-versions.js"; 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 @@ -49,4 +52,7 @@ import { latestStable } from "./latest-versions.js"; 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/src/navigation/sui/current.json b/src/navigation/sui/current.json index 55aef69f..1d04c055 100644 --- a/src/navigation/sui/current.json +++ b/src/navigation/sui/current.json @@ -81,6 +81,23 @@ "url": "/contracts-sui/1.x/rate-limiter" } ] + }, + { + "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" + } + ] } ] }, @@ -107,6 +124,11 @@ "type": "page", "name": "Utils", "url": "/contracts-sui/1.x/api/utils" + }, + { + "type": "page", + "name": "Sale", + "url": "/contracts-sui/1.x/api/sale" } ] }