Every Clank launch token starts on a bonding curve. While the curve is in its
Trading state, buyers exchange the configured pair token for launch tokens
and sellers exchange launch tokens back for that pair token. The pair token is
either native ETH or an approved ERC-20.
This guide calls the contracts directly with Viem. It assumes your app already
has a publicClient, a connected walletClient, and the launch-token address.
See the quickstart for a smaller native-ETH buy example.
The examples below are sections of the same bonding-curve.ts module.
Quote without a wallet
Read the curve and show expected outputs, fees, and refunds before asking the user to connect or sign.
Execute after confirmation
Apply slippage, handle approval, simulate, and submit only after the user accepts the displayed quote.
Pons V2 compatible
Existing Pons V2 integrations can keep their core execution and migration flow while adopting Clank V2’s richer reads.
Reusable flow
buy, sell, getReserves, graduate, createGraduatedPool, and
their core events retain the canonical interfaces.
Richer reads
quoteBuyFor, quoteSell, and state, used below, are Clank V2
additions.
Improved curve math
Maintains the 1 billion token supply through migration and a consistent market cap across graduation.
Resolve the curve and check its state
On Robinhood Chain, resolve the curve through the canonical factory instead of
accepting an untrusted curve address. Then read state() from that curve. Only
state 0 (Trading) accepts buys and sells.
import {
erc20Abi,
parseAbi,
zeroAddress,
type Address,
type PublicClient,
type WalletClient,
} from "viem";
const CLANK_FACTORY = "0x798daAa0707C1e538bB5Acf0867Ac0e1A84cccF2" as const;
const factoryAbi = parseAbi([
"function getLaunchedToken(address launchToken) view returns ((address launchToken,address curve,address deployer,address creatorFeeRecipient,address pairToken,uint256 graduationThreshold,uint24 poolFee,int24 tickSpacing,uint16 creatorTaxBps,bool buybackEnabled,uint8 phase,uint256 sweptPairToken,uint256 sweptLaunchTokens,uint256 sweptAt,bool exists) launched)",
]);
const curveAbi = parseAbi([
"function state() view returns (uint8)",
"function quoteBuyFor(address recipient,uint256 grossPairTokenIn) view returns (uint256 grossPairTokenUsed,uint256 netPairTokenIn,uint256 fee,uint256 launchTokensOut,uint256 pairTokenRefund)",
"function quoteSell(uint256 launchTokensIn) view returns (uint256 grossPairTokenOut,uint256 netPairTokenOut,uint256 fee)",
"function buy(uint256 pairTokenIn,uint256 minLaunchTokensOut,address recipient) payable returns (uint256 launchTokensOut)",
"function sell(uint256 launchTokensIn,uint256 minPairTokenOut,address recipient) returns (uint256 pairTokenOut)",
]);
const getTradingCurve = async (
publicClient: PublicClient,
launchToken: Address
) => {
const launch = await publicClient.readContract({
address: CLANK_FACTORY,
abi: factoryAbi,
functionName: "getLaunchedToken",
args: [launchToken],
});
if (!launch.exists) throw new Error("Not a Clank launch token");
const state = await publicClient.readContract({
address: launch.curve,
abi: curveAbi,
functionName: "state",
});
if (state !== 0) throw new Error("The bonding curve is no longer trading");
return {
curve: launch.curve,
pairToken: launch.pairToken,
} as const;
};The complete curve lifecycle is:
| State | Value | Trading behavior |
|---|---|---|
Trading |
0 | Buys and sells are enabled. |
Ready |
1 | The sale allocation is exhausted; curve trading is closed. |
Swept |
2 | Reserves have moved to the factory for graduation. |
PoolCreated |
3 | Trade through the graduated Uniswap V4 pool. |
Rescued |
4 | Graduation recovery completed; curve trading remains closed. |
Quote a buy
Keep quoting separate from execution so your app can display the expected
output, fees, and refund before asking for a wallet signature. quoteBuyFor
accepts the total amount of pair tokens the buyer is offering, including fees.
Quote for the actual launch-token recipient: during the launch window, the
total fee can depend on whether that recipient is exempt from the decaying
anti-snipe tax.
const quoteBondingCurveBuy = async ({
publicClient,
launchToken,
recipient,
requestedPairTokenIn,
}: {
publicClient: PublicClient;
launchToken: Address;
recipient: Address;
requestedPairTokenIn: bigint;
}) => {
const { curve, pairToken } = await getTradingCurve(publicClient, launchToken);
const [pairTokenUsed, netPairTokenIn, fee, launchTokensOut, pairTokenRefund] =
await publicClient.readContract({
address: curve,
abi: curveAbi,
functionName: "quoteBuyFor",
args: [recipient, requestedPairTokenIn],
});
return {
curve,
pairToken,
recipient,
requestedPairTokenIn,
pairTokenUsed,
netPairTokenIn,
fee,
launchTokensOut,
pairTokenRefund,
} as const;
};
type BondingCurveBuyQuote = Awaited<ReturnType<typeof quoteBondingCurveBuy>>;Call quoteBondingCurveBuy whenever the input or recipient changes and render
fields such as launchTokensOut, fee, and pairTokenRefund.
Buy
Pass the displayed quote unchanged to buyOnBondingCurve only after the user
confirms it. The function handles a required pair-token approval before
submitting the buy.
const BPS = 10_000n;
const minimumOutput = (amount: bigint, slippageBps: bigint) =>
(amount * (BPS - slippageBps)) / BPS;
const buyOnBondingCurve = async ({
publicClient,
walletClient,
quote,
slippageBps = 100n,
}: {
publicClient: PublicClient;
walletClient: WalletClient;
quote: BondingCurveBuyQuote;
slippageBps?: bigint;
}) => {
const [trader] = await walletClient.getAddresses();
if (!trader) throw new Error("Connect a wallet");
if (slippageBps < 0n || slippageBps >= BPS) {
throw new Error("Invalid slippage");
}
const minimumLaunchTokensOut = minimumOutput(
quote.launchTokensOut,
slippageBps
);
// A terminal buy can use only part of the offered input and refund the rest.
// Scale the contract argument so its proportional partial-fill check still
// enforces minimumLaunchTokensOut for the amount that is actually used.
const minimumForOfferedAmount =
quote.pairTokenUsed === quote.requestedPairTokenIn
? minimumLaunchTokensOut
: (minimumLaunchTokensOut * quote.requestedPairTokenIn +
quote.pairTokenUsed -
1n) /
quote.pairTokenUsed;
if (quote.pairToken !== zeroAddress) {
const allowance = await publicClient.readContract({
address: quote.pairToken,
abi: erc20Abi,
functionName: "allowance",
args: [trader, quote.curve],
});
if (allowance < quote.requestedPairTokenIn) {
const { request } = await publicClient.simulateContract({
account: trader,
address: quote.pairToken,
abi: erc20Abi,
functionName: "approve",
args: [quote.curve, quote.requestedPairTokenIn],
});
const hash = await walletClient.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash });
}
}
const { request } = await publicClient.simulateContract({
account: trader,
address: quote.curve,
abi: curveAbi,
functionName: "buy",
args: [
quote.requestedPairTokenIn,
minimumForOfferedAmount,
quote.recipient,
],
value: quote.pairToken === zeroAddress ? quote.requestedPairTokenIn : 0n,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Buy reverted");
return { hash, receipt } as const;
};The final buy can also move the curve out of Trading. Re-resolve and re-quote
after any failed simulation instead of submitting a stale transaction.
Quote a sell
quoteSell returns the gross pair-token amount removed from the curve, the net
pair-token amount paid to the recipient, and the fee. Return those amounts
without mixing display data with execution-time slippage.
const quoteBondingCurveSell = async ({
publicClient,
launchToken,
launchTokensIn,
}: {
publicClient: PublicClient;
launchToken: Address;
launchTokensIn: bigint;
}) => {
const { curve, pairToken } = await getTradingCurve(publicClient, launchToken);
const [grossPairTokenOut, netPairTokenOut, fee] =
await publicClient.readContract({
address: curve,
abi: curveAbi,
functionName: "quoteSell",
args: [launchTokensIn],
});
return {
curve,
pairToken,
launchToken,
launchTokensIn,
grossPairTokenOut,
netPairTokenOut,
fee,
} as const;
};
type BondingCurveSellQuote = Awaited<ReturnType<typeof quoteBondingCurveSell>>;Call quoteBondingCurveSell whenever the launch-token input changes. Display
netPairTokenOut, fee, and any other quote fields your interface needs.
Sell
A sell needs an allowance for the launch token. Pass the confirmed sell quote
to sellOnBondingCurve; the function approves the curve when necessary before
submitting the sell.
const sellOnBondingCurve = async ({
publicClient,
walletClient,
quote,
slippageBps = 100n,
}: {
publicClient: PublicClient;
walletClient: WalletClient;
quote: BondingCurveSellQuote;
slippageBps?: bigint;
}) => {
const [trader] = await walletClient.getAddresses();
if (!trader) throw new Error("Connect a wallet");
if (slippageBps < 0n || slippageBps >= BPS) {
throw new Error("Invalid slippage");
}
const minimumPairTokenOut = minimumOutput(quote.netPairTokenOut, slippageBps);
const allowance = await publicClient.readContract({
address: quote.launchToken,
abi: erc20Abi,
functionName: "allowance",
args: [trader, quote.curve],
});
if (allowance < quote.launchTokensIn) {
const { request } = await publicClient.simulateContract({
account: trader,
address: quote.launchToken,
abi: erc20Abi,
functionName: "approve",
args: [quote.curve, quote.launchTokensIn],
});
const hash = await walletClient.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash });
}
const { request } = await publicClient.simulateContract({
account: trader,
address: quote.curve,
abi: curveAbi,
functionName: "sell",
args: [quote.launchTokensIn, minimumPairTokenOut, trader],
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Sell reverted");
return { hash, receipt } as const;
};The curve pays native ETH when quote.pairToken is the zero address; otherwise
it pays that ERC-20. No pair-token approval is needed for a sell.
Slippage, fees, and transaction safety
- Slippage is expressed in basis points:
100nis 1%. Apply it tolaunchTokensOutfor a buy andnetPairTokenOutfor a sell. - Buy quotes include the bonding fee and any recipient-specific anti-snipe tax.
Use
quoteBuyFor, notquoteBuy, when the caller and recipient can differ. - Wait for an approval receipt before submitting the trade that depends on it. A submitted transaction hash alone does not mean the allowance is available.
- Treat all amounts as raw integer units. Use
parseEtheronly for native ETH or an 18-decimal asset; useparseUnitswith the ERC-20’s actual decimals. - Set a finite slippage limit. Passing zero as the minimum output removes price protection.
Once state() returns 3, route new trades through the launch token’s Uniswap
V4 pool. See Trade after graduation.