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.
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.
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:
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:
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.
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.
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.
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
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
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.