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 — immutable | 0xc3f6f683a5da0d555a303f9fa5a43e86a6dcfcd399760426428963bdacc242fc |
vortex package, latest version — calls go here | 0xc935f0ab11a24b32b6f8796bc78d93ce6f2d2969f5d4b4f6657b24352b61408a |
vortex package, original version — types and events live here | 0xc935f0ab11a24b32b6f8796bc78d93ce6f2d2969f5d4b4f6657b24352b61408a |
Launchpad — shared object | 0x61a4aacb5b0beea23a6e3fb789b48a6f1b6f95b1c8a98b8d1bd5112b4666c5c4 |
AdminCap — held by the owner | 0x57f0a498f4d924b1e8a8aefabc0a3b4cf854700be32c3cdcba021160c9baee7d |
vortex UpgradeCap — held by the owner | 0x08870d01e28a6cbb4f5eceeaf7c35af9494fd16c9bdc712ec7478e5c34f0e77a |
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_otwregisters the coin with 9 decimals and its strings;mintcreates 1,000,000,000 coins, once, and sends them to the publisher;make_supply_burn_onlyconsumes theTreasuryCap— no 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 against | 0x25ebb9a7c50eb17b3fa9c5a30fb8b5ad8f97caaf4928943acbcff7153dfee5e3 |
Cetus GlobalConfig | 0xdaa46292632c3c4d8f31f23ea0f9b36a28ff3677e9684980e4438403a67a3d8f |
Cetus Pools | 0xf699e7f2276f5c9a75944b37a0c5b5d9ddfd2471bf6242483b03ab2887d198d0 |
Sui CoinRegistry | 0xc |
Sui Clock | 0x6 |
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:
| call | what 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_spacing | which 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.

