size.fun Open app

TypeScript

Trade a token

Buying and selling on the curve, priced before you send it, with a slippage bound you set — and what changes once the coin has graduated.

Before graduation you trade against the coin's own curve contract, not against a router and not against another person. Two functions, buy and sell, both taking a slippage bound and a recipient.

Finding the curve#

Every coin has its own curve. Given the token address, the factory has the rest:

ts
const launch = await publicClient.readContract({
  address: size.factory,
  abi: factoryAbi,
  functionName: 'getLaunchedToken',
  args: [token],
})

launch.curve            // the bonding curve to trade against
launch.creatorTaxBps    // the extra fee this coin charges, if any
launch.phase            // 0 curve, 1 swept, 2 pool created, 3 rescued

Quoting a trade

Price the trade before you send it. The curve's arithmetic is short enough to reproduce exactly, so you can show a number that matches what will land — no allowance for rounding needed.

ts quote.ts
import type { Address } from 'viem'
import { publicClient, curveAbi } from './size.js'

const BPS = 10_000n

/**
 * Prices a buy exactly as the curve will, so you can show a number before the
 * wallet prompt and set a slippage bound against it.
 *
 * The three charges come off the OKB leg first, and what is left is priced
 * against the reserves by constant product. `recipient` matters because the
 * snipe tax is per-wallet: a creator's own address is exempt, a stranger's is
 * not, and after the first 15 seconds of a launch it is zero for everyone.
 */
export async function quoteBuy(curve: Address, quoteIn: bigint, recipient: Address) {
  const read = { address: curve, abi: curveAbi } as const

  const [reserves, feeBps, taxBps, snipeBps, sellable] = await Promise.all([
    publicClient.readContract({ ...read, functionName: 'getReserves' }),
    publicClient.readContract({ ...read, functionName: 'feeBps' }),
    publicClient.readContract({ ...read, functionName: 'creatorTaxBps' }),
    publicClient.readContract({ ...read, functionName: 'currentSnipeTaxBps', args: [recipient] }),
    publicClient.readContract({ ...read, functionName: 'sellableTokens' }),
  ])

  const [quoteReserve, tokenReserve] = reserves

  const fee = (quoteIn * feeBps) / BPS
  const tax = (quoteIn * taxBps) / BPS
  const snipeTax = (quoteIn * snipeBps) / BPS
  const net = quoteIn - fee - tax - snipeTax

  let tokensOut = (net * tokenReserve) / (quoteReserve + net)

  // The curve will not sell into the graduated pool's allocation. A buy past it
  // is filled up to it and the unspent OKB comes back in the same transaction.
  const partialFill = tokensOut > sellable
  if (partialFill) tokensOut = sellable

  return { tokensOut, fee, tax, snipeTax, partialFill }
}

/** The same, in the other direction. Fees come off the OKB output here. */
export async function quoteSell(curve: Address, tokensIn: bigint) {
  const read = { address: curve, abi: curveAbi } as const

  const [reserves, feeBps, taxBps] = await Promise.all([
    publicClient.readContract({ ...read, functionName: 'getReserves' }),
    publicClient.readContract({ ...read, functionName: 'feeBps' }),
    publicClient.readContract({ ...read, functionName: 'creatorTaxBps' }),
  ])

  const [quoteReserve, tokenReserve] = reserves

  const gross = (tokensIn * quoteReserve) / (tokenReserve + tokensIn)
  const fee = (gross * feeBps) / BPS
  const tax = (gross * taxBps) / BPS

  return { quoteOut: gross - fee - tax, fee, tax }
}

Three things this gets right that a naive quote does not:

  • Fees come off the input first. On a buy, all three charges are deducted from the OKB before anything is priced against the reserves. On a sell they come off the output.
  • The snipe tax is per wallet. Exempt addresses read zero, and after a launch's first 15 seconds everyone does.
  • A buy can be clamped. The curve will not sell into the graduated pool's allocation, so a large buy near the end fills partially and refunds the rest.

Buying#

ts buy.ts
import { parseEther } from 'viem'
import type { Address } from 'viem'
import { publicClient, walletClient, account, curveAbi } from './size.js'
import { quoteBuy } from './quote.js'

export async function buy(curve: Address, okb: string, slippageBps = 100n) {
  const quoteIn = parseEther(okb)

  const { tokensOut } = await quoteBuy(curve, quoteIn, account.address)
  const minTokensOut = (tokensOut * (10_000n - slippageBps)) / 10_000n

  const { request } = await publicClient.simulateContract({
    address: curve,
    abi: curveAbi,
    functionName: 'buy',
    // quoteIn must equal the value sent — a native launch takes both.
    args: [quoteIn, minTokensOut, account.address],
    value: quoteIn,
    account,
  })

  const hash = await walletClient.writeContract(request)
  return publicClient.waitForTransactionReceipt({ hash })
}

await buy('0x0000000000000000000000000000000000000000', '0.25')

Two things the contract insists on:

  • quoteIn must equal the value sent. The curve checks it rather than inferring one from the other.
  • recipient is who receives the tokens, and it is also whose snipe-tax exemption is checked. Buying to an address that is not your own is fine, but it is that address's rate that applies.

Slippage on a partial fill#

When a buy is clamped to what is left, minTokensOut is read as a bound on the price rather than on the quantity — the requirement is that what you paid per token is no worse than what your own arguments implied. Where nothing is clamped, it reduces exactly to tokensOut >= minTokensOut.

So a bound sized against a full fill will not fail a partial one. You get the tokens that were there, at a price no worse than you asked for, and the unspent OKB comes back in the same transaction.

Selling#

Selling moves tokens into the curve, so it needs an ERC-20 approval first. Buying does not — that leg is native OKB.

ts sell.ts
import { erc20Abi, parseEther } from 'viem'
import type { Address } from 'viem'
import { publicClient, walletClient, account, curveAbi } from './size.js'
import { quoteSell } from './quote.js'

export async function sell(curve: Address, amount: string, slippageBps = 100n) {
  const tokensIn = parseEther(amount)

  const token = await publicClient.readContract({
    address: curve, abi: curveAbi, functionName: 'token',
  })

  // Selling moves tokens into the curve, so it needs an allowance. Buying does
  // not — that leg is native OKB.
  const allowance = await publicClient.readContract({
    address: token, abi: erc20Abi, functionName: 'allowance',
    args: [account.address, curve],
  })

  if (allowance < tokensIn) {
    const { request } = await publicClient.simulateContract({
      address: token, abi: erc20Abi, functionName: 'approve',
      args: [curve, tokensIn], account,
    })
    const approval = await walletClient.writeContract(request)
    await publicClient.waitForTransactionReceipt({ hash: approval })
  }

  const { quoteOut } = await quoteSell(curve, tokensIn)
  const minQuoteOut = (quoteOut * (10_000n - slippageBps)) / 10_000n

  const { request } = await publicClient.simulateContract({
    address: curve,
    abi: curveAbi,
    functionName: 'sell',
    args: [tokensIn, minQuoteOut, account.address],
    account,
  })

  const hash = await walletClient.writeContract(request)
  return publicClient.waitForTransactionReceipt({ hash })
}

You can sell at any point before the curve graduates. The one closed moment is the single transaction between the curve selling out and its pool existing, and it is permissionless to settle: call createGraduatedPool(token) on the factory yourself and trade the pool instead.

Reading live state#

Price, market cap and progress are all derived from the curve's two balances. Nothing is stored, so nothing can be stale.

ts state.ts
import { formatEther, erc20Abi } from 'viem'
import type { Address } from 'viem'
import { publicClient, curveAbi } from './size.js'

const WAD = 10n ** 18n

/**
 * Price, market cap and progress are not stored anywhere — they are the curve's
 * two balances, read live. Which is why they are correct the instant a trade
 * lands rather than whenever something last indexed it.
 */
export async function curveState(curve: Address) {
  const read = { address: curve, abi: curveAbi } as const

  const [reserves, real, threshold, token, graduated] = await Promise.all([
    publicClient.readContract({ ...read, functionName: 'getReserves' }),
    publicClient.readContract({ ...read, functionName: 'realQuoteReserve' }),
    publicClient.readContract({ ...read, functionName: 'graduationThreshold' }),
    publicClient.readContract({ ...read, functionName: 'token' }),
    publicClient.readContract({ ...read, functionName: 'graduated' }),
  ])

  const [quoteReserve, tokenReserve] = reserves

  const supply = await publicClient.readContract({
    address: token, abi: erc20Abi, functionName: 'totalSupply',
  })

  // OKB per token, at 18 decimals.
  const price = (quoteReserve * WAD) / tokenReserve

  return {
    token,
    graduated,
    price: formatEther(price),
    marketCap: formatEther((price * supply) / WAD),
    raised: formatEther(real),
    progressPct: Number((real * 10_000n) / threshold) / 100,
  }
}

Watching trades#

ts watch.ts
import { formatEther } from 'viem'
import type { Address } from 'viem'
import { publicClient, curveAbi } from './size.js'

/** Every buy and sell on one curve, as it lands. */
export function watchTrades(curve: Address) {
  return publicClient.watchContractEvent({
    address: curve,
    abi: curveAbi,
    onLogs: (logs) => {
      for (const log of logs) {
        if (log.eventName === 'CurveBuy') {
          const { recipient, quoteIn, tokensOut } = log.args
          console.log(`BUY  ${formatEther(quoteIn!)} OKB -> ${formatEther(tokensOut!)} by ${recipient}`)
        }
        if (log.eventName === 'CurveSell') {
          const { recipient, tokensIn, quoteOut } = log.args
          console.log(`SELL ${formatEther(tokensIn!)} -> ${formatEther(quoteOut!)} OKB by ${recipient}`)
        }
      }
    },
  })
}

Blocks are one second on X Layer, so this is close to real time. Note that the public RPC caps log queries at 100 blocks, which matters if you page backwards through history rather than tailing.

Collecting fees

If you launched the coin, its fees accumulate on the curve as it trades. Getting them into your wallet is a sweep and then a claim.

ts claim.ts
import { formatEther } from 'viem'
import type { Address } from 'viem'
import {
  publicClient, walletClient, account,
  size, curveAbi, escrowAbi,
} from './size.js'

/**
 * Step one. Splits a curve's pending fees and credits the escrow. Callable by
 * the launch's fee recipient. Skip it for a graduated coin — the pool's hook
 * sweeps its own.
 *
 * The argument is a minimum output for the buyback swap, and only matters on a
 * curve with buyback enabled. Those are swept by the protocol's operator, since
 * only it can set that floor.
 */
export async function sweep(curve: Address) {
  const pending = await publicClient.readContract({
    address: curve, abi: curveAbi, functionName: 'quoteFeeBalance',
  })
  if (pending === 0n) return

  const { request } = await publicClient.simulateContract({
    address: curve, abi: curveAbi, functionName: 'sweepFees',
    args: [0n], account,
  })
  const hash = await walletClient.writeContract(request)
  await publicClient.waitForTransactionReceipt({ hash })
}

/**
 * Step two. Pays out everything owed to the caller, across every coin they have
 * launched, from both curves and graduated pools.
 *
 * There is no recipient argument and there could not be one: the escrow pays
 * whoever called it.
 */
export async function claim() {
  const owed = await publicClient.readContract({
    address: size.feeEscrow, abi: escrowAbi, functionName: 'balanceOf',
    args: [account.address],
  })
  console.log(`claimable: ${formatEther(owed)} OKB`)
  if (owed === 0n) return

  const { request } = await publicClient.simulateContract({
    address: size.feeEscrow, abi: escrowAbi, functionName: 'claim', account,
  })
  const hash = await walletClient.writeContract(request)
  return publicClient.waitForTransactionReceipt({ hash })
}

One claim collects everything you are owed across every coin you have launched, from curves and graduated pools alike. See Claiming.

After graduation#

The curve is closed for good and the coin is an ordinary Uniswap v4 pool. The functions on this page will revert with CurveGraduated.

Check before you route:

ts
const launch = await publicClient.readContract({
  address: size.factory, abi: factoryAbi,
  functionName: 'getLaunchedToken', args: [token],
})

if (launch.phase === 0) {
  // Still on the curve. buy() and sell() above.
} else if (launch.phase === 2) {
  // Graduated: swap through any v4 router on X Layer.
} else {
  // 1 is mid-graduation — anyone can finish it with createGraduatedPool(token).
  // 3 is a launch the protocol unwound; it has no pool and never will.
}

Swapping a graduated pool is standard Uniswap v4 — the pool key is the coin and OKB, at the tick spacing and fee tier the launch recorded, with the size.fun hook attached. The hook takes the same 1% and splits it the same way, so your effective cost per trade does not change across the boundary.