Create a sale
A sale is created in one programmable transaction, in three stages:
- A venue module’s
newstarts the sale and returns aPresaleInitializer<Meme, Quote>. - Setters on the initializer record the creator’s terms.
finalizechecks every setting together, mints the supply, and returns(Presale, PresaleCap).sharethen opens the sale.
The initializer is a hot potato: it has no abilities, and finalize is the only call that consumes it. Presale has key only, so it must be shared in the same transaction. A transaction that skips either step fails.
Before you start
Section titled “Before you start”- A new coin type
MemewhoseTreasuryCaphas minted nothing, and its shared Coin RegistryCurrency<Meme>. - A quote type and a venue that Blast’s
CreationPolicyallows.newaborts withEQuoteNotAllowedorEMigrationWitnessNotAllowedotherwise. - The IDs of the shared
FeePolicy,CreationPolicy, andVenueConfig, plus Cetus’sGlobalConfigandPoolsfor a Cetus sale. See Addresses.
Creators always call a venue module’s new (cetus::new or bluefin::new), which holds the migrator witness blast_presale::new requires. The venue is fixed for good.
-
Start. Call the venue’s
new, with type arguments<Meme, Quote>:cetus::new(treasury_cap, creation_policy, fee_policy, venue_config, cetus_config, pools, tick_spacing)bluefin::new(treasury_cap, currency, creation_policy, fee_policy, venue_config, tick_spacing, pool_fee_rate)
It records the pool tier (see Pool tier), checks the
CreationPolicy, that the sale coin differs from the quote, and that theTreasuryCaphas minted nothing, snapshots Blast’s current rates, and returns the initializer. -
Required setters. Each takes the initializer by mutable reference:
set_supply(initializer, token_supply, burnable)set_token_allocations(initializer, token_allocation_bps, liquidity_token_allocation_bps, recipient_allocations)set_raise(initializer, raise_target, raise_allocations)set_windows(initializer, duration_ms, liquidity_window_ms)set_eligibility(initializer, eligibility)set_fee_split(initializer, fee_split)
-
Optional setters:
set_participant_vesting(initializer, terms): each buyer’s tokens vest from that buyer’s claim.set_uncapped_phase(initializer, phase): lifts wallet caps from the phase’s start.set_hard_cap(initializer, starts_after_ms, closes_when_filled): holds the sale to its raise from that point. See Sale shapes.set_partner(initializer, creation_policy, partner, clock): names a Blast partner theCreationPolicycurrently accepts. This is the only time a partner can be named.
A setter called twice keeps its last value.
-
Finalize.
finalize(initializer, currency)checks every setting, mints the supply, consumes theTreasuryCap(the supply becomes fixed or burn-only), emitsPresaleCreated, and returns(Presale, PresaleCap). -
Share.
share(presale, start_ms, clock)opens the sale atmax(start_ms, now), so you can publish ahead of the start. It emitsPresaleOpened. -
Transfer the
PresaleCapto the creator.
Between finalize and share, the pending Presale accepts only share and Blast’s set_protocol_fees, which needs an AdminWitness<FeeAdmin> obtained by signing in through the shared Blast ACL in the same transaction. See Authority.
Value constructors
Section titled “Value constructors”Build these with Move calls and pass the results to the setters:
bps::new(value)builds each rate, where 10,000 is 100%.vesting_terms(cliff_ms, period_ms, periods)builds vesting terms.recipient_share(recipient, bps)orvested_recipient_share(recipient, bps, terms)builds one share. Gather shares withMakeMoveVecinto token, raise, and fee recipients. Any list may be empty.open_eligibility(wallet_cap, max_participants, min_commitment)orallowlisted_eligibility(root, leaf_count)builds the eligibility policy.uncapped_for_eligible(starts_after_ms)oruncapped_for_anyone(starts_after_ms, max_participants, min_commitment)builds a no-cap phase.fee_split(fee_recipients, holder_rewards_bps)builds the LP-fee split.
Example: a Bluefin sale
Section titled “Example: a Bluefin sale”An open sale of 1 billion tokens (9 decimals) raising 10,000 SUI over two days, with a vested treasury share. The supply split assumes Blast’s token rate is zero, since buyers, liquidity, Blast, and recipients must total exactly 10,000 basis points.
import { Transaction } from "@mysten/sui/transactions";
const PKG = "0x…"; // blast_presale packageconst FEE_POLICY = "0x…";const CREATION_POLICY = "0x…";const VENUE_CONFIG = "0x…";const TREASURY_CAP = "0x…"; // TreasuryCap<MEME>, zero supplyconst CURRENCY = "0x…"; // Currency<MEME> in the Coin Registryconst creator = "0x…";const treasury = "0x…";
const MEME = "0x…::meme::MEME";const SUI = "0x2::sui::SUI";
const DAY = 86_400_000;const tx = new Transaction();type Args = Parameters<typeof tx.moveCall>[0]["arguments"];const bps = (value: number) => tx.moveCall({ target: `${PKG}::bps::new`, arguments: [tx.pure.u64(value)] });// Value constructors take no type arguments; the sale's own calls take <MEME, SUI>.const value = (fn: string, args: Args = []) => tx.moveCall({ target: `${PKG}::blast_presale::${fn}`, arguments: args });const sale = (fn: string, args: Args = []) => tx.moveCall({ target: `${PKG}::blast_presale::${fn}`, typeArguments: [MEME, SUI], arguments: args });
// Treasury: 30% of supply, vesting in 12 thirty-day steps; nothing unlocks before day 90.const treasuryVesting = value("vesting_terms", [ tx.pure.u64(90 * DAY), tx.pure.u64(30 * DAY), tx.pure.u64(12),]);const tokenRecipients = tx.makeMoveVec({ type: `${PKG}::blast_presale::RecipientShare`, elements: [value("vested_recipient_share", [tx.pure.address(treasury), bps(3_000), treasuryVesting])],});// Treasury: 30% of the raise, paid at migration. The pool keeps the rest after Blast's fee.const raiseRecipients = tx.makeMoveVec({ type: `${PKG}::blast_presale::RecipientShare`, elements: [value("recipient_share", [tx.pure.address(treasury), bps(3_000)])],});// After Blast's LP fees: 50% to the creator, 50% to the holder allocation.// Holder Quote funds rounds; holder Meme is paid to the governed treasury.const feeSplit = value("fee_split", [ tx.makeMoveVec({ type: `${PKG}::blast_presale::RecipientShare`, elements: [value("recipient_share", [tx.pure.address(creator), bps(5_000)])], }), bps(5_000),]);// Open sale: up to 2,000 wallets, 10–1,000 SUI each (SUI has 9 decimals).const eligibility = value("open_eligibility", [ tx.pure.u64(1_000n * 10n ** 9n), tx.pure.u64(2_000), tx.pure.u64(10n * 10n ** 9n),]);
// 1. Start: record the Bluefin tier and take the initializer.const initializer = tx.moveCall({ target: `${PKG}::bluefin::new`, typeArguments: [MEME, SUI], arguments: [ tx.object(TREASURY_CAP), tx.object(CURRENCY), tx.object(CREATION_POLICY), tx.object(FEE_POLICY), tx.object(VENUE_CONFIG), tx.pure.u32(60), // tick_spacing tx.pure.u64(3_000), // pool_fee_rate: the raw fee_rate Bluefin's create_pool takes ],});
// 2. Terms.sale("set_supply", [initializer, tx.pure.u64(1_000_000_000n * 10n ** 9n), tx.pure.bool(false)]);sale("set_token_allocations", [initializer, bps(4_000), bps(3_000), tokenRecipients]);sale("set_raise", [initializer, tx.pure.u64(10_000n * 10n ** 9n), raiseRecipients]);sale("set_windows", [initializer, tx.pure.u64(2 * DAY), tx.pure.u64(7 * DAY)]);sale("set_eligibility", [initializer, eligibility]);sale("set_fee_split", [initializer, feeSplit]);
// 3. Finalize, share, and hand over the cap.const [presale, presaleCap] = sale("finalize", [initializer, tx.object(CURRENCY)]);sale("share", [presale, tx.pure.u64(Date.parse("2026-11-01T15:00:00Z")), tx.object.clock()]);tx.transferObjects([presaleCap], creator);With no hard cap, this sale overflows: it takes commitments above the raise for its whole window and fills every wallet at one rate.
For Cetus, replace step 1 with cetus::new(treasury_cap, creation_policy, fee_policy, venue_config, cetus_config, pools, tick_spacing). It takes no Currency and no fee rate. The rest is the same.
Pool tier
Section titled “Pool tier”The pool tier belongs to the venue, not the sale. Presale and PresaleCreated carry none.
- Bluefin takes
tick_spacing(1 to 400) andpool_fee_rate(1 to 20,000), passed as the rawfee_rateBluefin’screate_pooltakes. Check Bluefin’s docs for the tier you want. Out of range aborts withbluefin::EInvalidTier. The coin’s symbol must be at most 32 bytes (bluefin::ESymbolTooLong). - Cetus takes
tick_spacingand records the fee rate Cetus sets for it. It also mints the coin’s CetusPoolCreationCap, registers the pair at that spacing, and stores the cap inVenueConfig. The quote must be in Cetus’s permission-pair allowlist at that spacing.
Both record a venue_config::PoolTier { tick_spacing, pool_fee_rate } in the shared VenueConfig under venue_config::PoolTierKey<Meme>, and emit venue_config::PoolTierReserved<Meme, Quote, Venue>, where Venue is cetus::Venue or bluefin::Venue, before PresaleCreated. Migration reads the tier back, so cetus::migrate and bluefin::migrate both take the VenueConfig.
Sale shapes
Section titled “Sale shapes”Two optional settings decide how a sale fills:
- A no-cap phase (
set_uncapped_phase) lifts wallet caps fromstarts_after_msinto the window. It does nothing else. - A hard cap (
set_hard_cap) holds commitments to the raise, first come, first served, fromstarts_after_msinto the window. Each commit takes only what is left of the raise and returns the excess. Before that point the sale overflows. A hard cap does not lift wallet caps.
| Shape | Settings |
|---|---|
| Overflow for the whole window, filled pro rata | No hard cap |
| First come, first served within wallet caps | Hard cap at 0 |
| First come, first served without caps | No-cap phase and hard cap both at 0 |
| A capped overflow round, then a first-come no-cap round | No-cap phase and hard cap at the same offset |
// First come, first served within wallet caps, closing as soon as the raise is full.sale("set_hard_cap", [initializer, tx.pure.u64(0), tx.pure.bool(true)]);
// Or: one day of capped overflow, then a first-come round open to anyone.const phase = value("uncapped_for_anyone", [ tx.pure.u64(DAY), tx.pure.u64(5_000), tx.pure.u64(10n * 10n ** 9n),]);sale("set_uncapped_phase", [initializer, phase]);sale("set_hard_cap", [initializer, tx.pure.u64(DAY), tx.pure.bool(false)]);Closing when full
Section titled “Closing when full”With closes_when_filled, anyone may settle the sale as soon as its raise is full under the hard cap, without waiting for the window’s end. The liquidity deadline then counts from that close. Without it, a full sale runs to its end, so the pool can open at an announced time. A sale without a hard cap never closes early.
Settings
Section titled “Settings”| Setter | Argument | Rule |
|---|---|---|
set_supply |
token_supply |
Whole supply in atomic units, above 0. |
burnable |
true for burn-only supply, false for fixed. |
|
set_token_allocations |
token_allocation_bps |
Buyers’ share of supply, above 0. |
liquidity_token_allocation_bps |
Pool’s share of supply, above 0. | |
recipient_allocations |
Up to 64 distinct, nonzero addresses. Buyers + liquidity + Blast’s token rate + recipients = 10,000. | |
set_participant_vesting |
terms |
VestingTerms. Each buyer’s schedule starts at their claim. |
set_raise |
raise_target |
Quote to raise, atomic units, above 0. |
raise_allocations |
Up to 64 recipients. With Blast’s raise rate they must stay below 10,000; the pool keeps the rest. | |
set_windows |
duration_ms |
Commitment window, above 0. |
liquidity_window_ms |
Time after the window before a funded sale can be cancelled. At least 7 days. | |
set_eligibility |
eligibility |
Open: 0 < min_commitment ≤ wallet_cap, min × max_participants ≥ raise_target, 1 to 65,536 wallets. Allowlist: 32-byte root, 1 to 65,536 leaves. |
set_uncapped_phase |
phase |
starts_after_ms below duration_ms. A phase for anyone admits at least the policy’s wallets and at most 65,536, and must still be able to reach the raise at its minimum. |
set_hard_cap |
starts_after_ms |
Below duration_ms (EInvalidHardCap). |
closes_when_filled |
Whether the sale may settle as soon as its raise is full. | |
set_fee_split |
fee_split |
Up to 16 unvested, distinct fee recipients plus the holder share, totalling 10,000. |
A setting never set stays zero, so an unset supply, raise, or window fails its own check (EInvalidTokenAllocation, EInvalidRaiseTarget, EInvalidWindow). A missing eligibility policy or fee split aborts with EMissingSetting.
finalize also aborts with EUnseedablePrice if the pool’s quote per pool token, in atomic units, falls outside the range both venues can price, roughly 2⁻⁶² to 2⁶². A huge raise against a tiny pool allocation, or the reverse, fails this check.
Pitfalls
Section titled “Pitfalls”- Do everything in one transaction. The initializer must be finalized and the sale shared before it ends.
- A partner can be named only on the initializer, before
finalize. - Size an open policy before creating.
min_commitment × max_participantsmust reach the raise target. - If the coin is regulated, its issuer can block participants’ claims after migration. Blast vets this before listing a sale.