Contracts
Launchpad contract
The launchpad Move package: create a collection with its own token type, set mint phases, mint sequentially or at random, reveal and pay out.
The launchpad package launches NFT collections on Sui. Each collection gets its own token type, its own TransferPolicy and Display, mint phases in any coin, optional allowlists, sequential or fair random mints, a one-time reveal and verified traits. It is a separate package from the marketplace and depends only on the Sui framework and Mysten's kiosk rules package.
On this page, PACKAGE_ID is the latest launchpad package id, C is a collection's marker type and T = PACKAGE_ID::nft::Nft<C> is its token type. Amounts are in base units of the payment coin (MIST for SUI, 10^-6 for USDC).
Model
- One type per collection. The creator publishes a one-module marker package (a template, renamed per collection, for example
ghosts::GHOSTS) and keeps thePublisheritsinitclaims.collection::create<C>checks thatPublisheragainstCand bindsCto the new collection in theRegistry, so each marker launches once. The marker package depends only on the Sui framework and never needs upgrading. - Tokens are
Nft<C>: a distinct type per collection, minted only by launchpad code, so supply caps hold. - Policy and display. The launchpad creates
TransferPolicy<T>(shared) andDisplay<T>for each collection. TheTransferPolicyCapstays inside the collection so royalties can always be paid out. The creator can take it out withrelease_policy_cap. - Marketplace fit.
Tis a plainkey + storetype with a standard policy, so the marketplace trades it like any other collection.
Modules
Shared objects and caps
Rules every call follows
- Gates. Creating collections and minting take
&Configand abort with101while the platform is paused or102when this package version is retired. Mints also abort with210while the creator paused the collection or the platform blocked it. - Never gated.
withdraw,claim_royalties,release_policy_capand the views ignore every pause and version switch. - Exact payments. Mints split
price * qtyfrom your&mut Coin<Pay>and you keep the rest, so a failed mint never holds funds. - Destination follows the policy. With a kiosk lock rule, tokens are locked into a kiosk: yours (
mint_into) or a new one created for you (mint_fresh, personal when the policy has the personal kiosk rule). Wallet mints of such a collection abort (250). Without the lock rule, tokens go to your wallet, or are placed (not locked) in a kiosk when you pass one. - Text that goes into
Displaytemplates (name, description, project URL, image URIs and extension) must not contain{,}or\(204). - Type names in events are
type_name::with_original_ids, in JSON without the0xprefix.
Creating a collection
Terms
These are pure constructors. Pass their results to create.
public fun capped(max: u64, reserve: u64, random: bool): Supply
public fun open_edition(ends_ms: u64, reserve: u64): Supply
public fun revealed_art(base_uri: String, ext: String, metadata_root: Option<vector<u8>>): Art
public fun hidden_art(placeholder: String, provenance: Option<vector<u8>>): Art
public fun royalty(bps: u16, min: u64, recipient: address, lock: bool, personal: bool): Royaltycapped:max > 0andreserve <= max(202). Public mints stop atmax - reserve. The reserve is for the creator only.open_edition: no cap. Every mint, reserve included, must happen beforeends_ms(219). Always sequential.revealed_art: art is live from the start at{base_uri}/{n}.{ext}and theDisplayis frozen at create. A metadata root commits every token's traits up front.hidden_art: placeholder image untilreveal.provenance = blake2b256(metadata_root)binds the reveal to that root.royalty:bps <= 10000.bps = min = 0adds no royalty rule.lockadds the kiosk lock rule andpersonalthe personal kiosk rule.
create
public fun create<C>(
reg: &mut Registry,
cfg: &Config,
publisher: &Publisher,
name: String,
symbol: String,
description: String,
project_url: String,
supply: Supply,
art: Art,
royalty: Royalty,
payees: vector<address>,
shares: vector<u16>,
ctx: &mut TxContext,
): (Collection<C>, CreatorCap<C>)Creates the collection, shares its TransferPolicy and returns the collection unshared together with the creator cap. Add phases (and stock, for random collections) in the same PTB, then call share<C>, the only way to consume a collection. Aborts: 201 the publisher is not from C's module, 151 C was launched before, 203 payees must be 1 to 10 nonzero addresses with nonzero shares summing to 10000, 205 roots must be 32 bytes.
Phases and setup
public fun add_phase<C, Pay>(cap: &CreatorCap<C>, col: &mut Collection<C>, name: String, start_ms: u64, end_ms: u64, price: u64, per_wallet: u64, phase_cap: u64, root: Option<vector<u8>>): u64
public fun edit_phase<C, Pay>(cap: &CreatorCap<C>, col: &mut Collection<C>, index: u64, name: String, start_ms: u64, end_ms: u64, price: u64, per_wallet: u64, phase_cap: u64, root: Option<vector<u8>>)
public fun stock<C>(cap: &CreatorCap<C>, col: &mut Collection<C>, count: u64)
public fun share<C>(col: Collection<C>)add_phasereturns the phase index.Payis the coin type, for example0x2::sui::SUIor USDC.start_ms < end_ms(209), at most 16 phases (208).edit_phasereplaces the terms and keeps the phase's minted and per-wallet counts. Close a phase by moving its end. Buyers are protected from price changes by themax_pricethey pass to mint.stockis for random collections only (223). It loads up to 16,384 slots per call (226when full). Random mints needstocked == max_supply(224). Stocking costs about 0.005 SUI per 64 items in storage, which is rebated to minters as the pool empties.
Other creator calls, all checked against the cap (207): set_paused, set_payees (only before the first public mint, 241), reveal, set_royalty_recipient, set_royalty (0, 0 removes the rule, 240 after the policy cap was released) and release_policy_cap.
public fun reveal<C>(cap: &CreatorCap<C>, col: &mut Collection<C>, base_uri: String, ext: String, metadata_root: Option<vector<u8>>)Runs once (230). With a provenance commitment the root must hash to it (231). Sets image_url = {base_uri}/{n}.{ext}, bumps the Display version and freezes it.
Minting
Sequential collections use the public functions. Random collections use the entry functions (223 otherwise). Paid mints share these arguments:
phaseis the phase index andqtyis 1 to 100 (211).allowanceandproofare your allowlist entry and its Merkle proof. Pass0and[]for open phases.pay: &mut Coin<Pay>must be the phase's coin type (215).price * qtyis split off it.max_priceis the highest unit price you accept (216).
Checks run in this order: gates (101, 102, 210), the policy is the collection's (225), quantity, random pool stocked (224), phase exists (212), start_ms <= now < end_ms (213, 214), coin, price, phase cap (217), supply (218 sold out, 219 open edition ended), allowlist proof (220) and wallet limit (221). In allowlist phases the limit is your allowance, or per_wallet if that is lower. In open phases it is per_wallet. Every check runs before any randomness is drawn.
Sequential mints
public fun mint<C, Pay>(
col: &mut Collection<C>,
cfg: &Config,
policy: &TransferPolicy<Nft<C>>,
phase: u64,
qty: u64,
allowance: u64,
proof: vector<vector<u8>>,
pay: &mut Coin<Pay>,
max_price: u64,
clock: &Clock,
ctx: &mut TxContext,
): vector<ID>Mints to your wallet and returns the token ids. Aborts with 250 when the policy has a lock rule.
public fun mint_into<C, Pay>(
col: &mut Collection<C>,
cfg: &Config,
policy: &TransferPolicy<Nft<C>>,
kiosk: &mut Kiosk,
kiosk_cap: &KioskOwnerCap,
phase: u64,
qty: u64,
allowance: u64,
proof: vector<vector<u8>>,
pay: &mut Coin<Pay>,
max_price: u64,
clock: &Clock,
ctx: &mut TxContext,
): vector<ID>Mints into a kiosk you control: locked when the policy has a lock rule, else placed.
public fun mint_fresh<C, Pay>(col: &mut Collection<C>, cfg: &Config, policy: &TransferPolicy<Nft<C>>, phase: u64, qty: u64, allowance: u64, proof: vector<vector<u8>>, pay: &mut Coin<Pay>, max_price: u64, clock: &Clock, ctx: &mut TxContext): IDMints into a new shared kiosk for you (personal when the policy needs it) and returns the kiosk id.
Random mints
entry fun mint_random<C, Pay>(col, cfg, policy, phase, qty, allowance, proof, pay, max_price, r: &Random, clock: &Clock, ctx)
entry fun mint_random_into<C, Pay>(col, cfg, policy, kiosk, kiosk_cap, phase, qty, allowance, proof, pay, max_price, r: &Random, clock: &Clock, ctx)
entry fun mint_random_fresh<C, Pay>(col, cfg, policy, phase, qty, allowance, proof, pay, max_price, r: &Random, clock: &Clock, ctx)Same arguments as the sequential versions, plus the Random object 0x8 before the clock. Token numbers are drawn uniformly from those not yet minted. After a random mint, only TransferObjects and MergeCoins may follow in the PTB, so you cannot create and share a kiosk after the call: use an existing kiosk (mint_random_into) or let the call create one (mint_random_fresh). mint_random_into with a wrong kiosk cap aborts before the draw (252).
Random mints are built so a minter cannot pick outcomes:
- They are private
entryfunctions, so other Move code cannot call them, and Sui rejects a PTB where anything other thanTransferObjectsorMergeCoinsfollows them. A transaction cannot inspect its draw and abort. - Every check that can abort runs before the draw.
- Every outcome costs the same gas, so a buyer cannot pick a gas budget that only fits the outcomes they want. Gas varies with how many items are left, which is known before the draw.
Creator mints
airdrop<C>(cap, col, cfg, policy, recipients, clock) sends one reserve token per recipient (222 past the reserve, 251 with no recipients). With a lock rule each recipient gets a new kiosk, else the token goes to the wallet. reserve_into<C>(cap, col, cfg, policy, kiosk, kiosk_cap, qty, clock) mints reserve tokens into the creator's kiosk. airdrop_random and reserve_random_into are the entry versions for random collections. Reserve mints and airdrops are free.
Payouts, royalties and traits
public fun withdraw<C, Pay>(col: &mut Collection<C>, cfg: &Config, ctx: &mut TxContext)
public fun claim_royalties<C>(col: &Collection<C>, policy: &mut TransferPolicy<Nft<C>>, ctx: &mut TxContext): u64
public fun attach<C>(col: &Collection<C>, nft: &mut Nft<C>, keys: vector<String>, values: vector<String>, proof: vector<vector<u8>>)
public fun attach_in_kiosk<C>(col: &Collection<C>, kiosk: &mut Kiosk, kiosk_cap: &KioskOwnerCap, item: ID, keys: vector<String>, values: vector<String>, proof: vector<vector<u8>>)withdrawcan be called by anyone. It pays everything collected in coinPay: each payee getsgross * share / 10000(the last payee takes the rounding remainder) and the platform fee goes to the treasury. It does nothing when nothing is held.claim_royaltiescan be called by anyone. It sends the policy's royalty balance to the royalty recipient. Afterrelease_policy_capit aborts (240) and the cap holder withdraws royalties directly.attachwrites verified traits onto a token. The leaf for tokennmust be inmetadata_root(232no root,233bad proof,234token of another collection).attach_in_kioskdoes the same for a token in your kiosk (locked is fine, listed is not).
Views include name, symbol, creator, max_supply, ends_ms, reserve, reserved, minted, is_random, stocked, policy, royalty_recipient, is_revealed, base_uri, metadata_root, provenance, is_paused, is_blocked, phase_count, phase(i), minted_by(phase, who), till<C, Pay>() (proceeds and fees held) and payees().
Merkle formats
- Hash: blake2b-256.
- Leaf:
H(0x00 || payload). Node:H(0x01 || min(a, b) || max(a, b))in lexicographic byte order. The proof is the sibling hashes from leaf to root, with no position bits. On a level with an odd count, the lone node moves up unchanged and adds nothing to the proof. - Allowlist payload:
bcs(address) || bcs(u64 allowance). - Token payload:
bcs(u64 n) || bcs(vector<String> keys) || bcs(vector<String> values), with keys and values in display order.
merkle::allow_leaf, merkle::token_leaf, merkle::join and merkle::verify compute the same thing on chain.
Featured collections
Collections with their own package (for example Rarelings) list themselves in the Registry with their package's Publisher, and the platform approves them.
public fun propose<T: key>(reg: &mut Registry, publisher: &Publisher, name: String, url: String)
public fun amend<T: key>(reg: &mut Registry, publisher: &Publisher, name: String, url: String)
public fun withdraw<T: key>(reg: &mut Registry, publisher: &Publisher)T is the item type the site lists. propose needs a publisher from T's package (152) and a type not listed yet (153). The entry starts pending. amend updates it and puts it back to pending. Views: collection_of<C>(), is_featured<T>(), feature<T>(). Approval and removal are admin only.
Fees and royalties
The platform fee is fee_bps of mint proceeds, 10 bps (0.1%) by default, capped at 500 bps (103). It is taken out of what the minter pays, not added on top, and is charged at the rate in force when the mint happens. It rounds down, so a mint total under 1,000 base units pays no fee. Reserve mints and airdrops are free. Fee and treasury changes go through a 48 hour timelock.
Royalties are set by the creator with royalty(bps, min, recipient, lock, personal) and enforced by the collection's TransferPolicy on every kiosk sale. The creator can change them later with set_royalty.
Events
All events are in PACKAGE_ID::events. Field order never changes.
The framework also emits TransferPolicyCreated<T> and DisplayCreated<T> at create, and VersionUpdated<T> at create and at reveal. A collection's item list is its Minted stream. Traits come from TraitsAttached plus the token object.
Error codes
Examples
These use @mysten/sui with the Transaction builder.
Mint into a new kiosk
import { Transaction } from "@mysten/sui/transactions";
const PACKAGE_ID = "0x..."; // latest launchpad package id
const CONFIG_ID = "0x..."; // shared launchpad::platform::Config
export function mintFresh(
tx: Transaction,
m: {
marker: string; // C, for example "0xabc::ghosts::GHOSTS"
collectionId: string;
policyId: string; // the collection's TransferPolicy<Nft<C>>
phase: number;
qty: number;
price: bigint; // phase price per token, in MIST
allowance?: bigint; // allowlist phases only
proof?: number[][]; // allowlist phases only
},
) {
const SUI = "0x2::sui::SUI";
const [pay] = tx.splitCoins(tx.gas, [m.price * BigInt(m.qty)]);
tx.moveCall({
target: `${PACKAGE_ID}::mint::mint_fresh`,
typeArguments: [m.marker, SUI],
arguments: [
tx.object(m.collectionId),
tx.object(CONFIG_ID),
tx.object(m.policyId),
tx.pure.u64(m.phase),
tx.pure.u64(m.qty),
tx.pure.u64(m.allowance ?? 0n),
tx.pure.vector("vector<u8>", m.proof ?? []),
pay,
tx.pure.u64(m.price), // max_price: refuse a higher unit price
tx.object("0x6"),
],
});
tx.mergeCoins(tx.gas, [pay]);
}For a random collection, call mint_random_fresh with tx.object("0x8") (Random) before the clock. Nothing but TransferObjects or MergeCoins may follow it in the PTB. For a USDC phase, pass the USDC coin type as Pay and a USDC coin as pay.
Mint into an existing kiosk
const [pay] = tx.splitCoins(tx.gas, [price * BigInt(qty)]);
tx.moveCall({
target: `${PACKAGE_ID}::mint::mint_into`,
typeArguments: [marker, "0x2::sui::SUI"],
arguments: [
tx.object(collectionId),
tx.object(CONFIG_ID),
tx.object(policyId),
tx.object(kioskId),
tx.object(kioskCapId),
tx.pure.u64(phase),
tx.pure.u64(qty),
tx.pure.u64(0),
tx.pure.vector("vector<u8>", []),
pay,
tx.pure.u64(price),
tx.object("0x6"),
],
});
tx.mergeCoins(tx.gas, [pay]);Launch a collection
After publishing the marker package and keeping its Publisher, the creator launches in one PTB:
const REGISTRY_ID = "0x..."; // shared launchpad::registry::Registry
const SUI = "0x2::sui::SUI";
const C = `${markerPackageId}::ghosts::GHOSTS`;
const supply = tx.moveCall({
target: `${PACKAGE_ID}::collection::capped`,
arguments: [tx.pure.u64(10_000), tx.pure.u64(100), tx.pure.bool(true)], // max, reserve, random
});
const art = tx.moveCall({
target: `${PACKAGE_ID}::collection::hidden_art`,
arguments: [tx.pure.string("ipfs://<cid>/egg.png"), tx.pure.option("vector<u8>", provenance)],
});
const royalty = tx.moveCall({
target: `${PACKAGE_ID}::collection::royalty`,
arguments: [tx.pure.u16(500), tx.pure.u64(0), tx.pure.address(creator), tx.pure.bool(true), tx.pure.bool(false)],
});
const [col, cap] = tx.moveCall({
target: `${PACKAGE_ID}::collection::create`,
typeArguments: [C],
arguments: [
tx.object(REGISTRY_ID),
tx.object(CONFIG_ID),
tx.object(publisherId),
tx.pure.string("Ghosts"),
tx.pure.string("GHOST"),
tx.pure.string("A collection of ghosts."),
tx.pure.string("https://example.com"),
supply,
art,
royalty,
tx.pure.vector("address", [creator]),
tx.pure.vector("u16", [10_000]),
],
});
tx.moveCall({
target: `${PACKAGE_ID}::collection::stock`, // random collections only, up to 16,384 per call
typeArguments: [C],
arguments: [cap, col, tx.pure.u64(10_000)],
});
tx.moveCall({
target: `${PACKAGE_ID}::collection::add_phase`,
typeArguments: [C, SUI],
arguments: [
cap, col, tx.pure.string("public"),
tx.pure.u64(startMs), tx.pure.u64(endMs),
tx.pure.u64(1_000_000_000n), // 1 SUI
tx.pure.u64(5), tx.pure.u64(0), // per_wallet, phase cap
tx.pure.option("vector<u8>", null), // no allowlist
],
});
tx.moveCall({ target: `${PACKAGE_ID}::collection::share`, typeArguments: [C], arguments: [col] });
tx.transferObjects([cap], creator);Collections over 16,384 items call stock again in later transactions before minting opens.
Pay out
tx.moveCall({
target: `${PACKAGE_ID}::collection::withdraw`,
typeArguments: [marker, "0x2::sui::SUI"],
arguments: [tx.object(collectionId), tx.object(CONFIG_ID)],
});
tx.moveCall({
target: `${PACKAGE_ID}::collection::claim_royalties`,
typeArguments: [marker],
arguments: [tx.object(collectionId), tx.object(policyId)],
});Anyone can send this. Proceeds always go to the payees and royalties to the royalty recipient.
