Contracts Docs

How the contract works

Every token graduates to Cetus at 4,200 SUI. Liquidity is locked forever.

Contracts deployed on Sui mainnet · app coming soon
Price (SUI per 1M tokens)
0
real SUI raised / 4,200
1.69
price · SUI per 1M tokens
0
tokens bought / 1,000M
1,690
fully diluted market cap · SUI

1,690 SUI phantom reserve

The curve is priced as if 1,690 SUI were already in the pool. That SUI is never deposited and can never be withdrawn.

Virtual SUI: 3,190Tokens remaining: 529.8M

WhyEvery token gets the same starting price without anyone seeding liquidity, and the pool can always pay every seller.

Token distribution

At 4,200 SUI, the pool moves to Cetus at the curve's final price. Nobody receives tokens at graduation.

Pool lifecycle
Supply at graduation

WhyFull-range liquidity keeps Cetus liquid at every price. Leftover tokens are frozen, so they can never be sold.

Allocation ratio

r = 1,690 / 5,890 = 0.2869

Anti-snipe

Buy fee, first 5 seconds
Fee split

WhyBots gain nothing by buying the instant a token launches. The creator is exempt, and there is no whitelist.

Buyers

  • Fixed 1B supply, with no creator allocation
  • You can sell back to the curve until graduation
  • Cetus opens at the curve's final price
  • Liquidity is locked permanently

Creators

  • Your own buys are exempt from anti-snipe fees
  • 20% of the 1% trading fee, plus an optional 0–5% tax
  • 70% of Cetus trading fees after graduation
  • Only you can change your payout wallet

Permissions

The admin wallet can configure future launches and rotate the protocol fee recipient. It cannot pause, drain or rewrite a live pool.

NobodyAdmin walletCreatorCreator, at launchAnyoneCetus
ItemNobodyAdminCreatorAnyoneCetusNote
Tokens and pool
Supply and token metadataFixed at launch
SUI and tokens on the curveOnly trades move them
Cetus liquidity and leftover tokensLocked permanently
Graduation timingTriggered by the buy that reaches 4,200 SUI
Terms
Supply, phantom SUI, target, feesFixed for each pool
Creator tax (0–5%)Set at launch, then fixed
Anti-snipe scheduleSame for every token
70/30 Cetus fee splitFixed in code
Cetus fee tier rateSet by Cetus
Payouts
Creator payout walletOriginal creator only
Protocol payout walletCannot be set to an empty address
Triggering payoutsFunds go only to the stored wallets
System
New launchesLive pools are not affected
Contract logicNo logic upgrades
Cetus version linkCode must stay identical
Upgrades

Re-linking to a newer Cetus version keeps graduation working after Cetus upgrades.

External risk

After graduation, the liquidity lives in Cetus. If Cetus blocks a graduation, the final buy is cancelled and holders can still sell on the curve.

Contracts

Mainnet deployment · September 25, 2026

mainnet

1,000,000,000 tokens · 9 decimals · 4,200 SUI graduation target

Config enabled. Both caps are held by the designated single wallet. Contract source and authority are verified; the app is not open yet.

Launch config
0xd8df850b0eac5c57360c89e821f1f1a61c6a891b834a748186b51ca4045b7f71Suiscan ↗
Protocol fee recipient
0x4e7dfd9714a17cab5aaa7167f55770878f843c45b4f41dedd7341161f5ce9666Suiscan ↗

Launchpad

Curve, trading, graduation to Cetus, fees and the liquidity lock.
Network
mainnetversion 1
Upgrade policy
Dependency-only · policy 192
Cap owner
0x4e7dfd9714a17cab5aaa7167f55770878f843c45b4f41dedd7341161f5ce9666
Package
0x5730c8057176d795fea629033ec1ecedf801fcc6a39c540c9be63a8fd1f8d7cbSuiscan ↗
Upgrade cap
0x52eaa892514986a481a0fd6b88fdd888f896473d20384088e544e0a7068de4ccSuiscan ↗
Network
testnetversion 1
Package
0xcf6943616cc4193af295ebfdc4eeaae61b3c1a1b82766f24f6565cf3df111e1bSuiscan ↗
Upgrade cap
0x579e3a742235d3ab2c7d20e6d5e54c28033df94096a424080eba758b61f6a312Suiscan ↗
Contract code9 modules
cetus_graduation.move187 lines
/// The Cetus adapter. Three moments in a launch's life touch Cetus, and all three live here:
///
/// 1. `reserve_pair` at launch — mint a `PoolCreationCap` from the treasury before the
///    supply is fixed, and register the TOKEN/quote pair at the config's tick spacing. If
///    Cetus refuses either, the launch transaction aborts and nothing is ever funded.
/// 2. `graduate` inside the buy that meets the target — create the reserved pool at the
///    terminal curve price with the creation cap, deposit the reserves full range, and
///    freeze the position. If Cetus aborts, so does that buy.
/// 3. `collect_lp_fees` afterwards — pull accrued swap fees off the frozen position and pay
///    them 70/30 to the pool's creator recipient and its config's protocol recipient.
///
/// Callers never choose a price, range, destination, or recipient, and never receive
/// proceeds through graduation or collection. Reservation requires the sealed treasury;
/// graduation requires a target-reaching buy; fee collection is permissionless. The
/// pinned Cetus package creates singleton `GlobalConfig` and `Pools` objects and checks
/// its own version, pair reservation, price range and nonzero deposit.
module fish::cetus_graduation;

use sui::balance::{Self, Balance};
use sui::clock::{Self, Clock};
use sui::coin::{Self, TreasuryCap};
use fish::events;
use cetus_clmm::config::GlobalConfig;
use cetus_clmm::factory::{Self, Pools, PoolCreationCap};
use cetus_clmm::pool::Pool as CetusPool;
use cetus_clmm::pool_creator;
use cetus_clmm::clmm_math;
use cetus_clmm::tick_math;
use fish::config::Config;
use fish::pool::{Self, Pool};
use fish::math;
use fish::liquidity_lock::{Self, LockedLiquidity};

const EAmount: u64 = 1;
const ELock: u64 = 4;

// === Launch ===

/// Reserve the destination pool key for this launch. Cetus keys permission pairs on
/// `(A, B, tick_spacing)` and admits one creation cap per coin type, so a pair someone
/// else already reserved, a cap someone else already minted, or a quote/spacing Cetus does
/// not allow for permission pairs all abort here — at launch, before any quote is raised.
///
/// `register_permission_pair` takes the capped coin first and the quote second in either
/// sort order, and sorts the pool key itself.
public(package) fun reserve_pair<T, Q>(
    config: &GlobalConfig, pools: &mut Pools, tick_spacing: u32,
    treasury: &mut TreasuryCap<T>, ctx: &mut TxContext,
): PoolCreationCap {
    let cap = factory::mint_pool_creation_cap<T>(config, pools, treasury, ctx);
    factory::register_permission_pair<T, Q>(config, pools, tick_spacing, &cap, ctx);
    cap
}

// === Graduation ===

public(package) fun graduate<T, Q>(
    market: &mut Pool<T, Q>, config: &GlobalConfig, pools: &mut Pools,
    clock: &Clock, ctx: &mut TxContext,
) {
    let (tokens, quote, phantom, spacing) = pool::take_graduation_reserves(market);
    let cap = pool::take_creation_cap(market);
    let cap_id = object::id(&cap);
    let token_is_a = math::token_is_a<T, Q>();
    let sqrt_price = math::sqrt_price_x64(
        balance::value(&tokens), balance::value(&quote), phantom, token_is_a,
    );
    let (cetus_pool_id, position_id, lock_id, token_used, quote_used, token_surplus, quote_surplus) =
        if (token_is_a) {
            deploy<T, Q>(object::id(market), true, tokens, quote, false, sqrt_price, spacing, cap, config, pools, clock, ctx)
        } else {
            let (cp, pos, lock, qu, tu, qs, ts) = deploy<Q, T>(
                object::id(market), false, quote, tokens, true, sqrt_price, spacing, cap, config, pools, clock, ctx,
            );
            (cp, pos, lock, tu, qu, ts, qs)
        };
    pool::finish_graduation(market, cetus_pool_id, lock_id, clock, ctx);
    events::graduated(
        object::id(market),
        cetus_pool_id,
        position_id,
        lock_id,
        cap_id,
        token_is_a,
        sqrt_price,
        spacing,
        token_used,
        quote_used,
        token_surplus,
        quote_surplus,
        clock::timestamp_ms(clock),
    );
}

/// Type parameters are in Cetus's A/B order. Returns Cetus pool, position and lock IDs,
/// then the amounts deposited and left over, each in the same A/B order.
fun deploy<A, B>(
    source_pool: ID, token_is_a: bool, mut a: Balance<A>, mut b: Balance<B>, quote_is_a: bool,
    sqrt_price: u128, spacing: u32, cap: PoolCreationCap, config: &GlobalConfig,
    pools: &mut Pools, clock: &Clock, ctx: &mut TxContext,
): (ID, ID, ID, u64, u64, u64, u64) {
    let (lower, upper) = pool_creator::full_range_tick_range(spacing);
    // Also rejects a price outside Cetus's tick bounds before any reserve is committed.
    let current = tick_math::get_tick_at_sqrt_price(sqrt_price);
    let lower_tick = integer_mate::i32::from_u32(lower);
    let upper_tick = integer_mate::i32::from_u32(upper);
    let amount_a = balance::value(&a);
    let amount_b = balance::value(&b);
    let fixed_amount = if (quote_is_a) amount_a else amount_b;
    let (_, needed_a, needed_b) = clmm_math::get_liquidity_by_amount(
        lower_tick, upper_tick, current, sqrt_price, fixed_amount, quote_is_a,
    );
    // Fish splits the coins first, so it needs the amounts to fit.
    assert!(needed_a <= amount_a && needed_b <= amount_b, EAmount);
    // v3 requires the fixed-side coin to contain exactly the amount to deposit.
    let coin_a = coin::from_balance(balance::split(&mut a, needed_a), ctx);
    let coin_b = coin::from_balance(balance::split(&mut b, needed_b), ctx);
    let (position, returned_a, returned_b) = pool_creator::create_pool_v3_with_creation_cap<A, B>(
        config, pools, &cap, spacing, sqrt_price, b"".to_string(), lower, upper,
        coin_a, coin_b, quote_is_a, clock, ctx,
    );
    balance::join(&mut a, coin::into_balance(returned_a));
    balance::join(&mut b, coin::into_balance(returned_b));
    let surplus_a = balance::value(&a);
    let surplus_b = balance::value(&b);
    let used_a = amount_a - surplus_a;
    let used_b = amount_b - surplus_b;
    // All quote must reach liquidity (allow only tiny base-unit arithmetic dust).
    assert!(if (quote_is_a) surplus_a <= 1 else surplus_b <= 1, EAmount);
    // Whatever token the full-range shape could not absorb is frozen with the position.
    let (pool_id, position_id, lock_id) =
        liquidity_lock::freeze_forever(source_pool, token_is_a, position, cap, a, b, ctx);
    (pool_id, position_id, lock_id, used_a, used_b, surplus_a, surplus_b)
}

// === After graduation ===

/// Collect the position's accrued Cetus swap fees and pay them out 70/30 to the fish pool's
/// creator and protocol recipients. Permissionless: the caller picks nothing and gets
/// nothing. Principal, surplus and the position are untouched — the lock is an immutable
/// input here and Cetus's collector only borrows the position.
public(package) fun collect_lp_fees<T, Q, A, B>(
    market: &Pool<T, Q>, launch_config: &Config<Q>, lock: &LockedLiquidity<A, B>,
    config: &GlobalConfig, cetus: &mut CetusPool<A, B>, clock: &Clock, ctx: &TxContext,
) {
    assert!(liquidity_lock::source_pool_id(lock) == object::id(market), ELock);
    let (creator_recipient, protocol_recipient) =
        pool::lp_fee_routing(market, launch_config, object::id(lock));
    let (fee_a, fee_b) = liquidity_lock::collect_fees(lock, config, cetus);
    let total_a = balance::value(&fee_a);
    let total_b = balance::value(&fee_b);
    let (creator_a, protocol_a) = route(fee_a, creator_recipient, protocol_recipient);
    let (creator_b, protocol_b) = route(fee_b, creator_recipient, protocol_recipient);
    let token_is_a = liquidity_lock::token_is_a(lock);
    let (token_fee, quote_fee) = orient(token_is_a, total_a, total_b);
    let (creator_token, creator_quote) = orient(token_is_a, creator_a, creator_b);
    let (protocol_token, protocol_quote) = orient(token_is_a, protocol_a, protocol_b);
    events::lp_fees_collected(
        object::id(market),
        object::id(lock),
        liquidity_lock::cetus_pool_id(lock),
        liquidity_lock::position_id(lock),
        token_fee,
        quote_fee,
        creator_token,
        creator_quote,
        protocol_token,
        protocol_quote,
        creator_recipient,
        protocol_recipient,
        ctx.sender(),
        clock::timestamp_ms(clock),
    );
}

/// Splits one collected balance and settles both halves to address balances.
fun route<C>(mut fee: Balance<C>, creator: address, protocol: address): (u64, u64) {
    let (creator_share, protocol_share) = liquidity_lock::split_lp_fee(balance::value(&fee));
    balance::send_funds(balance::split(&mut fee, creator_share), creator);
    balance::send_funds(fee, protocol);
    (creator_share, protocol_share)
}

fun orient(token_is_a: bool, a: u64, b: u64): (u64, u64) {
    if (token_is_a) (a, b) else (b, a)
}
config.move107 lines
/// Launch terms authorized by the shared protocol administrator.
module fish::config;

use cetus_clmm::tick_math;
use integer_mate::i32;
use fish_admin::admin::AdminCap;
use fish::math;

const EConfig: u64 = 0;
const LIMIT: u64 = 1_000_000_000_000_000_000;

/// New economics require a new object. Disabling only prevents future launches; the
/// admin may change the enabled flag and protocol recipient. Recipient changes apply to
/// unclaimed protocol fees of every pool launched from this config.
public struct Config<phantom Q> has key {
    id: UID,
    enabled: bool,
    token_decimals: u8,
    supply: u64,
    phantom_quote: u64,
    graduation_quote: u64,
    base_fee_bps: u64,
    creator_share_bps: u64,
    creator_tax_cap_bps: u64,
    /// The opening buy surcharge in `math::buy_fee_bps`: 99% falling to nothing in 5 s.
    anti_snipe: bool,
    /// Cetus tick spacing of the destination pool. The fee for that tier is Cetus's to set;
    /// Fish does not pin it, because a tier Cetus re-prices must still graduate.
    tick_spacing: u32,
    /// Never 0x0. Read at payout time, so a rotation redirects existing pools too.
    protocol_recipient: address,
}

public(package) fun create<Q>(
    _: &AdminCap, token_decimals: u8, supply: u64, phantom_quote: u64, graduation_quote: u64,
    base_fee_bps: u64, creator_share_bps: u64, creator_tax_cap_bps: u64, anti_snipe: bool,
    tick_spacing: u32, protocol_recipient: address, ctx: &mut TxContext,
) {
    assert!(supply > 1 && supply <= LIMIT, EConfig);
    assert!(phantom_quote > 0 && graduation_quote > 0, EConfig);
    assert!(phantom_quote <= LIMIT && graduation_quote <= LIMIT, EConfig);
    assert!(base_fee_bps <= 1000 && creator_tax_cap_bps <= 1000, EConfig);
    assert!(creator_share_bps <= 10000, EConfig);
    assert!(token_decimals <= 18, EConfig);
    assert!(tick_spacing > 0 && tick_spacing <= tick_math::tick_bound(), EConfig);
    assert!(protocol_recipient != @0x0, EConfig);
    let reserve = math::mul_div_ceil(supply, phantom_quote, phantom_quote + graduation_quote);
    assert!(reserve > 0 && reserve < supply, EConfig);
    assert_graduation_price_in_range(supply, reserve, phantom_quote + graduation_quote, tick_spacing);
    let config = Config<Q> {
        id: object::new(ctx), enabled: true, token_decimals, supply, phantom_quote,
        graduation_quote, base_fee_bps, creator_share_bps, creator_tax_cap_bps, anti_snipe,
        tick_spacing, protocol_recipient,
    };
    fish::events::config_created<Q>(object::id(&config), ctx);
    transfer::share_object(config);
}

public(package) fun set_enabled<Q>(_: &AdminCap, config: &mut Config<Q>, enabled: bool) {
    config.enabled = enabled;
    fish::events::config_enabled(object::id(config), enabled);
}

public(package) fun set_protocol_recipient<Q>(
    _: &AdminCap, config: &mut Config<Q>, recipient: address,
) {
    assert!(recipient != @0x0, EConfig);
    config.protocol_recipient = recipient;
    fish::events::protocol_recipient_set(object::id(config), recipient);
}

/// Cetus opens the pool only at a price strictly inside the full-range position
/// (`pool_creator::create_pool_v3_with_creation_cap`). At graduation the quote is exactly
/// `terminal_quote`, and the tokens left lie between the reserved floor and the supply,
/// so checking those two ends in both type orders bounds the initial sqrt price.
/// This does not prove a nonzero final token fill or a nonzero Cetus liquidity deposit;
/// integer granularity and external Cetus policy can still prevent graduation.
fun assert_graduation_price_in_range(
    supply: u64, reserve: u64, terminal_quote: u64, tick_spacing: u32,
) {
    // Same bound as `pool_creator::full_range_tick_range`.
    let bound = tick_math::tick_bound() - tick_math::tick_bound() % tick_spacing;
    let lower = tick_math::get_sqrt_price_at_tick(i32::neg_from(bound));
    let upper = tick_math::get_sqrt_price_at_tick(i32::from(bound));
    // `sqrt_price_x64` takes the real quote and adds the phantom; the sum is what prices.
    let prices = vector[
        math::sqrt_price_x64(reserve, terminal_quote, 0, true),
        math::sqrt_price_x64(reserve, terminal_quote, 0, false),
        math::sqrt_price_x64(supply, terminal_quote, 0, true),
        math::sqrt_price_x64(supply, terminal_quote, 0, false),
    ];
    prices.do!(|price| assert!(lower < price && price < upper, EConfig));
}

/// Internal read boundary: Pool copies these immutable terms when a launch is created.
public(package) fun launch_terms<Q>(config: &Config<Q>): (
    ID, bool, u8, u64, u64, u64, u64, u64, u64, bool, u32,
) {
    (object::id(config), config.enabled, config.token_decimals, config.supply,
        config.phantom_quote, config.graduation_quote, config.base_fee_bps,
        config.creator_share_bps, config.creator_tax_cap_bps, config.anti_snipe,
        config.tick_spacing)
}

public(package) fun tick_spacing<Q>(config: &Config<Q>): u32 { config.tick_spacing }
public(package) fun protocol_recipient<Q>(config: &Config<Q>): address { config.protocol_recipient }
events.move233 lines
/// Canonical versioned launchpad events. Only package code can emit them.
///
/// `MarketEvent` is schema 2: schema 1 repeated every launch term on every mutation.
module fish::events;

use std::type_name::TypeName;
use sui::event;

public struct ConfigCreated has copy, drop {
    schema_version: u16,
    config_id: ID,
    quote_type: TypeName,
    actor: address,
}

public(package) fun config_created<Q>(config_id: ID, ctx: &TxContext) {
    event::emit(ConfigCreated { schema_version: 1, config_id,
        quote_type: std::type_name::with_original_ids<Q>(), actor: ctx.sender() });
}

public struct ConfigEnabled has copy, drop {
    schema_version: u16,
    config_id: ID,
    enabled: bool,
}

public(package) fun config_enabled(config_id: ID, enabled: bool) {
    event::emit(ConfigEnabled { schema_version: 1, config_id, enabled });
}

public struct ProtocolRecipientSet has copy, drop {
    schema_version: u16,
    config_id: ID,
    recipient: address,
}

public(package) fun protocol_recipient_set(config_id: ID, recipient: address) {
    event::emit(ProtocolRecipientSet { schema_version: 1, config_id, recipient });
}

public struct TokenCreated has copy, drop {
    schema_version: u16,
    token_type: TypeName,
    ticket_id: ID,
    treasury_cap_id: ID,
    decimals: u8,
    creator: address,
}

public(package) fun token_created<T>(ticket_id: ID, treasury_cap_id: ID, decimals: u8, ctx: &TxContext) {
    event::emit(TokenCreated { schema_version: 1,
        token_type: std::type_name::with_original_ids<T>(),
        ticket_id, treasury_cap_id, decimals, creator: ctx.sender() });
}

/// The terms a pool launched with. Emitted once. Only `creator_recipient` can change later
/// (in `MarketEvent`); the protocol recipient is the config's (`ProtocolRecipientSet`).
public struct PoolCreated has copy, drop {
    schema_version: u16,
    pool_id: ID,
    config_id: ID,
    token_type: TypeName,
    quote_type: TypeName,
    creator: address,
    creator_recipient: address,
    token_decimals: u8,
    total_supply: u64,
    phantom_quote: u64,
    graduation_quote: u64,
    reserved_tokens: u64,
    base_fee_bps: u64,
    creator_share_bps: u64,
    creator_tax_bps: u64,
    anti_snipe: bool,
    tick_spacing: u32,
    created_at_ms: u64,
    cetus_creation_cap_id: Option<ID>,
}

public(package) fun pool_created(
    pool_id: ID,
    config_id: ID,
    token_type: TypeName,
    quote_type: TypeName,
    creator: address,
    creator_recipient: address,
    token_decimals: u8,
    total_supply: u64,
    phantom_quote: u64,
    graduation_quote: u64,
    reserved_tokens: u64,
    base_fee_bps: u64,
    creator_share_bps: u64,
    creator_tax_bps: u64,
    anti_snipe: bool,
    tick_spacing: u32,
    created_at_ms: u64,
    cetus_creation_cap_id: Option<ID>,
) {
    event::emit(PoolCreated {
        schema_version: 1, pool_id, config_id, token_type, quote_type, creator,
        creator_recipient, token_decimals, total_supply, phantom_quote,
        graduation_quote, reserved_tokens, base_fee_bps, creator_share_bps, creator_tax_bps,
        anti_snipe, tick_spacing, created_at_ms, cetus_creation_cap_id,
    });
}

/// One per pool mutation, with every field a mutation can change. Launch terms are in
/// `PoolCreated`; Cetus IDs are in `Graduated`.
public struct MarketEvent has copy, drop {
    schema_version: u16,
    pool_id: ID,
    sequence: u64,
    // 0 create, 1 buy, 2 sell, 3 graduate, 4 creator claim, 5 protocol claim, 6 recipient.
    action: u8,
    amount_in: u64,
    amount_out: u64,
    fee: u64,
    // 0 curve, 1 graduated.
    state: u8,
    token_reserve: u64,
    quote_reserve: u64,
    creator_fees: u64,
    protocol_fees: u64,
    creator_recipient: address,
    actor: address,
    timestamp_ms: u64,
}

public(package) fun market(
    pool_id: ID,
    sequence: u64,
    action: u8,
    amount_in: u64,
    amount_out: u64,
    fee: u64,
    state: u8,
    token_reserve: u64,
    quote_reserve: u64,
    creator_fees: u64,
    protocol_fees: u64,
    creator_recipient: address,
    actor: address,
    timestamp_ms: u64,
) {
    event::emit(MarketEvent {
        schema_version: 2, pool_id, sequence, action, amount_in, amount_out, fee, state,
        token_reserve, quote_reserve, creator_fees, protocol_fees,
        creator_recipient, actor, timestamp_ms,
    });
}

public struct Graduated has copy, drop {
    schema_version: u16,
    pool_id: ID,
    cetus_pool_id: ID,
    position_id: ID,
    lock_id: ID,
    creation_cap_id: ID,
    token_is_a: bool,
    sqrt_price_x64: u128,
    tick_spacing: u32,
    token_deposited: u64,
    quote_deposited: u64,
    token_locked_surplus: u64,
    quote_locked_surplus: u64,
    timestamp_ms: u64,
}

public(package) fun graduated(
    pool_id: ID,
    cetus_pool_id: ID,
    position_id: ID,
    lock_id: ID,
    creation_cap_id: ID,
    token_is_a: bool,
    sqrt_price_x64: u128,
    tick_spacing: u32,
    token_deposited: u64,
    quote_deposited: u64,
    token_locked_surplus: u64,
    quote_locked_surplus: u64,
    timestamp_ms: u64,
) {
    event::emit(Graduated {
        schema_version: 1, pool_id, cetus_pool_id, position_id, lock_id,
        creation_cap_id, token_is_a, sqrt_price_x64, tick_spacing,
        token_deposited, quote_deposited, token_locked_surplus,
        quote_locked_surplus, timestamp_ms,
    });
}

public struct LpFeesCollected has copy, drop {
    schema_version: u16,
    pool_id: ID,
    lock_id: ID,
    cetus_pool_id: ID,
    position_id: ID,
    token_fee: u64,
    quote_fee: u64,
    creator_token: u64,
    creator_quote: u64,
    protocol_token: u64,
    protocol_quote: u64,
    creator_recipient: address,
    protocol_recipient: address,
    actor: address,
    timestamp_ms: u64,
}

public(package) fun lp_fees_collected(
    pool_id: ID,
    lock_id: ID,
    cetus_pool_id: ID,
    position_id: ID,
    token_fee: u64,
    quote_fee: u64,
    creator_token: u64,
    creator_quote: u64,
    protocol_token: u64,
    protocol_quote: u64,
    creator_recipient: address,
    protocol_recipient: address,
    actor: address,
    timestamp_ms: u64,
) {
    event::emit(LpFeesCollected {
        schema_version: 1, pool_id, lock_id, cetus_pool_id, position_id,
        token_fee, quote_fee, creator_token, creator_quote, protocol_token,
        protocol_quote, creator_recipient, protocol_recipient, actor, timestamp_ms,
    });
}
factory.move37 lines
/// Creates fresh, unregulated currencies and seals mint authority until launch.
module fish::factory;

use std::string::String;
use sui::coin::TreasuryCap;
use sui::coin_registry;
use fish::events;

/// Only this module can issue a ticket. The treasury never leaves package custody.
/// No store/copy/drop: callers cannot unwrap, discard or publicly transfer it.
public struct LaunchTicket<phantom T> has key {
    id: UID,
    treasury: TreasuryCap<T>,
}

/// Called by a token package's initializer through market::create_currency.
/// The initializer is finalized here, so the caller cannot add a DenyCap.
public(package) fun create<T: drop>(
    witness: T, decimals: u8, symbol: String, name: String,
    description: String, icon_url: String, ctx: &mut TxContext,
) {
    let (initializer, treasury) = coin_registry::new_currency_with_otw(
        witness, decimals, symbol, name, description, icon_url, ctx,
    );
    coin_registry::finalize_and_delete_metadata_cap(initializer, ctx);
    let ticket = LaunchTicket { id: object::new(ctx), treasury };
    events::token_created<T>(object::id(&ticket), object::id(&ticket.treasury), decimals, ctx);
    transfer::transfer(ticket, ctx.sender());
}

/// Called exactly once during launch. No public path releases the treasury.
public(package) fun consume<T>(ticket: LaunchTicket<T>): TreasuryCap<T> {
    let LaunchTicket { id, treasury } = ticket;
    id.delete();
    treasury
}
liquidity_lock.move80 lines
/// Frozen custody of the Cetus position, the retained `PoolCreationCap`, and all unspent
/// graduation reserves. No unwrap, transfer, mutable position borrow, liquidity removal,
/// or administrator override exists — not in this package, and not reachable by a later
/// upgrade of it, because the object is frozen at the Sui ownership layer.
///
/// Accrued swap fees remain collectable from Cetus. `collect_fees` borrows the
/// position immutably, which is all Cetus's `collect_fee` needs, and hands the balances
/// back to `cetus_graduation`, which routes them to the current stored recipients. The
/// position is never exposed outside the package: an exposed `&Position` would let anyone call
/// Cetus's collector and keep the proceeds.
module fish::liquidity_lock;

use sui::balance::Balance;
use cetus_clmm::config::GlobalConfig;
use cetus_clmm::factory::PoolCreationCap;
use cetus_clmm::pool::{Self as cetus_pool, Pool as CetusPool};
use cetus_clmm::position::{Self, Position};
use fish::math;

const ELiquidity: u64 = 0;
const EPool: u64 = 1;

/// Share of collected LP fees paid to the launch creator's recipient. The remainder goes to
/// the protocol recipient. A settled product decision, so a constant and not a policy field.
const CREATOR_LP_FEE_BPS: u64 = 7000;

public struct LockedLiquidity<phantom A, phantom B> has key {
    id: UID,
    source_pool_id: ID,
    cetus_pool_id: ID,
    /// Whether the fish token is Cetus's `A`. Lets fee events speak in token/quote terms.
    token_is_a: bool,
    position: Position,
    /// Not consumed by Cetus. No accessor exposes even `&PoolCreationCap`: Cetus can
    /// unregister a pair through an immutable borrow, so freezing alone is insufficient
    /// to revoke that authority. Fish's release upgrade policy must preserve this boundary.
    creation_cap: PoolCreationCap,
    surplus_a: Balance<A>,
    surplus_b: Balance<B>,
}

#[allow(lint(freeze_wrapped))]
public(package) fun freeze_forever<A, B>(
    source_pool_id: ID, token_is_a: bool, position: Position, creation_cap: PoolCreationCap,
    surplus_a: Balance<A>, surplus_b: Balance<B>, ctx: &mut TxContext,
): (ID, ID, ID) {
    let cetus_pool_id = position::pool_id(&position);
    let position_id = object::id(&position);
    assert!(position::liquidity(&position) > 0, ELiquidity);
    let lock = LockedLiquidity { id: object::new(ctx), source_pool_id, cetus_pool_id, token_is_a,
        position, creation_cap, surplus_a, surplus_b };
    let lock_id = object::id(&lock);
    // Immutable ownership enforces the lock at the Sui input layer, even across upgrades
    // of this package. Cetus itself remains an external, upgradeable dependency.
    transfer::freeze_object(lock);
    (cetus_pool_id, position_id, lock_id)
}

/// Accrued swap fees only. Cetus's collector takes `&Position`; liquidity is untouched.
public(package) fun collect_fees<A, B>(
    lock: &LockedLiquidity<A, B>, config: &GlobalConfig, pool: &mut CetusPool<A, B>,
): (Balance<A>, Balance<B>) {
    assert!(object::id(pool) == lock.cetus_pool_id, EPool);
    cetus_pool::collect_fee(config, pool, &lock.position, true)
}

public(package) fun source_pool_id<A, B>(lock: &LockedLiquidity<A, B>): ID { lock.source_pool_id }
public(package) fun cetus_pool_id<A, B>(lock: &LockedLiquidity<A, B>): ID { lock.cetus_pool_id }
public(package) fun token_is_a<A, B>(lock: &LockedLiquidity<A, B>): bool { lock.token_is_a }
public(package) fun position_id<A, B>(lock: &LockedLiquidity<A, B>): ID { object::id(&lock.position) }

/// 70/30 creator/protocol split of one collected fee amount. The creator share rounds
/// down; the protocol receives the remainder, so nothing is lost and the two always sum.
public fun split_lp_fee(amount: u64): (u64, u64) {
    let creator = math::mul_div(amount, CREATOR_LP_FEE_BPS, 10000);
    (creator, amount - creator)
}

public fun creator_lp_fee_bps(): u64 { CREATOR_LP_FEE_BPS }
market.move128 lines
/// Stable public transaction API. Implementation lives in config.move and pool.move.
module fish::market;

use sui::clock::Clock;
use std::string::String;
use sui::coin::Coin;
use sui::coin_registry::Currency;
use cetus_clmm::config::GlobalConfig;
use cetus_clmm::factory::Pools;
use fish::cetus_graduation;
use fish_admin::admin::AdminCap;
use fish::config::{Self, Config};
use fish::pool::{Self, Pool};
use fish::factory::{Self, LaunchTicket};
use fish::liquidity_lock::LockedLiquidity;
use cetus_clmm::pool::Pool as CetusPool;

/// A plain `buy` met the graduation target; use `buy_and_graduate`.
const EGraduates: u64 = 0;

public fun create_config<Q>(
    admin: &AdminCap, token_decimals: u8, supply: u64, phantom_quote: u64,
    graduation_quote: u64, base_fee_bps: u64, creator_share_bps: u64,
    creator_tax_cap_bps: u64, anti_snipe: bool, tick_spacing: u32,
    protocol_recipient: address, ctx: &mut TxContext,
) {
    config::create<Q>(admin, token_decimals, supply, phantom_quote, graduation_quote,
        base_fee_bps, creator_share_bps, creator_tax_cap_bps, anti_snipe, tick_spacing,
        protocol_recipient, ctx);
}

public fun set_config_enabled<Q>(admin: &AdminCap, launch_config: &mut Config<Q>, enabled: bool) {
    config::set_enabled(admin, launch_config, enabled);
}

/// Redirects protocol fees and LP-fee shares of every pool launched from this config,
/// including fees already accrued but not yet claimed. Rejects 0x0.
public fun set_protocol_recipient<Q>(
    admin: &AdminCap, launch_config: &mut Config<Q>, recipient: address,
) {
    config::set_protocol_recipient(admin, launch_config, recipient);
}

/// Creates the currency and sends its one-use launch ticket to the publisher.
public fun create_currency<T: drop>(
    witness: T, decimals: u8, symbol: String, name: String,
    description: String, icon_url: String, ctx: &mut TxContext,
) {
    factory::create(witness, decimals, symbol, name, description, icon_url, ctx);
}

/// Launch. Reserves the Cetus destination first — a `PoolCreationCap` minted from the
/// treasury and the TOKEN/quote pair registered at the config's tick spacing — then mints
/// the supply into the pool and records it as fixed in the `Currency`. If Cetus refuses the
/// reservation the whole launch aborts. A zero `creator_recipient` means the sender.
public fun create<T, Q>(
    launch_config: &Config<Q>, cetus_config: &GlobalConfig, cetus_pools: &mut Pools,
    ticket: LaunchTicket<T>, currency: &mut Currency<T>, creator_tax_bps: u64,
    creator_recipient: address, clock: &Clock, ctx: &mut TxContext,
): Pool<T, Q> {
    let mut treasury = factory::consume(ticket);
    let creation_cap = cetus_graduation::reserve_pair<T, Q>(cetus_config, cetus_pools,
        config::tick_spacing(launch_config), &mut treasury, ctx);
    pool::create(launch_config, creation_cap, treasury, currency, creator_tax_bps,
        creator_recipient, clock, ctx)
}

public fun share<T, Q>(market: Pool<T, Q>) { pool::share(market); }

/// A buy that stays below the graduation target. Touches only the fish pool, so buys on
/// different pools do not contend on a shared Fish or Cetus registry. Aborts if it would
/// meet the target; reusing the same payment or gas objects can still cause contention.
public fun buy<T, Q>(
    market: &mut Pool<T, Q>, payment: Coin<Q>, min_out: u64,
    deadline_ms: u64, clock: &Clock, ctx: &mut TxContext,
): (Coin<T>, Coin<Q>) {
    let (bought, refund, graduates) = pool::buy(market, payment, min_out, deadline_ms, clock, ctx);
    assert!(!graduates, EGraduates);
    (bought, refund)
}

/// A buy that may meet the target. If it does, the reserves move to Cetus in the same call;
/// if Cetus refuses, the whole buy aborts and the pool stays on the curve, still open to
/// sells and to smaller buys. There is no state in between. Below the target it is an
/// ordinary buy. If another transaction has already graduated the pool, the state check
/// aborts; failed transactions can still charge gas.
public fun buy_and_graduate<T, Q>(
    market: &mut Pool<T, Q>, cetus_config: &GlobalConfig, cetus_pools: &mut Pools,
    payment: Coin<Q>, min_out: u64, deadline_ms: u64, clock: &Clock, ctx: &mut TxContext,
): (Coin<T>, Coin<Q>) {
    let (bought, refund, graduates) = pool::buy(market, payment, min_out, deadline_ms, clock, ctx);
    if (graduates) cetus_graduation::graduate(market, cetus_config, cetus_pools, clock, ctx);
    (bought, refund)
}

public fun sell<T, Q>(
    market: &mut Pool<T, Q>, tokens: Coin<T>, min_out: u64,
    deadline_ms: u64, clock: &Clock, ctx: &mut TxContext,
): Coin<Q> {
    pool::sell(market, tokens, min_out, deadline_ms, clock, ctx)
}

public fun claim_creator<T, Q>(market: &mut Pool<T, Q>, clock: &Clock, ctx: &TxContext) {
    pool::claim_creator(market, clock, ctx);
}

public fun claim_protocol<T, Q>(
    market: &mut Pool<T, Q>, launch_config: &Config<Q>, clock: &Clock, ctx: &TxContext,
) {
    pool::claim_protocol(market, launch_config, clock, ctx);
}

/// Creator-only: pays accrued curve fees to the current recipient, then changes routing.
/// Uncollected Cetus LP fees follow the new recipient, including fees earned earlier.
/// A zero `recipient` means the creator.
public fun set_creator_recipient<T, Q>(
    market: &mut Pool<T, Q>, recipient: address, clock: &Clock, ctx: &TxContext,
) {
    pool::set_creator_recipient(market, recipient, clock, ctx);
}

public fun collect_lp_fees<T, Q, A, B>(
    market: &Pool<T, Q>, launch_config: &Config<Q>, lock: &LockedLiquidity<A, B>,
    config: &GlobalConfig, cetus: &mut CetusPool<A, B>, clock: &Clock, ctx: &TxContext,
) {
    cetus_graduation::collect_lp_fees(market, launch_config, lock, config, cetus, clock, ctx);
}
math.move152 lines
/// Deterministic bonding-curve fees and graduation pricing.
module fish::math;

use std::type_name;

const EPrice: u64 = 0;

public(package) fun mul_div(a: u64, b: u64, d: u64): u64 {
    (((a as u128) * (b as u128)) / (d as u128)) as u64
}

public(package) fun mul_div_ceil(a: u64, b: u64, d: u64): u64 {
    ((((a as u128) * (b as u128)) + (d as u128) - 1) / (d as u128)) as u64
}

// Total opening buy fee at 100 ms intervals. Samples follow
// 9900 * 2^(-t(t+3)/2), t in seconds: 99%, 24.75%, 3.09% at 0, 1, 2 s.
// Interpolate between samples so the on-chain curve is smooth at millisecond precision.
const OPENING_BUY_BPS: vector<u64> = vector[
    9900, 8892, 7931, 7025, 6179, 5398, 4683, 4035, 3452, 2933,
    2475, 2074, 1726, 1426, 1171, 954, 772, 621, 496, 393,
    309, 242, 188, 145, 111, 84, 64, 48, 36, 26,
    19, 14, 10, 7, 5, 4, 3, 2, 1, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0,
];

/// Length of the opening window the schedule above covers.
const ANTI_SNIPE_MS: u64 = 5_000;

public(package) fun buy_fee_bps(
    base_fee_bps: u64, creator_tax_bps: u64, anti_snipe: bool, created_at_ms: u64,
    is_exempt: bool, timestamp_ms: u64,
): u64 {
    let normal = base_fee_bps + creator_tax_bps;
    if (!anti_snipe || is_exempt) return normal;
    let elapsed = timestamp_ms - created_at_ms;
    if (elapsed >= ANTI_SNIPE_MS) return normal;
    let step = elapsed / 100;
    let remainder = elapsed % 100;
    let schedule = OPENING_BUY_BPS;
    let start = schedule[step];
    let end = schedule[step + 1];
    let opening = start - mul_div(start - end, remainder, 100);
    if (opening > normal) opening else normal
}

/// Cetus's `factory::is_right_order`: descending lexicographic order of fully qualified type
/// names with defining IDs, which is what Cetus keys pools and permission pairs on. Original
/// IDs would differ for a type added by a package upgrade and send graduation the wrong order.
public fun token_is_a<T, Q>(): bool {
    let token = type_name::with_defining_ids<T>().into_string().into_bytes();
    let quote = type_name::with_defining_ids<Q>().into_string().into_bytes();
    let mut i = 0;
    while (i < token.length() && i < quote.length()) {
        if (token[i] != quote[i]) return token[i] > quote[i];
        i = i + 1;
    };
    token.length() > quote.length()
}

/// Terminal virtual quote divided by actual remaining tokens. The raw base-unit ratio is
/// intentional; decimals are only applied when displaying the price.
public fun sqrt_price_x64(tokens: u64, real_quote: u64, phantom_quote: u64, token_is_a: bool): u128 {
    assert!(tokens > 0 && real_quote > 0, EPrice);
    let terminal_quote = (real_quote as u256) + (phantom_quote as u256);
    let (numerator, denominator) = if (token_is_a) (terminal_quote, tokens as u256)
        else (tokens as u256, terminal_quote);
    sqrt((numerator << 128) / denominator) as u128
}

/// Bit-length seeded integer square root from zzyzx_math::sqrt::sqrt_u256.
/// Pin: ZzyzxLabs/ZZYZX-Contract@69a979a772bda3592ecce4f233347cad30612dfb.
/// This keeps the exact floor result without requiring a separately published math package.
fun sqrt(value: u256): u256 {
    if (value == 0) return 0;
    if (value < 4) return 1;

    let mut shift: u8 = 0;
    let mut remaining = value;
    if (remaining >= (1u256 << 128)) { remaining = remaining >> 128; shift = shift + 128; };
    if (remaining >= (1u256 << 64)) { remaining = remaining >> 64; shift = shift + 64; };
    if (remaining >= (1u256 << 32)) { remaining = remaining >> 32; shift = shift + 32; };
    if (remaining >= (1u256 << 16)) { remaining = remaining >> 16; shift = shift + 16; };
    if (remaining >= (1u256 << 8)) { remaining = remaining >> 8; shift = shift + 8; };
    if (remaining >= (1u256 << 4)) { remaining = remaining >> 4; shift = shift + 4; };
    if (remaining >= (1u256 << 2)) { shift = shift + 2; };

    let mut guess = 1u256 << ((shift >> 1) + 1);
    let mut iterations: u8 = 0;
    while (iterations < 128) {
        let next = (guess + value / guess) >> 1;
        if (next >= guess) break;
        guess = next;
        iterations = iterations + 1;
    };

    if (guess > value / guess) guess - 1 else guess
}

// === Tests ===

#[test]
fun sqrt_floors_at_bit_boundaries() {
    let values = vector[
        0u256, 1, 2, 3, 4, 15, 16, 17,
        (1u256 << 128) - 1, 1u256 << 128, (1u256 << 128) + 1,
        (1u256 << 192) - 1, std::u256::max_value!(),
    ];
    let mut i = 0;
    while (i < values.length()) {
        let value = values[i];
        let root = sqrt(value);
        if (value == 0) {
            assert!(root == 0, i);
        } else {
            assert!(root <= value / root && root + 1 > value / (root + 1), i);
        };
        i = i + 1;
    };
}

#[test]
fun price_rounding_and_orientation() {
    let tokens = 286_926_994_906_621_393;
    let quote = 4_200_000_000_000;
    let phantom = 1_690_000_000_000;
    let s = sqrt_price_x64(tokens, quote, phantom, true) as u256;
    let ratio = (((quote + phantom) as u256) << 128) / (tokens as u256);
    assert!(s * s <= ratio && (s + 1) * (s + 1) > ratio, 0);
    let inverse = sqrt_price_x64(tokens, quote, phantom, false) as u256;
    let ratio = ((tokens as u256) << 128) / ((quote + phantom) as u256);
    assert!(inverse * inverse <= ratio && (inverse + 1) * (inverse + 1) > ratio, 1);
    assert!(sqrt_price_x64(100, 80, 20, true) == 18446744073709551616, 2);
    // Exercise the pinned Cetus math helpers in both type orders.
    // Full-range ticks aligned to the illustrative 200 spacing, both type orders.
    let lower = integer_mate::i32::neg_from(443600);
    let upper = integer_mate::i32::from(443600);
    let price_a = sqrt_price_x64(tokens, quote, phantom, true);
    let (liq_a, tokens_a, quote_b) = cetus_clmm::clmm_math::get_liquidity_by_amount(
        lower, upper, cetus_clmm::tick_math::get_tick_at_sqrt_price(price_a), price_a, quote, false,
    );
    let price_b = sqrt_price_x64(tokens, quote, phantom, false);
    let (liq_b, quote_a, tokens_b) = cetus_clmm::clmm_math::get_liquidity_by_amount(
        lower, upper, cetus_clmm::tick_math::get_tick_at_sqrt_price(price_b), price_b, quote, true,
    );
    assert!(liq_a > 0 && liq_b > 0 && quote_a == quote && quote_b == quote, 3);
    assert!(tokens_a > 204_000_000_000_000_000 && tokens_a < 205_000_000_000_000_000, 4);
    assert!(tokens_b > 204_000_000_000_000_000 && tokens_b < 205_000_000_000_000_000, 5);
    assert!(tokens_a < tokens && tokens_b < tokens, 6);
}
pool.move443 lines
/// Pool state, trading, graduation, fee custody, and mutation events.
module fish::pool;

use std::type_name;
use sui::balance::{Self, Balance};
use sui::clock::{Self, Clock};
use sui::coin::{Self, Coin, TreasuryCap};
use sui::coin_registry::{Self, Currency};
use fish::events;
use cetus_clmm::factory::PoolCreationCap;
use fish::config::{Self, Config};
use fish::math;

const EConfig: u64 = 0;
const EDisabled: u64 = 1;
const ESupply: u64 = 2;
const ESlippage: u64 = 3;
const EAmount: u64 = 4;
const EDeadline: u64 = 5;
const EAuthority: u64 = 6;
const EState: u64 = 7;

public struct Pool<phantom T, phantom Q> has key {
    id: UID,
    config_id: ID,
    creator: address,
    // Never 0x0: an unset or zero recipient is replaced by the creator.
    creator_recipient: address,
    token_decimals: u8,
    total_supply: u64,
    tokens: Balance<T>,
    quote: Balance<Q>,
    creator_fees: Balance<Q>,
    protocol_fees: Balance<Q>,
    phantom_quote: u64,
    graduation_quote: u64,
    reserved_tokens: u64,
    base_fee_bps: u64,
    creator_share_bps: u64,
    creator_tax_bps: u64,
    anti_snipe: bool,
    tick_spacing: u32,
    created_at_ms: u64,
    // 0: curve, 1: graduated. The buy that meets the target graduates in the same call, so
    // no state exists where trading is closed and the reserves are still here.
    state: u8,
    sequence: u64,
    cetus_pool_id: Option<ID>,
    position_lock_id: Option<ID>,
    // The Cetus pair reservation. Held from launch until graduation moves it into the
    // frozen lock, so it is `some` in state 0 and `none` in state 1.
    cetus_creation_cap: Option<PoolCreationCap>,
}

/// Returns an unshared pool so create -> buy -> share is one programmable transaction.
/// The zero-supply TreasuryCap is consumed into the registry as a fixed supply, so no one
/// can mint after launch and wallets read the supply as fixed. The `PoolCreationCap` must
/// already have been minted from that same treasury and the pair registered —
/// `cetus_graduation::reserve_pair` does both — so a launch cannot exist without its Cetus
/// destination reserved.
public(package) fun create<T, Q>(
    config: &Config<Q>, creation_cap: PoolCreationCap, treasury: TreasuryCap<T>,
    currency: &mut Currency<T>, creator_tax_bps: u64, creator_recipient: address,
    clock: &Clock, ctx: &mut TxContext,
): Pool<T, Q> {
    create_impl(config, option::some(creation_cap), treasury, currency, creator_tax_bps,
        creator_recipient, clock, ctx)
}

fun create_impl<T, Q>(
    config: &Config<Q>, creation_cap: Option<PoolCreationCap>, mut treasury: TreasuryCap<T>,
    currency: &mut Currency<T>, creator_tax_bps: u64, creator_recipient: address,
    clock: &Clock, ctx: &mut TxContext,
): Pool<T, Q> {
    let (config_id, enabled, token_decimals, supply, phantom_quote, graduation_quote,
        base_fee_bps, creator_share_bps, creator_tax_cap_bps, anti_snipe, tick_spacing,
    ) = config::launch_terms(config);
    assert!(enabled, EDisabled);
    assert!(type_name::with_original_ids<T>() != type_name::with_original_ids<Q>(), EConfig);
    // Cetus has had `&mut TreasuryCap` to mint the creation cap; it must not have minted coins.
    assert!(coin::total_supply(&treasury) == 0, ESupply);
    assert!(coin_registry::decimals(currency) == token_decimals, EConfig);
    assert!(creator_tax_bps <= creator_tax_cap_bps, EConfig);
    let tokens = coin::mint_balance(&mut treasury, supply);
    coin_registry::make_supply_fixed(currency, treasury);
    let mut pool = Pool {
        id: object::new(ctx), config_id,
        creator: ctx.sender(), creator_recipient: or_creator(creator_recipient, ctx.sender()),
        token_decimals, total_supply: supply,
        tokens, quote: balance::zero(), creator_fees: balance::zero(), protocol_fees: balance::zero(),
        phantom_quote, graduation_quote,
        reserved_tokens: math::mul_div_ceil(supply, phantom_quote, phantom_quote + graduation_quote),
        base_fee_bps, creator_share_bps, creator_tax_bps, anti_snipe, tick_spacing,
        created_at_ms: clock::timestamp_ms(clock), state: 0, sequence: 0,
        cetus_pool_id: option::none(), position_lock_id: option::none(),
        cetus_creation_cap: creation_cap,
    };
    events::pool_created(
        object::id(&pool),
        pool.config_id,
        type_name::with_original_ids<T>(),
        type_name::with_original_ids<Q>(),
        pool.creator,
        pool.creator_recipient,
        pool.token_decimals,
        pool.total_supply,
        pool.phantom_quote,
        pool.graduation_quote,
        pool.reserved_tokens,
        pool.base_fee_bps,
        pool.creator_share_bps,
        pool.creator_tax_bps,
        pool.anti_snipe,
        pool.tick_spacing,
        pool.created_at_ms,
        creation_cap_id(&pool.cetus_creation_cap),
    );
    emit(&mut pool, 0, 0, 0, 0, clock, ctx);
    pool
}

public(package) fun share<T, Q>(pool: Pool<T, Q>) { transfer::share_object(pool); }

/// Exact-input buy, with partial final fill and quote refund returned to the caller.
/// Also returns whether the buy met the target; the caller must then graduate in the same
/// call (`market::buy_and_graduate`) or abort (`market::buy`).
public(package) fun buy<T, Q>(
    pool: &mut Pool<T, Q>, mut payment: Coin<Q>, min_out: u64,
    deadline_ms: u64, clock: &Clock, ctx: &mut TxContext,
): (Coin<T>, Coin<Q>, bool) {
    assert!(clock::timestamp_ms(clock) <= deadline_ms, EDeadline);
    let (used, _, out, fee, graduates) = quote_buy(pool, coin::value(&payment), ctx.sender(), clock);
    assert!(out >= min_out, ESlippage);
    let mut incoming = coin::into_balance(coin::split(&mut payment, used, ctx));
    accrue(pool, &mut incoming, used, fee);
    balance::join(&mut pool.quote, incoming);
    let bought = coin::from_balance(balance::split(&mut pool.tokens, out), ctx);
    emit(pool, 1, used, out, fee, clock, ctx);
    (bought, payment, graduates)
}

public(package) fun sell<T, Q>(
    pool: &mut Pool<T, Q>, tokens: Coin<T>, min_out: u64,
    deadline_ms: u64, clock: &Clock, ctx: &mut TxContext,
): Coin<Q> {
    assert!(clock::timestamp_ms(clock) <= deadline_ms, EDeadline);
    let amount = coin::value(&tokens);
    let (gross, net) = quote_sell(pool, amount);
    assert!(net >= min_out, ESlippage);
    balance::join(&mut pool.tokens, coin::into_balance(tokens));
    let mut outgoing = balance::split(&mut pool.quote, gross);
    accrue(pool, &mut outgoing, gross, gross - net);
    emit(pool, 2, amount, net, gross - net, clock, ctx);
    coin::from_balance(outgoing, ctx)
}

/// Anyone may trigger payout, but cannot choose its destination.
public(package) fun claim_creator<T, Q>(pool: &mut Pool<T, Q>, clock: &Clock, ctx: &TxContext) {
    let fees = balance::withdraw_all(&mut pool.creator_fees);
    let amount = balance::value(&fees);
    balance::send_funds(fees, pool.creator_recipient);
    emit(pool, 4, 0, amount, 0, clock, ctx);
}

/// Pays whoever the pool's config names now; the admin may rotate it.
public(package) fun claim_protocol<T, Q>(
    pool: &mut Pool<T, Q>, config: &Config<Q>, clock: &Clock, ctx: &TxContext,
) {
    assert!(object::id(config) == pool.config_id, EConfig);
    let fees = balance::withdraw_all(&mut pool.protocol_fees);
    let amount = balance::value(&fees);
    balance::send_funds(fees, config::protocol_recipient(config));
    emit(pool, 5, 0, amount, 0, clock, ctx);
}

/// Pays accrued curve fees to the old recipient before rotation. Uncollected LP fees stay
/// in Cetus and follow the recipient stored at collection time. Zero means the creator.
public(package) fun set_creator_recipient<T, Q>(
    pool: &mut Pool<T, Q>, recipient: address, clock: &Clock, ctx: &TxContext,
) {
    assert!(ctx.sender() == pool.creator, EAuthority);
    claim_creator(pool, clock, ctx);
    pool.creator_recipient = or_creator(recipient, pool.creator);
    emit(pool, 6, 0, 0, 0, clock, ctx);
}

fun accrue<T, Q>(pool: &mut Pool<T, Q>, funds: &mut Balance<Q>, gross: u64, fee: u64) {
    let creator = math::mul_div(gross, pool.creator_tax_bps, 10000)
        + math::mul_div(math::mul_div(gross, pool.base_fee_bps, 10000), pool.creator_share_bps, 10000);
    balance::join(&mut pool.creator_fees, balance::split(funds, creator));
    balance::join(&mut pool.protocol_fees, balance::split(funds, fee - creator));
}

fun or_creator(recipient: address, creator: address): address {
    if (recipient == @0x0) creator else recipient
}

fun creation_cap_id(cap: &Option<PoolCreationCap>): Option<ID> {
    if (cap.is_some()) option::some(object::id(cap.borrow())) else option::none()
}

fun virtual_quote<T, Q>(pool: &Pool<T, Q>): u64 {
    balance::value(&pool.quote) + pool.phantom_quote
}

fun emit<T, Q>(
    pool: &mut Pool<T, Q>, action: u8, amount_in: u64, amount_out: u64, fee: u64,
    clock: &Clock, ctx: &TxContext,
) {
    pool.sequence = pool.sequence + 1;
    events::market(
        object::id(pool),
        pool.sequence,
        action,
        amount_in,
        amount_out,
        fee,
        pool.state,
        balance::value(&pool.tokens),
        balance::value(&pool.quote),
        balance::value(&pool.creator_fees),
        balance::value(&pool.protocol_fees),
        pool.creator_recipient,
        ctx.sender(),
        clock::timestamp_ms(clock),
    );
}

/// Calculation shared by execution and view; no reserve mutation.
///
/// No amount ceiling is needed: every product is taken in u128 and every quotient is bounded
/// by a u64 reserve, and a final buy is clipped to the remaining target.
public(package) fun quote_buy<T, Q>(
    pool: &Pool<T, Q>, offered: u64, sender: address, clock: &Clock,
): (u64, u64, u64, u64, bool) {
    assert!(pool.state == 0, EState);
    // Only the creator is exempt from the opening surcharge, so a dev buy composed into the
    // launch transaction pays normal fees.
    let exempt = sender == pool.creator;
    let rate = math::buy_fee_bps(pool.base_fee_bps, pool.creator_tax_bps, pool.anti_snipe,
        pool.created_at_ms, exempt, clock::timestamp_ms(clock));
    let mut used = offered;
    let mut net = math::mul_div(offered, 10000 - rate, 10000);
    let remaining = pool.graduation_quote - balance::value(&pool.quote);
    if (net > remaining) {
        net = remaining;
        used = math::mul_div_ceil(net, 10000, 10000 - rate);
    };
    assert!(net > 0, EAmount);
    let q = virtual_quote(pool);
    let out = math::mul_div(net, balance::value(&pool.tokens), q + net);
    assert!(out > 0, ESlippage);
    assert!(balance::value(&pool.tokens) - out >= pool.reserved_tokens, ESupply);
    (used, net, out, used - net, balance::value(&pool.quote) + net == pool.graduation_quote)
}

/// The gross never exceeds the real quote reserve: tokens × virtual quote never falls below
/// its launch value, and at most the whole supply can be sold back.
public(package) fun quote_sell<T, Q>(pool: &Pool<T, Q>, amount: u64): (u64, u64) {
    assert!(pool.state == 0, EState);
    assert!(amount > 0, EAmount);
    let gross = math::mul_div(amount, virtual_quote(pool), balance::value(&pool.tokens) + amount);
    let net = math::mul_div(gross, 10000 - pool.base_fee_bps - pool.creator_tax_bps, 10000);
    assert!(net > 0, ESlippage);
    (gross, net)
}

/// Only the package's Cetus adapter can take reserves, and only from the buy that met the
/// target; no public withdrawal path. Returns the reserves, the phantom quote that prices
/// them, and the tick spacing.
public(package) fun take_graduation_reserves<T, Q>(
    pool: &mut Pool<T, Q>,
): (Balance<T>, Balance<Q>, u64, u32) {
    assert!(pool.state == 0 && balance::value(&pool.quote) == pool.graduation_quote, EState);
    (balance::withdraw_all(&mut pool.tokens), balance::withdraw_all(&mut pool.quote),
        pool.phantom_quote, pool.tick_spacing)
}

/// The reservation leaves with the reserves; graduation freezes it inside the lock.
/// Called only by `cetus_graduation::graduate`, right after `take_graduation_reserves`.
public(package) fun take_creation_cap<T, Q>(pool: &mut Pool<T, Q>): PoolCreationCap {
    option::extract(&mut pool.cetus_creation_cap)
}

public(package) fun finish_graduation<T, Q>(
    pool: &mut Pool<T, Q>, cetus_pool_id: ID, position_lock_id: ID, clock: &Clock, ctx: &TxContext,
) {
    pool.state = 1;
    pool.cetus_pool_id = option::some(cetus_pool_id);
    pool.position_lock_id = option::some(position_lock_id);
    emit(pool, 3, 0, 0, 0, clock, ctx);
}

/// Where post-graduation LP fees go. Only a graduated pool routes, and only for the lock it
/// recorded, so a lock cannot be paired with a pool that did not fund it.
public(package) fun lp_fee_routing<T, Q>(
    pool: &Pool<T, Q>, config: &Config<Q>, lock_id: ID,
): (address, address) {
    assert!(pool.state == 1, EState);
    assert!(pool.position_lock_id == option::some(lock_id), EConfig);
    assert!(object::id(config) == pool.config_id, EConfig);
    (pool.creator_recipient, config::protocol_recipient(config))
}

public struct Snapshot has copy, drop {
    pool_id: ID,
    config_id: ID,
    state: u8,
    sequence: u64,
    creator: address,
    creator_recipient: address,
    token_reserve: u64,
    quote_reserve: u64,
    creator_fees: u64,
    protocol_fees: u64,
    phantom_quote: u64,
    graduation_quote: u64,
    reserved_tokens: u64,
    tick_spacing: u32,
    cetus_pool_id: Option<ID>,
    position_lock_id: Option<ID>,
}

public(package) fun snapshot<T, Q>(pool: &Pool<T, Q>): Snapshot {
    Snapshot { pool_id: object::id(pool), config_id: pool.config_id,
        state: pool.state, sequence: pool.sequence,
        creator: pool.creator, creator_recipient: pool.creator_recipient,
        token_reserve: balance::value(&pool.tokens), quote_reserve: balance::value(&pool.quote),
        creator_fees: balance::value(&pool.creator_fees), protocol_fees: balance::value(&pool.protocol_fees),
        phantom_quote: pool.phantom_quote, graduation_quote: pool.graduation_quote,
        reserved_tokens: pool.reserved_tokens,
        tick_spacing: pool.tick_spacing, cetus_pool_id: pool.cetus_pool_id,
        position_lock_id: pool.position_lock_id }
}

// === Test-only helpers and tests ===

/// Curve-only fixtures skip Cetus initialization and launch without a cap. The separate
/// `cetus_integration_tests` exercise the real reservation and graduation calls. This
/// helper is never published; production pools hold a cap until they graduate.
#[test_only]
public(package) fun create_for_testing<T, Q>(
    config: &Config<Q>, treasury: TreasuryCap<T>, currency: &mut Currency<T>,
    creator_tax_bps: u64, creator_recipient: address, clock: &Clock, ctx: &mut TxContext,
): Pool<T, Q> {
    create_impl(config, option::none(), treasury, currency, creator_tax_bps, creator_recipient,
        clock, ctx)
}

/// Launch identity fields checked together by the creation suite.
#[test_only]
public(package) fun launch_identity_for_testing<T, Q>(pool: &Pool<T, Q>): (ID, u8, u32, bool) {
    (pool.config_id, pool.token_decimals, pool.tick_spacing, pool.cetus_creation_cap.is_some())
}

/// Immutable launch economics copied into the pool. Production readers use BCS.
#[test_only]
public(package) fun launch_economics_for_testing<T, Q>(pool: &Pool<T, Q>): (
    u64, u64, u64, u64, u64, u64, bool,
) {
    (pool.total_supply, pool.phantom_quote, pool.graduation_quote, pool.reserved_tokens,
        pool.base_fee_bps, pool.creator_share_bps, pool.anti_snipe)
}

/// Move keeps fields private to this module; trading and lifecycle suites need these focused readers.
#[test_only]
public(package) fun creator<T, Q>(pool: &Pool<T, Q>): address { pool.creator }
#[test_only]
public(package) fun creator_recipient<T, Q>(pool: &Pool<T, Q>): address { pool.creator_recipient }
#[test_only]
public(package) fun token_reserve<T, Q>(pool: &Pool<T, Q>): u64 { balance::value(&pool.tokens) }
#[test_only]
public(package) fun quote_reserve<T, Q>(pool: &Pool<T, Q>): u64 { balance::value(&pool.quote) }
#[test_only]
public(package) fun creator_fees<T, Q>(pool: &Pool<T, Q>): u64 { balance::value(&pool.creator_fees) }
#[test_only]
public(package) fun protocol_fees<T, Q>(pool: &Pool<T, Q>): u64 { balance::value(&pool.protocol_fees) }
#[test_only]
public(package) fun creator_tax_bps<T, Q>(pool: &Pool<T, Q>): u64 { pool.creator_tax_bps }
#[test_only]
public(package) fun created_at_ms<T, Q>(pool: &Pool<T, Q>): u64 { pool.created_at_ms }
#[test_only]
public(package) fun state<T, Q>(pool: &Pool<T, Q>): u8 { pool.state }
#[test_only]
public(package) fun sequence<T, Q>(pool: &Pool<T, Q>): u64 { pool.sequence }
#[test_only]
public(package) fun cetus_pool_id<T, Q>(pool: &Pool<T, Q>): Option<ID> { pool.cetus_pool_id }
#[test_only]
public(package) fun position_lock_id<T, Q>(pool: &Pool<T, Q>): Option<ID> { pool.position_lock_id }

#[test_only]
public struct SMOKE_QUOTE has drop {}

/// Curve lifecycle smoke test; focused tests live in `tests/`. Cetus is bypassed here.
#[test]
fun lifecycle_smoke() {
    use sui::test_scenario;
    use fish::test_token::{Self, TEST_TOKEN};
    let sender = @0xa;
    let mut scenario = test_scenario::begin(sender);
    let admin = fish_admin::admin::for_testing(scenario.ctx());
    // Testnet MVP terms: 1,690 virtual SUI and a 4,200 SUI graduation target.
    config::create<SMOKE_QUOTE>(&admin, 9, 1_000_000_000_000_000_000, 1_690_000_000_000,
        4_200_000_000_000, 100, 2000, 500, false, 200, @0xb, scenario.ctx());
    transfer::public_transfer(admin, sender);
    scenario.next_tx(sender);
    let config = scenario.take_shared<Config<SMOKE_QUOTE>>();
    let clock = clock::create_for_testing(scenario.ctx());
    let mut currency = test_token::currency(9, scenario.ctx());
    let treasury = coin::create_treasury_cap_for_testing<TEST_TOKEN>(scenario.ctx());
    let mut pool = create_impl(&config, option::none(), treasury, &mut currency, 0, sender,
        &clock, scenario.ctx());
    assert!(coin_registry::total_supply(&currency) == option::some(pool.total_supply), 109);
    assert!(pool.phantom_quote == 1_690_000_000_000, 106);
    assert!(pool.graduation_quote == 4_200_000_000_000, 107);
    assert!(pool.reserved_tokens == 286_926_994_906_621_393, 108);
    let payment = coin::mint_for_testing<SMOKE_QUOTE>(1_000_000_000, scenario.ctx());
    let (mut bought, refund, _) = buy(&mut pool, payment, 1, 1000, &clock, scenario.ctx());
    coin::destroy_zero(refund);
    let sell_amount = coin::value(&bought) / 2;
    let to_sell = coin::split(&mut bought, sell_amount, scenario.ctx());
    let proceeds = sell(&mut pool, to_sell, 1, 1000, &clock, scenario.ctx());
    assert!(coin::value(&proceeds) > 0, 100);
    transfer::public_transfer(proceeds, sender);
    let payment = coin::mint_for_testing<SMOKE_QUOTE>(10_000_000_000_000, scenario.ctx());
    let (last, refund, graduates) = buy(&mut pool, payment, 1, 1000, &clock, scenario.ctx());
    assert!(coin::value(&refund) > 0 && graduates, 101);
    assert!(balance::value(&pool.quote) == pool.graduation_quote, 102);
    assert!(balance::value(&pool.tokens) >= pool.reserved_tokens, 103);
    coin::join(&mut bought, last);
    assert!(balance::value(&pool.tokens) + coin::value(&bought) == pool.total_supply, 104);
    claim_creator(&mut pool, &clock, scenario.ctx());
    claim_protocol(&mut pool, &config, &clock, scenario.ctx());
    assert!(balance::value(&pool.creator_fees) == 0 && balance::value(&pool.protocol_fees) == 0, 105);
    transfer::public_transfer(bought, sender);
    transfer::public_transfer(refund, sender);
    share(pool);
    clock::destroy_for_testing(clock);
    std::unit_test::destroy(currency);
    test_scenario::return_shared(config);
    scenario.end();
}
view.move37 lines
/// Read-only devInspect/DryRun surface. Quotes use the execution calculations.
module fish::view;

use sui::clock::Clock;
use fish::config::Config;
use fish::pool::{Self, Pool, Snapshot};

public struct BuyQuote has copy, drop {
    amount_in: u64,
    amount_out: u64,
    refund: u64,
    fee: u64,
    /// The buy meets the target: submit it through `market::buy_and_graduate`.
    graduates: bool,
}

public struct SellQuote has copy, drop {
    amount_in: u64,
    amount_out: u64,
    fee: u64,
}

/// Returns bytes with the exact Config<Q> object layout, including its ID.
public fun config<Q>(config: &Config<Q>): vector<u8> { sui::bcs::to_bytes(config) }

public fun pool<T, Q>(pool: &Pool<T, Q>): Snapshot { pool::snapshot(pool) }

public fun quote_buy<T, Q>(pool: &Pool<T, Q>, offered: u64, sender: address, clock: &Clock): BuyQuote {
    let (used, _, out, fee, graduates) = pool::quote_buy(pool, offered, sender, clock);
    BuyQuote { amount_in: used, amount_out: out, refund: offered - used, fee, graduates }
}

public fun quote_sell<T, Q>(pool: &Pool<T, Q>, amount: u64): SellQuote {
    let (gross, net) = pool::quote_sell(pool, amount);
    SellQuote { amount_in: amount, amount_out: net, fee: gross - net }
}
View on GitHub ↗

Admin

Config permissions and protocol fee recipient management.
Network
mainnetversion 1
Upgrade policy
Immutable · upgrade cap consumed
Cap owner
0x4e7dfd9714a17cab5aaa7167f55770878f843c45b4f41dedd7341161f5ce9666
Package
0x884a8aceda89be24b53443769e868106084628dc2eb140ebc7059a8be21125f4Suiscan ↗
Admin cap
0xa97cd67528b866563903b3263b5b0e440dcd89285c1aba465ebf4e3b54f8b968Suiscan ↗
Network
testnetversion 1
Package
0x6246ecfe085d807a2cae5239ad1daaca1e7551ac05e6ccaf6a81941ba25ac96bSuiscan ↗
Upgrade cap
0xd713337636120151f542420ce7d430545b6570e28cfbd5258f71406b192c1fcfSuiscan ↗
Contract code1 modules
admin.move28 lines
/// Publication sends one transferable AdminCap to the publisher.
/// Multisig custody, admin-package immutability and dependency-only launchpad upgrades
/// require separate deployment actions; this initializer does not enforce them.
module fish_admin::admin;

/// Possession authorizes config creation and mutation across the launchpad. There is no
/// revocation or recovery path if custody is lost; upgrade authority is a separate cap.
public struct AdminCap has key, store { id: UID }

fun init(ctx: &mut TxContext) {
    transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender());
}

#[test_only]
public fun for_testing(ctx: &mut TxContext): AdminCap {
    AdminCap { id: object::new(ctx) }
}

#[test]
fun publication_mints_cap_to_publisher() {
    let mut scenario = sui::test_scenario::begin(@0xa);
    init(scenario.ctx());
    scenario.next_tx(@0xa);
    let cap = scenario.take_from_sender<AdminCap>();
    std::unit_test::destroy(cap);
    scenario.end();
}
View on GitHub ↗