Maelstrom
Launch coin

Contracts

Two Move packages on Sui mainnet, plus one small package per coin.

Deployed on 13 September 2026. The deploy script's record is deployments/mainnet.json in the repository. The packages carry their original on-chain names, vortex and vortex_locker: the project was renamed Maelstrom after they were published, and a published package's name cannot change.

ID
vortex_locker package — immutable0xc3f6f683a5da0d555a303f9fa5a43e86a6dcfcd399760426428963bdacc242fc
vortex package, latest version — calls go here0xc935f0ab11a24b32b6f8796bc78d93ce6f2d2969f5d4b4f6657b24352b61408a
vortex package, original version — types and events live here0xc935f0ab11a24b32b6f8796bc78d93ce6f2d2969f5d4b4f6657b24352b61408a
Launchpad — shared object0x61a4aacb5b0beea23a6e3fb789b48a6f1b6f95b1c8a98b8d1bd5112b4666c5c4
AdminCap — held by the owner0x57f0a498f4d924b1e8a8aefabc0a3b4cf854700be32c3cdcba021160c9baee7d
vortex UpgradeCap — held by the owner0x08870d01e28a6cbb4f5eceeaf7c35af9494fd16c9bdc712ec7478e5c34f0e77a

The locker was made immutable in transaction GDpRZpwQiJPHkU4dwXq5mGgBLV9UvhtzGdUSQGa3rXRQ; its UpgradeCap no longer exists.

The two vortex package IDs are the same until the first upgrade. After one, calls go to the latest ID, while every type — Launchpad, Launch<T, Q>, the events — keeps the original ID in its name. That is how Sui packages work, not a choice made here.

Your coin's own package

Every coin launched here is move/coin_template, published by the creator's wallet. It is not compiled per coin: the template's bytecode has its module and one-time witness renamed after the symbol and its four string constants replaced, and those are the only differences between any two coins. Its init is the whole of its behaviour:

  • coin_registry::new_currency_with_otw registers the coin with 9 decimals and its strings;
  • mint creates 1,000,000,000 coins, once, and sends them to the publisher;
  • make_supply_burn_only consumes the TreasuryCapno mint path exists afterwards, for anyone;
  • finalize_and_delete_metadata_cap — the name, symbol, description and logo can never change.

The same transaction calls 0x2::package::make_immutable on the package's UpgradeCap, so the module can never change either. What is left: no mint, no owner, no pause, no deny list, no transfer fee, no upgrade. Anyone holding the coin can burn it through its Currency, which is what makes the buyback-and-burn route shrink the real supply.

The launchpad does not check any of this at launch — launch_as_a accepts any Coin<T>. The interface only ever launches template coins, and the SDK's isTemplateCoin flags any launch whose Currency is not burn-only with its metadata cap deleted. A coin launched some other way, by calling the contract directly, still gets a locked pool; it just does not get these guarantees, and the interface says so.

External objects it uses

ID
Cetus CLMM, original package (types: Pool, Position)0x1eabed72c53feb3805120a081dc15963c204dc8d091542592abaf7a35689b2fb
Cetus CLMM, published-at (v14) — what the launchpad links against0x25ebb9a7c50eb17b3fa9c5a30fb8b5ad8f97caaf4928943acbcff7153dfee5e3
Cetus GlobalConfig0xdaa46292632c3c4d8f31f23ea0f9b36a28ff3677e9684980e4438403a67a3d8f
Cetus Pools0xf699e7f2276f5c9a75944b37a0c5b5d9ddfd2471bf6242483b03ab2887d198d0
Sui CoinRegistry0xc
Sui Clock0x6

Calls worth knowing

Signatures as Move declares them, minus the trailing TxContext parameter, which the chain supplies. Every launchpad:: call goes to the latest vortex package ID.

Before launching

launchpad::is_quote_allowed<Q>(pad: &Launchpad): bool
launchpad::launch_fee(pad: &Launchpad): u64             // MIST
launchpad::creator_fee_bps(pad: &Launchpad): u64        // what a launch made now would snapshot
launchpad::launch_id_of<T>(pad: &Launchpad): Option<ID> // has this coin launched?
cetus_clmm::factory::is_right_order<A, B>(): bool       // true: launch_as_a<T, Q>; false: launch_as_b

These are views; call them through a simulated transaction. The SDK reads the same answers straight from object state instead: fetchLaunchpad, fetchQuoteDecision, findLaunchId, coinIsA.

Launching

Prompt one is tx.publish(template) followed by 0x2::package::make_immutable(upgrade_cap)patchTemplate and buildPublishCoinTransaction in the SDK. Prompt two is one PTB:

0x2::coin_registry::finalize_registration<T>(
    registry: &mut CoinRegistry,       // 0xc
    currency: Receiving<Currency<T>>,  // only while still pending; anyone may call it
)

launchpad::launch_as_a<T, Q>(          // launch_as_b<T, Q> for a coin that sorts below Q
    pad: &mut Launchpad,
    cetus: &GlobalConfig,
    pools: &mut Pools,
    supply: Coin<T>,         // the whole supply
    seed: Coin<Q>,           // the dev buy — at least a few base units
    fee: Coin<SUI>,          // at least launch_fee; the excess comes back
    tick_spacing: u32,       // 200 (1%) or 220 (2%)
    tick_lower: u32,         // the boundary tick, two's complement; launch_as_b takes tick_upper
    creator_bps: u64,        // premine, at most 2000
    fee_route: u8,           // 0 wallet, 1 buyback-burn
    fee_recipient: address,  // @0x0 means the sender
    min_coins_out: u64,      // least the dev buy may return
    metadata: String,        // links as JSON, at most 1024 bytes
    clock: &Clock,           // 0x6
)

buildLaunchTransaction builds it, and previewLaunch reproduces its arithmetic to the base unit first. The creator recorded on the launch — the only address that can change its fee route and recipient — is the transaction's sender.

After launching

// Anyone. Proceeds only reach the recorded recipient, the buyback sink and the treasury, or the burn.
launchpad::collect_as_a<T, Q>(pad: &Launchpad, launch: &mut Launch<T, Q>, currency: &mut Currency<T>,
                              cetus: &GlobalConfig, pool: &mut Pool<T, Q>, clock: &Clock)
launchpad::collect_as_b<T, Q>(pad: &Launchpad, launch: &mut Launch<T, Q>, currency: &mut Currency<T>,
                              cetus: &GlobalConfig, pool: &mut Pool<Q, T>, clock: &Clock)

// The creator only. Both take effect from the next collect.
launchpad::set_fee_route<T, Q>(pad: &Launchpad, launch: &mut Launch<T, Q>, fee_route: u8)
launchpad::set_fee_recipient<T, Q>(pad: &Launchpad, launch: &mut Launch<T, Q>, recipient: address)

// Reads on a launch
launchpad::creator / fee_recipient / fee_route / launch_creator_fee_bps / coin_is_a
launchpad::pool_id / supply / burned / dust / earned / locker
locker::position(locker: &Locker): &Position
locker::position_id / pool_id / liquidity

The Currency<T> a collect needs lives at an address derived from the registry and the coin type (currencyIdOf in the SDK). buildCollectTransaction batches collects for many launches into one transaction; the keeper uses exactly that.

Finding launches

There is no subgraph to ask. The Launchpad object lists every launch itself: launches is a TableVec<ID> in launch order, and by_coin a Table<TypeName, ID>. Both are dynamic fields that any node can read at a derived ID, so the list never depends on how much event history a node has kept — public Sui nodes keep weeks of it at most. fetchAllLaunches pages through them.

The events — Launched, FeesCollected, BoughtBackAndBurned, FeeRouteUpdated, FeeRecipientUpdated, SettingsUpdated, QuoteDecisionUpdated, and the locker's Locked — carry everything an indexer needs, and their BCS layouts are in the SDK's events.ts. Launched puts pool_id first so a pipeline reading raw bytes finds the pool without decoding anything. The optional Goldsky pipeline in indexer/ streams them, with the Cetus swaps of launched pools, into Postgres for charts and history.

Where the liquidity lock actually lives

A Cetus position is an object, and it is wrapped twice:

Launch<T, Q>            shared object — find it by its ID, or through Launchpad.by_coin
└─ locker: Locker       vortex_locker::locker::Locker
   └─ position: Position    cetus_clmm::position::Position

Wrapped objects do not appear at their own ID on explorers; read the Launch object's locker.position field, or the Locked event, which records the position and pool IDs.

The locker module is five functions. lock(position): Locker takes a position in and has no inverse. position(&Locker): &Position hands out an immutable reference, which is enough for Cetus's collect_fee and nothing that moves principal — close_position needs the position by value, add_liquidity and remove_liquidity by mutable reference. The rest are read-only getters. Nothing takes a Locker by value or by mutable reference, and in Move only the defining module can unpack a struct. The package was made immutable immediately after publishing, so none of that can change.

Owner powers

The AdminCap can:

callwhat it changes
set_paused(pad, cap, paused)stops or resumes launches; collects are not affected
set_treasury(pad, cap, address)where the launch fee and the protocol's operating share go
set_buyback_sink(pad, cap, address)where the protocol's buyback share goes
set_launch_fee(pad, cap, mist)the flat launch fee, at most 100 SUI
set_creator_fee_bps(pad, cap, bps)the creator share for future launches
set_buyback_bps(pad, cap, bps)how the protocol's share divides between sink and treasury
add_tick_spacing / remove_tick_spacingwhich Cetus fee tiers new launches may use
set_open_quotes / set_quote_decision<Q>the pair-asset registry
migrate(pad, cap)moves the Launchpad onto a new package version after an upgrade

As the code stands it cannot touch a launch's liquidity, change an existing launch's creator share, route or recipient, or stop anyone collecting.

The UpgradeCap is the trust point

The vortex package has to stay upgradeable, for a reason outside its control: every Cetus pool call checks that the calling code was linked against a current Cetus version (config::checked_package_version). When Cetus retires the version the launchpad was built against, launches and collects abort until the launchpad is upgraded and relinked. An immutable launchpad would eventually stop working.

The holder of its UpgradeCap can therefore publish new launchpad code, and should be read as having that power. An upgrade can add functions to the launchpad module, which can rewrite a Launch's fields — its creator share, its recipient — or change how collects split fees. What no upgrade can do is get the position out of a Locker: that code lives in the immutable locker package, and a launchpad upgrade cannot add functions to it. An upgrade can redirect fees; it cannot pull liquidity.

Today the deployer holds both caps. They should sit with a multisig, ideally behind a timelock, and the contracts do not enforce that themselves. Sui also allows the cap to be narrowed for good — 0x2::package::only_dep_upgrades would permit relinking a new Cetus version and nothing else — at the price of never being able to fix a bug in the launchpad's own logic. That trade has not been made yet.

Cetus's powers

Every launch is a Cetus pool, and Cetus keeps administrative powers over its pools that nothing here can remove. Its pool manager role can disable swaps, liquidity changes or fee collection on any pool (pool::set_pool_status), change a pool's fee rate, and change the protocol fee rate up to 30% of the fee; Cetus can halt its whole protocol by raising the package version its GlobalConfig demands; and its package is upgradeable, so the meaning of &Position is ultimately Cetus's code. These powers are not hypothetical: after the May 2025 exploit, Cetus paused pools and restored them with governance functions that remain in its code, now disabled. The locker guarantees nobody holding a Maelstrom key can pull a launch's liquidity; it cannot guarantee anything about Cetus.

None of this has been audited.