Skip to content

Trade after graduation

Quote and execute swaps in a graduated token's locked Uniswap V4 pool.

Updated View as Markdown

After graduation, curve trading stays closed and liquidity moves to a full-range Uniswap V4 position that is permanently locked by Clank. Route new buys and sells through that V4 pool instead of calling the bonding curve.

Quote without a wallet

Resolve the canonical pool key and simulate the V4 Quoter before asking the user to connect or sign.

Execute after confirmation

Apply slippage and a deadline, complete any Permit2 approvals, simulate the exact router call, and then submit it.

Current Robinhood contracts

Contract Address
Clank factory 0x798daAa0707C1e538bB5Acf0867Ac0e1A84cccF2
V4 Quoter 0x8dc178efb8111bb0973dd9d722ebeff267c98f94
Universal Router 0x8876789976decbfcbbbe364623c63652db8c0904
Permit2 0x000000000022D473030F116dDEE9F6B43aC78BA3

The current graduated pools charge 0.3%. Read the pool fee, tick spacing, pair token, and hook from the launch record and factory instead of assuming those values for every launch.

Prerequisites

This guide calls the contracts directly with Viem. It assumes your app already has a Robinhood Chain publicClient, a connected walletClient, and the launchToken address.

The code blocks below are sections of the same graduated-trade.ts module. Quotes and wallet execution are intentionally separate so an interface can display the expected output before requesting approvals or a swap signature.

Resolve the graduated pool

Read the curve state rather than relying only on the compatibility phase field. State 3 is PoolCreated; states 1 and 2 are still moving through graduation and cannot trade on either venue.

graduated-trade.tsts
import {
  encodeAbiParameters,
  erc20Abi,
  maxUint256,
  parseAbi,
  parseEther,
  zeroAddress,
  type Address,
  type Hex,
  type PublicClient,
  type WalletClient,
} from "viem";

const ROBINHOOD_CHAIN_ID = 4663;
const CLANK_FACTORY = "0x798daAa0707C1e538bB5Acf0867Ac0e1A84cccF2";
const V4_QUOTER = "0x8dc178efb8111bb0973dd9d722ebeff267c98f94";
const UNIVERSAL_ROUTER = "0x8876789976decbfcbbbe364623c63652db8c0904";
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";

const MAX_UINT128 = (1n << 128n) - 1n;
const MAX_UINT160 = (1n << 160n) - 1n;
const MAX_UINT48 = (1n << 48n) - 1n;

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)",
  "function initializationHook() view returns (address)",
]);

const curveAbi = parseAbi(["function state() view returns (uint8)"]);

const quoterAbi = parseAbi([
  "function quoteExactInputSingle(((address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks) poolKey,bool zeroForOne,uint128 exactAmount,bytes hookData) params) returns (uint256 amountOut,uint256 gasEstimate)",
]);

const universalRouterAbi = parseAbi([
  "function execute(bytes commands,bytes[] inputs,uint256 deadline) payable",
]);

const permit2Abi = parseAbi([
  "function allowance(address user,address token,address spender) view returns (uint160 amount,uint48 expiration,uint48 nonce)",
  "function approve(address token,address spender,uint160 amount,uint48 expiration)",
]);

type PoolKey = {
  currency0: Address;
  currency1: Address;
  fee: number;
  tickSpacing: number;
  hooks: Address;
};

const resolveGraduatedPool = async ({
  publicClient,
  launchToken,
}: {
  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, hooks] = await Promise.all([
    publicClient.readContract({
      address: launch.curve,
      abi: curveAbi,
      functionName: "state",
    }),
    publicClient.readContract({
      address: CLANK_FACTORY,
      abi: factoryAbi,
      functionName: "initializationHook",
    }),
  ]);
  if (state !== 3) throw new Error("The token has not completed graduation");

  const launchTokenFirst = BigInt(launchToken) < BigInt(launch.pairToken);
  const poolKey: PoolKey = {
    currency0: launchTokenFirst ? launchToken : launch.pairToken,
    currency1: launchTokenFirst ? launch.pairToken : launchToken,
    fee: launch.poolFee,
    tickSpacing: launch.tickSpacing,
    hooks,
  };

  return {
    launchToken,
    curve: launch.curve,
    pairToken: launch.pairToken,
    poolKey,
  } as const;
};

const market = await resolveGraduatedPool({ publicClient, launchToken });

The zero address is native ETH in a V4 pool. Currency order is numeric address order, not a fixed “token first” or “pair token first” convention.

Quote buys and sells

The V4 Quoter is called through simulateContract because its quote function is not marked view. This is still a read-only RPC simulation and does not require a transaction.

graduated-trade.tsts
type SwapQuote = {
  side: "buy" | "sell";
  amountIn: bigint;
  amountOut: bigint;
  minimumAmountOut: bigint;
};

const quoteExactInput = async ({
  publicClient,
  trader,
  inputToken,
  amountIn,
  slippageBps,
}: {
  publicClient: PublicClient;
  trader?: Address;
  inputToken: Address;
  amountIn: bigint;
  slippageBps: bigint;
}) => {
  if (amountIn <= 0n || amountIn > MAX_UINT128) {
    throw new Error("Input must fit the Uniswap V4 uint128 range");
  }
  if (slippageBps < 0n || slippageBps >= 10_000n) {
    throw new Error("Slippage must be between 0 and 9,999 basis points");
  }

  const zeroForOne =
    inputToken.toLowerCase() === market.poolKey.currency0.toLowerCase();
  const { result } = await publicClient.simulateContract({
    account: trader ?? zeroAddress,
    address: V4_QUOTER,
    abi: quoterAbi,
    functionName: "quoteExactInputSingle",
    args: [
      {
        poolKey: market.poolKey,
        zeroForOne,
        exactAmount: amountIn,
        hookData: "0x",
      },
    ],
  });

  const [amountOut] = result;
  const minimumAmountOut = (amountOut * (10_000n - slippageBps)) / 10_000n;
  if (minimumAmountOut > MAX_UINT128) {
    throw new Error("Minimum output exceeds the router's uint128 range");
  }
  return { amountIn, amountOut, minimumAmountOut } as const;
};

const quoteBuy = async ({
  publicClient,
  pairTokenIn,
  trader,
  slippageBps = 100n,
}: {
  publicClient: PublicClient;
  pairTokenIn: bigint;
  trader?: Address;
  slippageBps?: bigint;
}): Promise<SwapQuote> => ({
  side: "buy",
  ...(await quoteExactInput({
    publicClient,
    trader,
    inputToken: market.pairToken,
    amountIn: pairTokenIn,
    slippageBps,
  })),
});

const quoteSell = async ({
  publicClient,
  launchTokensIn,
  trader,
  slippageBps = 100n,
}: {
  publicClient: PublicClient;
  launchTokensIn: bigint;
  trader?: Address;
  slippageBps?: bigint;
}): Promise<SwapQuote> => ({
  side: "sell",
  ...(await quoteExactInput({
    publicClient,
    trader,
    inputToken: market.launchToken,
    amountIn: launchTokensIn,
    slippageBps,
  })),
});

The returned amountOut already reflects the pool’s swap fee and price impact; V4 does not return the fee as a separate output. Display amountOut and minimumAmountOut before requesting wallet confirmation.

Quote a buy

For a native-ETH pool, the buy input uses 18 decimals:

graduated-trade.tsts
const [trader] = await walletClient.getAddresses();
if (!trader) throw new Error("Connect a wallet before trading");

const buyQuote = await quoteBuy({
  publicClient,
  trader,
  pairTokenIn: parseEther("0.1"),
});

For an ERC-20 pair token, read its decimals and replace parseEther with parseUnits(amount, pairTokenDecimals).

Quote a sell

Clank launch tokens use 18 decimals:

graduated-trade.tsts
const sellQuote = await quoteSell({
  publicClient,
  trader,
  launchTokensIn: parseEther("1000"),
});

Approvals and Permit2

The Universal Router spends ERC-20 inputs through Permit2. An ERC-20 input may therefore require two one-time transactions: approve the token for Permit2, then approve the Universal Router inside Permit2. Native-ETH buys need neither.

graduated-trade.tsts
const approveRouterInput = async ({
  publicClient,
  walletClient,
  trader,
  inputToken,
  amountIn,
}: {
  publicClient: PublicClient;
  walletClient: WalletClient;
  trader: Address;
  inputToken: Address;
  amountIn: bigint;
}) => {
  if (inputToken === zeroAddress) return;
  if (amountIn > MAX_UINT160) {
    throw new Error("Input exceeds the Permit2 uint160 limit");
  }

  const [tokenAllowance, permitAllowance, latestBlock] = await Promise.all([
    publicClient.readContract({
      address: inputToken,
      abi: erc20Abi,
      functionName: "allowance",
      args: [trader, PERMIT2],
    }),
    publicClient.readContract({
      address: PERMIT2,
      abi: permit2Abi,
      functionName: "allowance",
      args: [trader, inputToken, UNIVERSAL_ROUTER],
    }),
    publicClient.getBlock({ blockTag: "latest" }),
  ]);

  if (tokenAllowance < amountIn) {
    const { request } = await publicClient.simulateContract({
      account: trader,
      address: inputToken,
      abi: erc20Abi,
      functionName: "approve",
      args: [PERMIT2, maxUint256],
    });
    const hash = await walletClient.writeContract(request);
    const receipt = await publicClient.waitForTransactionReceipt({ hash });
    if (receipt.status !== "success") {
      throw new Error("Permit2 token approval reverted");
    }
  }

  const [permitAmount, permitExpiration] = permitAllowance;
  const blockTimestamp = Number(latestBlock.timestamp);
  if (permitAmount < amountIn || permitExpiration <= blockTimestamp) {
    const { request } = await publicClient.simulateContract({
      account: trader,
      address: PERMIT2,
      abi: permit2Abi,
      functionName: "approve",
      args: [inputToken, UNIVERSAL_ROUTER, MAX_UINT160, Number(MAX_UINT48)],
    });
    const hash = await walletClient.writeContract(request);
    const receipt = await publicClient.waitForTransactionReceipt({ hash });
    if (receipt.status !== "success") {
      throw new Error("Universal Router approval reverted");
    }
  }
};

Encode the Robinhood V4 route

Robinhood’s deployed Universal Router includes minHopPriceX36 in its V4 exact-input tuple. Generic V4 examples that omit this field encode incompatible calldata for this router.

graduated-trade.tsts
const exactInputSingleType = {
  type: "tuple",
  components: [
    {
      name: "poolKey",
      type: "tuple",
      components: [
        { name: "currency0", type: "address" },
        { name: "currency1", type: "address" },
        { name: "fee", type: "uint24" },
        { name: "tickSpacing", type: "int24" },
        { name: "hooks", type: "address" },
      ],
    },
    { name: "zeroForOne", type: "bool" },
    { name: "amountIn", type: "uint128" },
    { name: "amountOutMinimum", type: "uint128" },
    { name: "minHopPriceX36", type: "uint256" },
    { name: "hookData", type: "bytes" },
  ],
} as const;

const currencyAmountTypes = [
  { name: "currency", type: "address" },
  { name: "amount", type: "uint256" },
] as const;

const encodeExactInputRoute = ({
  poolKey,
  inputToken,
  amountIn,
  minimumAmountOut,
}: {
  poolKey: PoolKey;
  inputToken: Address;
  amountIn: bigint;
  minimumAmountOut: bigint;
}) => {
  const zeroForOne =
    inputToken.toLowerCase() === poolKey.currency0.toLowerCase();
  const outputToken = zeroForOne ? poolKey.currency1 : poolKey.currency0;

  const swap = encodeAbiParameters(
    [exactInputSingleType],
    [
      {
        poolKey,
        zeroForOne,
        amountIn,
        amountOutMinimum: minimumAmountOut,
        minHopPriceX36: 0n,
        hookData: "0x",
      },
    ]
  );
  const settleAll = encodeAbiParameters(currencyAmountTypes, [
    inputToken,
    amountIn,
  ]);
  const takeAll = encodeAbiParameters(currencyAmountTypes, [
    outputToken,
    minimumAmountOut,
  ]);
  const input = encodeAbiParameters(
    [
      { name: "actions", type: "bytes" },
      { name: "params", type: "bytes[]" },
    ],
    ["0x060c0f", [swap, settleAll, takeAll]]
  );

  return { commands: "0x10" as Hex, inputs: [input] as const };
};

0x10 executes a V4 swap. The action sequence 0x060c0f performs an exact-input single swap, settles the entire input, and takes the output back to the caller.

Execute a buy or sell

Execution consumes a previously displayed quote. It handles the input approval, builds a short deadline from the latest chain time, and simulates the exact router call before opening the wallet.

graduated-trade.tsts
const executeSwap = async ({
  publicClient,
  walletClient,
  trader,
  quote,
}: {
  publicClient: PublicClient;
  walletClient: WalletClient;
  trader: Address;
  quote: SwapQuote;
}) => {
  const [walletAddresses, walletChainId, rpcChainId] = await Promise.all([
    walletClient.getAddresses(),
    walletClient.getChainId(),
    publicClient.getChainId(),
  ]);
  if (walletAddresses[0]?.toLowerCase() !== trader.toLowerCase()) {
    throw new Error("The connected wallet account changed");
  }
  if (
    walletChainId !== ROBINHOOD_CHAIN_ID ||
    rpcChainId !== ROBINHOOD_CHAIN_ID
  ) {
    throw new Error("Connect the wallet and RPC to Robinhood Chain");
  }

  const inputToken =
    quote.side === "buy" ? market.pairToken : market.launchToken;
  await approveRouterInput({
    publicClient,
    walletClient,
    trader,
    inputToken,
    amountIn: quote.amountIn,
  });

  const route = encodeExactInputRoute({
    poolKey: market.poolKey,
    inputToken,
    amountIn: quote.amountIn,
    minimumAmountOut: quote.minimumAmountOut,
  });
  const latestBlock = await publicClient.getBlock({ blockTag: "latest" });
  const wallTime = BigInt(Math.floor(Date.now() / 1_000));
  const currentTime =
    latestBlock.timestamp > wallTime ? latestBlock.timestamp : wallTime;
  const deadline = currentTime + 60n;
  const value = inputToken === zeroAddress ? quote.amountIn : 0n;

  const { request } = await publicClient.simulateContract({
    account: trader,
    address: UNIVERSAL_ROUTER,
    abi: universalRouterAbi,
    functionName: "execute",
    args: [route.commands, [...route.inputs], deadline],
    value,
  });

  const hash = await walletClient.writeContract(request);
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  if (receipt.status !== "success") throw new Error("Swap reverted");
  return { hash, receipt } as const;
};

Buy

graduated-trade.tsts
const buyResult = await executeSwap({
  publicClient,
  walletClient,
  trader,
  quote: buyQuote,
});

A native-ETH buy sends buyQuote.amountIn as transaction value. An ERC-20 buy uses the confirmed token and Permit2 approvals instead.

Sell

graduated-trade.tsts
const sellResult = await executeSwap({
  publicClient,
  walletClient,
  trader,
  quote: sellQuote,
});

Every sell uses the launch token as its ERC-20 input, so the first sell normally needs both approval steps. The output is native ETH when pairToken is the zero address, otherwise it is the configured ERC-20 pair token.

Routing summary

Curve state Value Route trades to
Trading 0 Clank bonding curve
Ready 1 Nowhere; wait for reserve sweeping
Swept 2 Nowhere; wait for V4 pool creation
PoolCreated 3 The launch’s locked Uniswap V4 pool
Rescued 4 No automatic venue

See Token lifecycle for graduation mechanics and Fees for the current curve and V4 fee configuration.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close