Integrating with Ballast on Robinhood Chain
If you built swap calldata against a Ballast pool with the stock Uniswap SDK and it reverted on the buy — before a sell was ever attempted — that isn't a token problem. It's a router encoding difference specific to this chain. This page is the fix: the exact struct shape, worked examples, and a runnable repro you can check against a live pool yourself rather than take our word for it.
BallastToken.sol yourself. This page is about the chain's router, not the token.The gotcha
Robinhood Chain's deployed UniversalRouter is a modified fork. Its v4 swap params carry an extra minHopPriceX36 field that the stock @uniswap/* SDKs don't know about. Calldata built by an unmodified SDK omits the field entirely, which shifts every byte after it — the fork's ABI decoder reads garbage and reverts. Two other router-lookalike addresses also exist on this chain; only one carries the matching verified fork source (see below).
The struct shape
minHopPriceX36 is a fixed-point ×1036 minimum execution price per hop. Set it to 0 to disable it and rely on amountOutMinimum for slippage instead — that's what Ballast's own app does. It has two different shapes depending on hop count, which is the actual trap (one field name, two types):
Single-hop — ExactInputSingleParams
What a graduated token/WETH pool needs. The field sits after amountOutMinimum, before hookData.
{
poolKey: { currency0, currency1, fee, tickSpacing, hooks },
zeroForOne: boolean,
amountIn: uint128,
amountOutMinimum: uint128,
minHopPriceX36: uint256, // <-- the fork field. 0 = disabled.
hookData: bytes,
}Multi-hop — ExactInputParams
Ballast's own app doesn't need this (graduated pools are single-hop token/WETH), but the chain's router supports it. Here the field is uint256[], and it sits third — after path, before amountIn — not trailing like the single-hop shape:
{
currencyIn: address,
path: PathKey[],
minHopPriceX36: uint256[], // <-- length 0 (disabled) or exactly path.length,
// else the router reverts InvalidHopPriceLength
amountIn: uint128,
amountOutMinimum: uint128,
}SETTLE_ALL pays from msgSender, always
SETTLE_ALL pulls the input currency from whoever called execute() — unconditionally, via Permit2. It does not pay from the router's own balance. If you need the router to fund the input itself (e.g. right after a WRAP_ETH that leaves WETH sitting in the router), use plain SETTLE with payerIsUser=false instead — that's how a native-ETH buy avoids a Permit2 pull entirely. Mixing these up either fails a buy that should be gasless-to-approve, or tries to pull funds from a wallet that never approved anything.
Working examples
Native-ETH buy — wrap and swap in one execute() call, no separate wrap transaction and no Permit2 approval (the router funds the swap from its own just-wrapped balance):
import { concatHex, encodeAbiParameters, toHex } from "viem";
const CMD_WRAP_ETH = "0x0b";
const CMD_V4_SWAP = "0x10";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const CONTRACT_BALANCE = 1n << 255n;
// actions: [SWAP_EXACT_IN_SINGLE (0x06), SETTLE (0x0b), TAKE_ALL (0x0f)]
const swapParams = encodeAbiParameters([exactInputSingleParamsAbi], [{
poolKey,
zeroForOne: false, // buying: WETH (currency1) -> token (currency0)
amountIn, // wei, native ETH you're sending as msg.value
amountOutMinimum,
minHopPriceX36: 0n, // disabled; slippage enforced by amountOutMinimum
hookData: "0x",
}]);
const wrap = encodeAbiParameters(
[{ type: "address" }, { type: "uint256" }],
[ADDRESS_THIS, CONTRACT_BALANCE],
);
const settle = encodeAbiParameters( // payerIsUser=false: router pays
[{ type: "address" }, { type: "uint256" }, { type: "bool" }],
[WETH_ADDRESS, CONTRACT_BALANCE, false],
);
const takeAll = encodeAbiParameters(
[{ type: "address" }, { type: "uint256" }],
[tokenAddress, amountOutMinimum],
);
const v4Input = encodeAbiParameters(
[{ type: "bytes" }, { type: "bytes[]" }],
[concatHex(["0x06", "0x0b", "0x0f"]), [swapParams, settle, takeAll]],
);
await router.execute(
concatHex([CMD_WRAP_ETH, CMD_V4_SWAP]),
[wrap, v4Input],
deadline, // a TIMESTAMP — this chain's blocks are ~100ms
{ value: amountIn },
);Sell — token to native ETH, pulled from your wallet via Permit2, unwrapped on the way out:
const CMD_UNWRAP_WETH = "0x0c";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const swapParams = encodeAbiParameters([exactInputSingleParamsAbi], [{
poolKey,
zeroForOne: true, // selling: token (currency0) -> WETH (currency1)
amountIn,
amountOutMinimum,
minHopPriceX36: 0n,
hookData: "0x",
}]);
const settleAll = encodeAbiParameters( // pulls from msgSender, always
[{ type: "address" }, { type: "uint256" }],
[tokenAddress, amountIn],
);
const take = encodeAbiParameters( // 0 = OPEN_DELTA, full credit to router
[{ type: "address" }, { type: "address" }, { type: "uint256" }],
[WETH_ADDRESS, ADDRESS_THIS, 0n],
);
const v4Input = encodeAbiParameters(
[{ type: "bytes" }, { type: "bytes[]" }],
[concatHex(["0x06", "0x0c", "0x0e"]), [swapParams, settleAll, take]],
);
const unwrap = encodeAbiParameters(
[{ type: "address" }, { type: "uint256" }],
[MSG_SENDER, amountOutMinimum],
);
// requires two one-time Permit2 approvals first: token -> Permit2, Permit2 -> router
await router.execute(
concatHex([CMD_V4_SWAP, CMD_UNWRAP_WETH]),
[v4Input, unwrap],
deadline,
);Verify it yourself
Don't take this page's word for it. Three independent ways to check:
- Runnable repro —
contracts/test/RouterEncodingRepro.t.solforks the chain and executes both calldata shapes against a real graduated pool: the shape above succeeds on buy and sell; standard SDK-shaped calldata (the field omitted) reverts on the buy, before a sell is ever attempted. Clone the repo and runforge test --match-path "test/RouterEncodingRepro.t.sol"against your own fork. - Canonical source —
web/lib/robinhoodRouter.tsandweb/lib/swap.tsare what Ballast's own app signs with, byte for byte — copy them directly rather than re-deriving from this page. - On-chain proof — a separate, earlier proof (
contracts/script/ProveSwapMainnet.s.sol) ran quote / simulate / real-broadcast against a live pool and got the identical output all three ways, confirming the fork router decodes this shape correctly.
Router address: 0x8876789976dEcBfCbBbe364623C63652db8C0904 — verified fork source on Blockscout. Two look-alike router addresses exist on this chain; re-verify independently before sending value, don't trust an address because a page printed it.