TypeScript
Trade a token
Buying and selling on the curve, priced before you send it, with a slippage bound you set. Plus what changes once a coin graduates.
Before graduation you trade against the coin's own curve contract — not a router, not
another person. Two functions, buy and sell, each taking a
slippage bound and a recipient.
Seeded coins trade elsewhere
Everything here is a curve launch. A seeded coin has no curve at any point — it is a Uniswap v3 pool, bought and sold through Uniswap's own router. Seeded launches has that code.
Finding the curve#
Every coin has its own curve. Given the token address, the factory has the rest:
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 the number you show is the number that lands.
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 a rough quote gets wrong:
- 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 so does everyone.
- A buy can be clamped. The curve won't sell into the pool's allocation, so a large buy near the end fills partially and refunds the rest.
Buying#
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:
quoteInmust equal the value sent. The curve checks rather than inferring one from the other.recipientreceives the tokens and is also whose snipe-tax exemption gets checked. Buying to an address that isn't yours is fine, but it's that address's rate that applies.
Slippage on a partial fill#
When a buy is clamped to what's left, minTokensOut is read as a bound on
the price rather than the quantity: what you paid per token has to be no
worse than your own arguments implied. Where nothing is clamped it reduces to
tokensOut >= minTokensOut.
So a bound sized for a full fill won't 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 doesn't — that leg is native OKB.
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 any time before the curve graduates. The one closed moment is the single
transaction between the curve selling out and its pool existing, and it's permissionless
to settle: call createGraduatedPool(token) yourself and trade the pool
instead.
Reading live state#
Price, market cap and progress all come from the curve's two balances. Nothing is stored, so nothing can be stale.
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#
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. 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 pile up on the curve as it trades. Getting them into your wallet is a sweep and then a claim.
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're owed across every coin you've 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 revert with CurveGraduated.
Check before you route:
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 cost per trade doesn't change across the boundary.