lumpfun

← Docs

On-Chain Protocol

Everything you need to build, sign, and submit transactions against LumpFun directly. Source of truth for validators is contracts/cardano/ in the repo. CBOR constants below match the deployed mainnet build.

Architecture overview

Each launched token has a unique policy ID (one-shot mint), curve script address (parameterised bonding curve), and fee accumulator address (parameterised with the creator's vkey). Trades transition the curve UTxO; fees pay into the accumulator; vesting (if any) locks the creator's initial-buy tokens at a separate per-position timelock script. Once ada_reserve reaches the configured graduation threshold, the curve drains via the Graduate redeemer and a Minswap V2 pool is created from the protocol treasury.

All scripts are PlutusV3, written in Aiken (stdlib v3.1.0).

Per-launch addresses & params

A launch is fully described by the parameters baked into its validators at deploy time. To reconstruct any address from scratch, reapply the params to the unparameterised CBOR below.

curveAddressbonding_curve script with (policyId, assetName, creatorFeeBps, treasuryPkh, payment_hash, graduationAda) params
feeAccumulatorAddressfee_accumulator script with (creatorPkh) param
vestingAddressvesting script with (creatorPkh, unlockPosixMs) params (per-position)
policyIdone-shot minting policy with the launch seed UTxO encoded

On a new launch, payment_hash equals the fee accumulator's script hash, not the creator's vkey. This is what routes creator fees into the accumulator. Tokens launched before the accumulator was added have payment_hash = creator_pkhinstead and pay the creator's wallet directly. Both are parsed by the same fees.creator_fee_paid check, which now accepts either VKey or Script payment credentials.

Bonding curve validator

Datum (inline)

pub type CurveDatum {
  ada_reserve:   Int,   // lovelace at the curve UTxO
  token_reserve: Int,   // tokens in the curve, ready to sell
}

// CBOR encoding: Constr(0, [<uint ada_reserve>, <uint token_reserve>])
//   d8 79 82 <cbor-uint> <cbor-uint>

Redeemer

pub type CurveRedeemer {
  Buy      { min_out: Int }   // Constr(0, [<uint min_out>])
  Sell     { min_out: Int }   // Constr(1, [<uint min_out>])
  Graduate                    // Constr(2, [])
}

Validator parameters (compile-time)

validator bonding_curve(
  policy_id:        ByteArray,    // 28-byte policy hash (hex)
  asset_name:       ByteArray,    // utf-8 of ticker, hex-encoded
  creator_fee_bps:  Int,          // 0..200, 100 = 1%
  treasury_pkh:     ByteArray,    // protocol treasury vkey hash
  payment_hash:     ByteArray,    // fee accumulator script hash (new) OR creator vkey (legacy)
  graduation_ada:   Int,          // lovelace threshold, default 21_000_000_000
)

Spend rules — Buy

  • old_ada < graduation_ada (curve hasn't graduated)
  • ada_in > 0 and matches quote_buy(old_ada, old_tokens, ada_in) exactly
  • continuation curve UTxO has lovelace = old_lovelace + ada_in and tokens = old_tokens − tokens_out
  • tx pays platform fee (1 ADA) to treasury_pkh
  • tx pays creator fee (ada_in × bps / 10000) to payment_hash (script or vkey)
  • tokens_out ≥ min_out

Spend rules — Sell

  • same graduation gate
  • tokens_in > 0 and ada_gross = quote_sell_gross(old_ada, old_tokens, tokens_in)
  • continuation has lovelace = old_lovelace − ada_gross and tokens = old_tokens + tokens_in
  • platform + creator fees as above (creator fee on ada_gross for sell)
  • net_ada ≥ min_out where net_ada = ada_gross − bps/10000 × ada_gross − 1_000_000

Spend rules — Graduate

  • Single check: datum.ada_reserve ≥ graduation_ada
  • Off-chain side (driven from treasury) sweeps the curve into treasury, then creates the Minswap V2 pool in a follow-up tx.

Curve math

Constant-product with a 3,000 ADA virtual offset (sets the starting price without seeding real liquidity). Total supply is 1_000_000_000n per token (0 decimals).

const VIRTUAL_ADA  = 3_000_000_000n; // lovelace

quote_buy(adaReserve, tokenReserve, adaIn):
  effective       = adaReserve + VIRTUAL_ADA
  k               = effective × tokenReserve
  newEffective    = effective + adaIn
  newTokenReserve = k / newEffective              // floor division
  return tokenReserve − newTokenReserve

quote_sell_gross(adaReserve, tokenReserve, tokensIn):
  effective       = adaReserve + VIRTUAL_ADA
  k               = effective × tokenReserve
  newTokenReserve = tokenReserve + tokensIn
  newEffective    = k / newTokenReserve
  gross           = effective − newEffective
  return min(gross, adaReserve)                   // safety cap

spotPrice(adaReserve, tokenReserve):
  return (adaReserve + VIRTUAL_ADA) × 1e6 / tokenReserve  // lovelace/token

The off-chain helpers are exported from web/src/lib/curve-math.tsin the repo. Use them — don't re-implement floor division semantics.

Fee accumulator

Per-launch script that collects creator fees into a single growing UTxO. Without it, every fee under ~1 ADA bumps to Cardano's min-UTxO and fragments the creator's wallet across hundreds of dust outputs after a few hundred trades.

Validator parameters

validator fee_accumulator(
  creator_pkh: ByteArray,   // only key that can spend
)

// Datum: unused
// Redeemer: unused
// Spend rule: tx is signed by creator_pkh.

Lifecycle

  • At launch: derive the accumulator address from the creator's vkey, take its script hash, and use that hash as payment_hash when parameterising the bonding curve.
  • On every trade: bonding-curve validator checks that creator_fee × ada_in/10000 lovelace was paid to a UTxO whose payment credential matches payment_hash — including Script credentials. So the fee output flows to the accumulator address.
  • Anytime: creator submits a tx that spends every UTxO at the accumulator address (signs as the creator's wallet) and sends the total lovelace back to themselves.

Helper available in web/src/lib/cardano-tx.ts as claimCreatorFees(walletApi, address, validatorCbor).

Vesting timelock

Optional per-launch (and re-vest) lockup for creator tokens. Each unlock time produces a different parameterised script address, so positions with different timelines never collide on chain.

Validator parameters

validator vesting(
  creator_pkh:     ByteArray,
  unlock_posix_ms: Int,        // POSIX milliseconds, matches Lucid validFrom
)

// Spend rule (both must hold):
//   1. tx is signed by creator_pkh
//   2. tx.validity_range.lower_bound is Finite(t) with t ≥ unlock_posix_ms

Claim path: claimVestedTokens in cardano-tx.ts. Adds the creator's pkh as a required signer and sets validFrom = max(unlock + 1s, now + 5s) so the slot lands strictly after the unlock without trying to be in the past.

One-shot minting policy

Parameterised with the launch OutputReference (a tx-input the creator's wallet consumes). Spending that input mints the token; once spent, the policy can never mint again. This is what makes policyId uniquely identify a launch — no second mint is possible from the same script.

Param encoding

// CIP-25 OutputReference (stdlib v3): Constr(0, [<bytes tx_hash>, <int output_index>])
//   d8 79 82 <bytes32> <cbor-uint>

Build a buy transaction

Walks through the exact tx your buyTokens flow signs, using Lucid Evolution. Pseudocode is verbatim from web/src/lib/cardano-tx.ts; copy as-is.

import {
  Lucid, Blockfrost, Data, Constr,
  applyDoubleCborEncoding, applyParamsToScript,
} from '@lucid-evolution/lucid';

// 1. Connect Lucid + wallet
const lucid = await Lucid(new Blockfrost(BF_URL, BF_PROJECT_ID), 'Mainnet');
lucid.selectWallet.fromAPI(cip30);   // any CIP-30 wallet

// 2. Pull live curve UTxO + decode datum
const curveAddress = '<from registry>';
const assetUnit    = '<policyId><assetName-hex>';
const utxos = await lucid.utxosAt(curveAddress);
const curveUtxo = utxos.find(u => u.assets[assetUnit] !== undefined);
const datum = Data.from(curveUtxo.datum) as Constr<bigint>;
const adaReserve   = datum.fields[0] as bigint;
const tokenReserve = datum.fields[1] as bigint;

// 3. Compute quote (mirror chain math exactly)
const adaIn      = 5_000_000n;          // 5 ADA
const tokensOut  = quoteBuy(adaReserve, tokenReserve, adaIn);
const minOut     = tokensOut - (tokensOut * 50n) / 10000n;   // 0.5% slippage

// 4. New datum + redeemer
const newDatum = Data.to(new Constr(0, [adaReserve + adaIn, tokenReserve - tokensOut]));
const redeemer = Data.to(new Constr(0, [minOut]));           // Buy { min_out }

// 5. Build, sign, submit
const tx = await lucid.newTx()
  .collectFrom([{...curveUtxo, datumHash: undefined}], redeemer)
  .attach.SpendingValidator({ type: 'PlutusV3', script: validatorCbor })
  .pay.ToAddressWithData(
    curveAddress,
    { kind: 'inline', value: newDatum },
    { lovelace: curveUtxo.assets.lovelace + adaIn,
      [assetUnit]: tokenReserve - tokensOut },
  )
  .pay.ToAddress(treasuryAddress, { lovelace: 1_000_000n })   // platform fee
  .pay.ToAddress(feeAccumulatorAddress,                       // creator fee
                 { lovelace: (adaIn * BigInt(creatorFeeBps)) / 10000n })
  .pay.ToAddress(walletAddr, { lovelace: 2_000_000n,
                                [assetUnit]: tokensOut })     // buyer's tokens
  .complete()
  .then(t => t.sign.withWallet().complete());

const txHash = await tx.submit();

For tokens launched before the fee accumulator, replace feeAccumulatorAddresswith the creator's bech32 wallet address — those validators require a VKey output to creator_pkh.

Build a sell transaction

Mirror image of the buy. Datum updates are (adaReserve − adaGross, tokenReserve + tokensIn); curve UTxO loses ADA and gains tokens; redeemer is Constr(1, [minOutAdaNet]); net ADA goes back to the seller's wallet:

const adaGross  = quoteSellGross(adaReserve, tokenReserve, tokensIn);
const creatorFee = (adaGross * BigInt(creatorFeeBps)) / 10000n;
const adaNet    = adaGross - 1_000_000n - creatorFee;
const minOut    = adaNet - (adaNet * 50n) / 10000n;          // 0.5% slippage

// .pay.ToAddress(curveAddress, { lovelace: lovelace - adaGross, [assetUnit]: tokenReserve + tokensIn })
// .pay.ToAddress(treasuryAddress,         { lovelace: 1_000_000n })
// .pay.ToAddress(feeAccumulatorAddress,   { lovelace: creatorFee })   // (or creator vkey for legacy)
// .pay.ToAddress(sellerWalletAddr,        { lovelace: adaNet })

Build a launch transaction

Programmatic launches are supported but not common — the UI handles the full flow including image upload, vesting parameterisation, and registry write. If you need to build a launch tx directly, the steps are:

  1. Pick a seed UTxO from the creator's wallet. This UTxO gets consumed by the mint — its (txHash, outputIndex) becomes the one-shot policy parameter, so policy ID is unique per launch.
  2. Derive the fee accumulator script + address (parameterise FEE_ACCUMULATOR_CBORwith the creator's vkey hash).
  3. Parameterise the bonding curve with (policyId, assetName, creatorFeeBps, treasuryPkh, feeAccumulatorScriptHash, graduationAda).
  4. If vesting requested: derive a vesting script address with (creatorPkh, unlockPosixMs).
  5. Build a single tx that:
    • Mints TOTAL_SUPPLY = 1_000_000_000 tokens via the one-shot policy
    • Sends (MIN_UTXO + initialBuyLovelace, curveTokens − initialBuyTokensOut) to curveAddress with inline CurveDatum
    • Sends 1 ADA platform fee to treasury
    • If vesting: sends initial-buy tokens to vesting address with Data.void() datum. Else: sends them to creator wallet.
    • Attaches CIP-25 metadata at label 721 (chunk every string at 64 UTF-8 bytes).
  6. POST the resulting TokenMeta (policyId, assetName, addresses, validatorCbor, etc.) to POST /api/tokens so the launch shows up in the feed and graduation cron picks it up.

The full reference implementation is launchToken in web/src/lib/cardano-tx.ts.

Graduation to Minswap V2

Once ada_reserve ≥ graduation_ada (default 21,000 ADA), the protocol drains the curve into the treasury wallet (Graduate redeemer, signed by treasury) and creates a Minswap V2 pool from the same wallet using the Minswap SDK.

adaForPoolall of curve.adaReserve at the moment of graduation
tokensForPoolcomputed so the opening pool price equals the curve closing price (avoids arbitrage)
Pool tradingFee0.3% (Minswap V2 default)

The opening price match formula is documented in web/src/lib/graduate-math.ts. Surplus tokens (everything not seeded into the pool) stay in treasury and can be swapped back manually if needed.

Graduation is automatic. The cron route runs every minute on Vercel Pro and triggers any pending migration. Trades on the bonding curve are rejected by the validator once threshold is hit; until the pool tx confirms, the only operation that succeeds is Graduate.