---
title: "Trade after graduation"
description: "Quote and execute swaps in a graduated token's locked Uniswap V4 pool."
---

> Documentation Index
> Fetch the complete documentation index at: https://developer.clank.trade/llms.txt
> Use this file to discover all available pages before exploring further.

# Trade after graduation

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](https://viem.sh/). 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.

```ts title="graduated-trade.ts"
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.

```ts title="graduated-trade.ts"
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:

```ts title="graduated-trade.ts"
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:

```ts title="graduated-trade.ts"
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.

```ts title="graduated-trade.ts"
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.

```ts title="graduated-trade.ts"
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.

```ts title="graduated-trade.ts"
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

```ts title="graduated-trade.ts"
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

```ts title="graduated-trade.ts"
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.

> **Quotes are snapshots**
>
> Pool price and liquidity may change before confirmation. Refresh the quote
> when the amount, side, slippage, account, chain, or token changes. The minimum
> output and deadline protect execution, but your interface should still mark an
> older quote as stale.

## 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](/concepts/token-lifecycle) for graduation mechanics and
[Fees](/concepts/fees) for the current curve and V4 fee configuration.

Source: https://developer.clank.trade/guides/trade-after-graduation/index.mdx
