Skip to content

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, pairGross is 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.
quote.ts
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`);
FieldMeaning
totalFeeBpsBot Tax plus the buy or sell fee, in basis points
botTaxBps, tradeFeeBpsThe two parts of that total
pairFeeThe fee charged in the paired asset
toCast, toCreator, toHoldersHow pairFee is split
botTaxThe part of pairFee that is Bot Tax
burnRateShare of the token side burned, scaled by 1e8
pairAfterFeeBuy: 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 exactly amountIn as value.
  • An ERC-20 such as USDG or a test stock: send no value, and approve the router for amountIn first. 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.

router.ts
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 });
}
buy.ts
// `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.

buy-with-permit.ts
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.

sell.ts
// 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 });
}
Other routers pay the full sell fee, and can't buy capped tokens
Only the CAST Router can name the trader to the Fee Hook. A sell through Uniswap's Universal Router or any other router is treated as a wallet with no Hold Streak. See Hold Streak. While a token's max wallet applies, the hook also refuses buys from any router but the CAST Router, with 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 Swap for each curve trade, but with zero amounts. The trade itself is in the hook's CurveTrade event. 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:

Errors you may see
// 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();
ErrorWhat to tell the user
ExpiredThe quote is too old. Get a fresh one and try again.
TooLittleReceivedThe price moved past your slippage. Try again or allow more slippage.
WrongValueThe ETH sent doesn't match. Send exactly amountIn for an ETH pair, and nothing for an ERC-20 pair.
NotCastTokenThat address isn't a CAST token: its pool doesn't use the CAST Fee Hook. The router refuses it before moving any funds.
MaxWalletExceededThis would take the receiving wallet over the token's max wallet. Try a smaller amount.
CreatorLockActiveThe creator's tokens are still locked until the given time.
BuyNeedsCastRouterThis token's max wallet still applies, so only the CAST Router can buy it. Buy through the CAST Router; selling works through any router.
CurveIsExactInputOnlyUntil this token graduates, only trades with a fixed amount in work. Use the CAST Router, or exact input in your router.
SellExceedsCurveThe curve can only buy back as many tokens as it has sold so far. Sell a smaller amount.
LiquidityLockedForeverLiquidity in a CAST pool can never be removed.
OnlyGraduationAddsLiquidityNobody 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.

Popular
  • QuickstartGet started · Set up a wallet, get free test funds and buy your first CAST token.
  • Launch a tokenGet started · Pick a pair and a template, check the grade and seal your rules in one transaction.
  • Fees and Bot TaxConcepts · The three fees a trade can pay, who receives them, and a worked example.
  • Network and contractsDevelopers · Chain settings, contract addresses, pairs and protocol constants.
  • RisksTrust and safety · What sealed rules protect you from, and what they don't.

Connect wallet

New to wallets? Start here.
By connecting, you agree to the Terms and Privacy Policy.