Skip to content

Trade on the bonding curve

Quote, buy, and sell a launch token while it trades on its bonding curve.

Updated View as Markdown

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.

bonding-curve.tsts
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.

bonding-curve.tsts
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.

bonding-curve.tsts
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.

bonding-curve.tsts
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.

bonding-curve.tsts
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: 100n is 1%. Apply it to launchTokensOut for a buy and netPairTokenOut for a sell.
  • Buy quotes include the bonding fee and any recipient-specific anti-snipe tax. Use quoteBuyFor, not quoteBuy, 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 parseEther only for native ETH or an 18-decimal asset; use parseUnits with 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close