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.

Ballast token contracts have no owner, no pause, no blacklist, no transfer hook, and no mint function — see why scanners flag us for the full breakdown, or read 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.sol forks 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 run forge test --match-path "test/RouterEncodingRepro.t.sol" against your own fork.
  • Canonical source — web/lib/robinhoodRouter.ts and web/lib/swap.ts are 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.