DEV Community

BlazePhoenix
BlazePhoenix

Posted on

The swap with no route in it: 196 bytes of calldata, and the solver runs inside the transaction

There are two ways to execute a routed swap: send the route, or send the question. One of them has a gap between "the route was good" and "the route runs". The other doesn't.
This is a byte-level look at both, using the public contract source and a real route read from Base.

Entry A: the route is calldata

The Solver contract exposes findBestRoutePlan(tokenIn, tokenOut, amountIn) as a view function. Anyone can run it with a free eth_call. Here it is against Base through a public node (the Solver address is the one the SDK pins for generation 1.0.0):

import { createPublicClient, http, parseUnits, size, type Address } from 'viem';
import { SOLVER_ABI, EMBEDDED_DEPLOYMENTS, encodeSwapExactIn, encodeSwapBestExactIn, routeFingerprint } from '@blazephoenix/sdk';

const RPC = process.env.BASE_RPC_URL ?? 'https://mainnet.base.org';
const WETH: Address = '0x4200000000000000000000000000000000000006';
const USDC: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const me: Address = '0x000000000000000000000000000000000000dEaD';
const solver = EMBEDDED_DEPLOYMENTS.versions.find((v) => v.version === '1.0.0')!.chains['8453'].solver as Address;

const client = createPublicClient({ transport: http(RPC) });
const block = await client.getBlockNumber();
const read = () => client.readContract({ address: solver, abi: SOLVER_ABI, functionName: 'findBestRoutePlan',
  args: [WETH, USDC, parseUnits('1', 18)], blockNumber: block });

// "Entry A": the Solver is a view function, so the plan comes out of a free eth_call
const plan = await read();
console.log('solver', solver, '@ block', block);
console.log('best route: hops', plan.best.hops.length, '| legs', plan.best.hops.reduce((n, h) => n + h.legs.length, 0));
console.log('same block, second call, identical route:', routeFingerprint(plan.best) === routeFingerprint((await read()).best));

// What travels in the transaction bytes
const deadline = BigInt(Math.floor(Date.now() / 1000) + 120);
const A = encodeSwapExactIn(plan.best, parseUnits('1', 18), 2_324_000_000n, me, deadline);
const B = encodeSwapBestExactIn(WETH, USDC, parseUnits('1', 18), 2_324_000_000n, me, deadline);
console.log('swapExactIn      calldata:', size(A), 'bytes (route + 5 legs inside)');
console.log('swapBestExactIn  calldata:', size(B), 'bytes (tokens, amount, minOut, recipient, deadline)');
Enter fullscreen mode Exit fullscreen mode
solver 0xB1902990260975dD4C89ad74B1f317bc100CB830 @ block 53223035n
best route: hops 1 | legs 5
same block, second call, identical route: true
swapExactIn      calldata: 2308 bytes (route + 5 legs inside)
swapBestExactIn  calldata: 196 bytes (tokens, amount, minOut, recipient, deadline)
Enter fullscreen mode Exit fullscreen mode

(Run it as npx tsx solve.mts after npm i @blazephoenix/sdk viem tsx. The 2,308 bytes are not mysterious: swapExactIn carries a Route struct holding the hops and, for each leg, the pool address, hooks address, venue kind, fee, tick spacing, direction, amounts and an aux id.)

So with entry A, the flow is: read the plan from the Solver at block N, send it inside a transaction that lands at block N+k. In between, pools move. The contract can't re-plan for you; what it can do is refuse to settle a plan that no longer delivers (floors, minimums). That's the "quote-to-execution seam".

Entry B: the route is computed in the swap

In the 2.0.0 Router source (swapBestExactIn), there is no route parameter at all:

function swapBestExactIn(
    address tokenIn, address tokenOut, uint256 amountIn,
    uint256 userMinOut, address recipient, uint256 deadline
) external whenLive nrEntrant returns (uint256) {
    if (amountIn == 0 || amountIn > type(uint128).max) revert RouterE(3);
    if (userMinOut == 0) revert RouterE(10);
    RoutePlan memory plan = _solve(tokenIn, tokenOut, amountIn);
    // ...both ends of the plan are checked against tokenIn / tokenOut, then the input is pulled...
    return this.selfExecutePrePulled(plan.best, received, userMinOut, recipient, deadline, msg.sender);
}
Enter fullscreen mode Exit fullscreen mode

The Router calls the same Solver view function, inside the transaction, against the state the swap is about to trade in. The source comment says what that buys: "no quote-vs-execution seam at all — solve and execution read the same block state, atomically." It also says what it does not buy: userMinOut stays mandatory, because "in-tx solving removes plan staleness, not sandwich exposure — the tx can still be ordered behind an attacker's."

Three engineering details I found worth the read:

  • The solve has a fixed gas budget. SOLVE_GAS = 14_700_000 is forwarded to the Solver; if the search exhausts it, the Router reverts with RouterE(17) instead of dying in an out-of-gas for the whole transaction. The test file for this entry says why a cap and not an optimisation: how many pools on a route's reach are fragmented is an attacker's choice, so no optimisation can bound it, "a fixed budget that reverts can."
  • Both ends of the solved plan are checked. The plan must start in tokenIn and end in tokenOut, or the call reverts with RouterE(3). The Solver is immutable, but this is the one door where the route isn't the caller's, so the Router double-checks.
  • A self-call re-encodes memory to calldata. The Solver returns Route memory, while the hot execution path takes Route calldata, so the plan crosses through selfExecutePrePulled, which only the Router itself may call (RouterE(1) otherwise).

The property the tests pin down

The header of the Router's solve-budget test states the invariant in one sentence: entry B executes exactly the plan entry A quotes for the same state, or it reverts. Never a different plan. There is a fuzz test for that equality and one for the budget cap never degrading a plan ("a capped solve returns the uncapped plan or nothing").

The trade-off, stated plainly

The source comments are candid: entry B "costs the solve on top of execution" (they describe it as cheap on L2s), and the calldata-plan entry points "remain the gas-lean path". Entry B buys a smaller transaction, no stale plan and nothing for a front-end to tamper with in the route; entry A buys lower gas. Both require a real minOut.

Status

This is where I have to be careful. Version 2.0.0, the generation that has swapBestExactIn, is listed as pending in the deployments registry; its addresses are zero there today. The Base output above comes from the 1.0.0 deployment the SDK pins, which has the view-function Solver (entry A). What I'm describing for entry B is the public source (contracts) and its tests, not something you can call on a chain today. In the SDK it is mode: 'best', flagged 2.x-only. The encodeSwapBestExactIn helper above only builds the bytes.

Background on on-chain solving: DEX_ROUTING in the repo and the on-chain quoting article. Everything starts at blazephoenix.xyz.

BlazePhoenix is pre-launch and an independent external audit is scheduled. Source reading, not advice.

Top comments (0)