Quotes and trades
Quote any trade with the Quoter, then buy or sell through the CAST Router. These examples build on the client, CAST addresses, token and pair from Reading token state.
Quote a trade
quote(token, isBuy, pairGross, trader, atTime) runs the same fee maths as the Fee Hook. It works for any wallet (or none) and any time from launch on, so you can show what a trade costs now and later. The sealed fees are the same on the bonding curve and in the pool, so the quote is too. Amounts are in the token's paired asset, in its smallest unit: wei for ETH, 6 decimals for USDG.
- For a buy,
pairGrossis what the trader pays in. - For a sell, it is what the curve or pool pays out before fees. Simulate the sell for that number, or use the router simulation below, which already includes fees.
import { formatUnits, parseUnits } from "viem";
const quoterAbi = parseAbi([
"struct Breakdown { uint256 totalFeeBps; uint256 botTaxBps; uint256 tradeFeeBps; uint256 pairFee; uint256 toCast; uint256 toCreator; uint256 toHolders; uint256 botTax; uint256 burnRate; uint256 pairAfterFee; }",
"function quote(address token, bool isBuy, uint256 pairGross, address trader, uint256 atTime) view returns (Breakdown)",
]);
const atTime = BigInt(Math.floor(Date.now() / 1000));
// What would a buy of one whole unit of the pair (1 ETH, 1 USDG or one test share) pay right now,
// for a wallet with no Hold Streak? Amounts are in the pair's smallest unit.
const q = await client.readContract({
address: CAST.quoter,
abi: quoterAbi,
functionName: "quote",
args: [token, true, parseUnits("1", pair.decimals), zeroAddress, atTime],
});
const inPair = (x: bigint) => `${formatUnits(x, pair.decimals)} ${pair.symbol}`;
console.log(`Fee ${inPair(q.pairFee)} (${Number(q.totalFeeBps) / 100}%)`);
console.log(`Holders get ${inPair(q.toHolders)}, ${inPair(q.pairAfterFee)} reaches the pool`);| Field | Meaning |
|---|---|
| totalFeeBps | Bot Tax plus the buy or sell fee, in basis points |
| botTaxBps, tradeFeeBps | The two parts of that total |
| pairFee | The fee charged in the paired asset |
| toCast, toCreator, toHolders | How pairFee is split |
| botTax | The part of pairFee that is Bot Tax |
| burnRate | Share of the token side burned, scaled by 1e8 |
| pairAfterFee | Buy: what reaches the curve or pool. Sell: what the trader receives |
Buy
buy(token, amountIn, minTokensOut, recipient, deadline) spends amountIn of the token's paired asset. How you pay depends on the pair:
- ETH (
pairAsset()is the zero address): send exactlyamountInas value. - An ERC-20 such as USDG or a test stock: send no value, and approve the router for
amountInfirst. The router takes it straight from your wallet.
Any other value reverts with WrongValue. Simulate the buy first to learn how many tokens it returns right now, then send it with a minimum a little below that. The example below handles both kinds of pair, and works the same whether the token is on its bonding curve or has graduated.
import { erc20Abi, type WalletClient } from "viem";
const routerAbi = parseAbi([
"function buy(address token, uint256 amountIn, uint256 minTokensOut, address recipient, uint256 deadline) payable returns (uint256 tokensOut)",
"function buyWithPermit(address token, uint256 amountIn, uint256 minTokensOut, address recipient, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s) returns (uint256 tokensOut)",
"function sell(address token, uint256 amountIn, uint256 minOut, address recipient, uint256 deadline) returns (uint256 amountOut)",
]);
const SLIPPAGE_BPS = 100n; // accept up to 1% less than the simulated amount
const inFiveMinutes = () => BigInt(Math.floor(Date.now() / 1000) + 300);
// The router pulls ERC-20s from your wallet (the paired asset on a buy, the token on a sell),
// so it needs an allowance first.
export async function approveRouter(wallet: WalletClient, asset: `0x${string}`, amount: bigint) {
const account = wallet.account!;
const allowance = await client.readContract({
address: asset,
abi: erc20Abi,
functionName: "allowance",
args: [account.address, CAST.router],
});
if (allowance >= amount) return;
const hash = await wallet.writeContract({
account,
chain: robinhood,
address: asset,
abi: erc20Abi,
functionName: "approve",
args: [CAST.router, amount],
});
await client.waitForTransactionReceipt({ hash });
}// `wallet` is a viem WalletClient created with an account and chain: robinhood.
// `amountIn` is in the token's paired asset, in its smallest unit: parseEther("0.01") for ETH,
// parseUnits("25", 6) for USDG.
export async function buy(wallet: WalletClient, token: `0x${string}`, amountIn: bigint) {
const account = wallet.account!;
const asset = await client.readContract({ address: token, abi: tokenAbi, functionName: "pairAsset" });
// An ETH pair is paid as value. An ERC-20 pair sends no value: the router takes it with an allowance.
const value = asset === zeroAddress ? amountIn : 0n;
if (asset !== zeroAddress) await approveRouter(wallet, asset, amountIn);
// Simulate first: the result is what this buy would return right now, fees included.
const deadline = inFiveMinutes();
const { result: expected } = await client.simulateContract({
account,
address: CAST.router,
abi: routerAbi,
functionName: "buy",
args: [token, amountIn, 0n, account.address, deadline],
value,
});
const minTokensOut = (expected * (10_000n - SLIPPAGE_BPS)) / 10_000n;
const hash = await wallet.writeContract({
account,
chain: robinhood,
address: CAST.router,
abi: routerAbi,
functionName: "buy",
args: [token, amountIn, minTokensOut, account.address, deadline],
value,
});
return client.waitForTransactionReceipt({ hash });
}After each trade the router asks the Fee Hook to pay out holder rewards and burn the burn share. It does this in a try/catch, so a payout problem, such as a paused paired asset, never blocks the trade. The burn never waits on the paired asset: if the reward payout fails, the burn still happens and the rewards stay pending until a later distribute(token), which anyone can call on the hook.
Buy with a permit
USDG and the CAST test stocks support EIP-2612 permits. Instead of an approval transaction, the buyer signs a permit for the router to take exactly amountIn, and you pass the signature to buyWithPermit(token, amountIn, minTokensOut, recipient, deadline, permitDeadline, v, r, s). The permit is used in the same transaction as the buy. If it fails, for example because it was already used, the buy still goes ahead when an approval is in place.
import { parseSignature } from "viem";
const permitAbi = parseAbi([
"function name() view returns (string)",
"function nonces(address owner) view returns (uint256)",
]);
// For an ERC-20 pair with EIP-2612 permit, such as USDG and the CAST test stocks: one free
// signature instead of an approval transaction. Their permit domain is the asset's name() and version "1".
export async function buyWithPermit(wallet: WalletClient, token: `0x${string}`, amountIn: bigint) {
const account = wallet.account!;
const asset = await client.readContract({ address: token, abi: tokenAbi, functionName: "pairAsset" });
const [name, nonce] = await Promise.all([
client.readContract({ address: asset, abi: permitAbi, functionName: "name" }),
client.readContract({ address: asset, abi: permitAbi, functionName: "nonces", args: [account.address] }),
]);
// Allow the router to take exactly amountIn, once, within 30 minutes.
const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 1800);
const signature = await wallet.signTypedData({
account,
domain: { name, version: "1", chainId: robinhood.id, verifyingContract: asset },
types: {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
message: { owner: account.address, spender: CAST.router, value: amountIn, nonce, deadline: permitDeadline },
});
const sig = parseSignature(signature);
const v = sig.v !== undefined ? Number(sig.v) : sig.yParity + 27;
// The router uses the permit, then buys. Simulating checks the signature as well as the price.
const deadline = inFiveMinutes();
const args = [token, amountIn, 0n, account.address, deadline, permitDeadline, v, sig.r, sig.s] as const;
const { result: expected } = await client.simulateContract({ account, address: CAST.router, abi: routerAbi, functionName: "buyWithPermit", args });
const minTokensOut = (expected * (10_000n - SLIPPAGE_BPS)) / 10_000n;
const hash = await wallet.writeContract({
account,
chain: robinhood,
address: CAST.router,
abi: routerAbi,
functionName: "buyWithPermit",
args: [token, amountIn, minTokensOut, account.address, deadline, permitDeadline, v, sig.r, sig.s],
});
return client.waitForTransactionReceipt({ hash });
}Sell
sell(token, amountIn, minOut, recipient, deadline) pulls tokens from the caller, so the router needs an ERC-20 allowance first. It returns amountOut and sends it to recipient in the token's paired asset. Sell from the wallet that holds the tokens: the router names that wallet to the Fee Hook, which is how its Hold Streak discount applies.
// Sells `amountIn` tokens for the token's paired asset.
export async function sell(wallet: WalletClient, token: `0x${string}`, amountIn: bigint) {
const account = wallet.account!;
// The router pulls the tokens from your wallet, so it needs an allowance first.
await approveRouter(wallet, token, amountIn);
const deadline = inFiveMinutes();
const { result: expected } = await client.simulateContract({
account,
address: CAST.router,
abi: routerAbi,
functionName: "sell",
args: [token, amountIn, 0n, account.address, deadline],
});
const minOut = (expected * (10_000n - SLIPPAGE_BPS)) / 10_000n;
const hash = await wallet.writeContract({
account,
chain: robinhood,
address: CAST.router,
abi: routerAbi,
functionName: "sell",
args: [token, amountIn, minOut, account.address, deadline],
});
return client.waitForTransactionReceipt({ hash });
}BuyNeedsCastRouter. Sells through other routers still work, and once the cap lifts any router can buy.Trading on the curve
Until a token graduates, the Fee Hook fills every trade from the token's bonding curve, inside the same Uniswap v4 pool. The CAST Router works the same before and after, but a few things differ for anyone building on it:
- Exact input only. The curve takes exact-input swaps, where the amount in is fixed. The CAST Router only does exact input anyway. Other routers must use exact input until graduation; an exact-output swap reverts with
CurveIsExactInputOnly. - The curve buys back at most what it has sold. A sell bigger than that reverts with
SellExceedsCurve(tokens, sold). Tokens bought on the curve can always be sold back, so in practice this only matters for a creator allocation released before the token graduates. - No 0.3% pool fee. There is no pool position yet, so curve trades only pay the sealed fees. The pool fee starts with the first trade after graduation.
- A buy can graduate the token. The buy that takes the curve to its target gets the curve's last tokens, the token graduates in the same transaction, and the rest of the buy swaps in the new pool. Your simulation already includes all of this.
- Read CurveTrade, not Swap. The PoolManager still logs a
Swapfor each curve trade, but with zero amounts. The trade itself is in the hook'sCurveTradeevent. See Index trades.
To show whether a token is still on its curve and how close it is to graduating, read curves(token): see Curve progress.
Errors
Decode reverts with the token, router and hook ABIs to show a clear message:
// CastRouter
error Expired();
error TooLittleReceived(uint256 received, uint256 minimum);
error WrongValue(uint256 sent, uint256 expected);
error NotCastToken(address token);
// CastToken
error MaxWalletExceeded(address wallet, uint256 balance, uint256 cap);
error CreatorLockActive(uint256 unlocksAt);
// CastHook
error BuyNeedsCastRouter();
error CurveIsExactInputOnly();
error SellExceedsCurve(uint256 tokens, uint256 sold);
error LiquidityLockedForever();
error OnlyGraduationAddsLiquidity();| Error | What to tell the user |
|---|---|
| Expired | The quote is too old. Get a fresh one and try again. |
| TooLittleReceived | The price moved past your slippage. Try again or allow more slippage. |
| WrongValue | The ETH sent doesn't match. Send exactly amountIn for an ETH pair, and nothing for an ERC-20 pair. |
| NotCastToken | That address isn't a CAST token: its pool doesn't use the CAST Fee Hook. The router refuses it before moving any funds. |
| MaxWalletExceeded | This would take the receiving wallet over the token's max wallet. Try a smaller amount. |
| CreatorLockActive | The creator's tokens are still locked until the given time. |
| BuyNeedsCastRouter | This token's max wallet still applies, so only the CAST Router can buy it. Buy through the CAST Router; selling works through any router. |
| CurveIsExactInputOnly | Until this token graduates, only trades with a fixed amount in work. Use the CAST Router, or exact input in your router. |
| SellExceedsCurve | The curve can only buy back as many tokens as it has sold so far. Sell a smaller amount. |
| LiquidityLockedForever | Liquidity in a CAST pool can never be removed. |
| OnlyGraduationAddsLiquidity | Nobody can add liquidity to a CAST pool. Its only position is added by the Fee Hook when the token graduates. |
An ERC-20 buy without enough allowance or balance reverts in the paired asset's own contract, with that asset's error.