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)');
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)
(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);
}
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_000is forwarded to the Solver; if the search exhausts it, the Router reverts withRouterE(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
tokenInand end intokenOut, or the call reverts withRouterE(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 takesRoute calldata, so the plan crosses throughselfExecutePrePulled, 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)