DEV Community

BlazePhoenix
BlazePhoenix

Posted on

A preflight for on-chain swaps: four rehearsals, three refusals, zero transactions

Many swaps that fail on-chain, and burn gas doing it, would have shown the same failure in a free eth_call first.
Here is a ~30-line preflight for swaps built with the BlazePhoenix SDK, run through four situations. Three get refused. None of them cost a wei.

The idea

A swap transaction is a function call. A node can execute any function call against current state without mining it, and tell you exactly what would have happened: the return value, or the revert. Many wallets do a version of this under the hood; bots and agents often skip it. Put it in front of the signing step and a whole class of mistakes turns into a log line.

The SDK already does this in execute() (approve → simulate → swap, simulation on by default). Below I pull it apart so you can see what each layer catches.

Layer 1: the SDK's simulate()

const plan = await blaze.buildSwap({ chain: 'base', tokenIn: 'WETH', tokenOut: 'USDC', amount: '0.5',
                                     recipient: me, from: me, slippageBps: 50 });
console.log('steps to send     :', plan.steps.length, '(approve + swap)');
console.log('simulate(plan)    :', JSON.stringify(await blaze.simulate(plan, me)));
Enter fullscreen mode Exit fullscreen mode
steps to send     : 2 (approve + swap)
simulate(plan)    : {"ok":false,"error":{"contract":"unknown","name":"allowance","reason":"the Router is not approved for tokenIn yet — send plan.approval.tx first"}}
Enter fullscreen mode Exit fullscreen mode

Passing from makes buildSwap read the allowance, and the plan then has two steps. simulate() notices the Router isn't approved and says so before it even spends an RPC call on the swap. Easy mistake, clear message.

Layer 2: rehearse from a wallet that isn't funded yet

An approval and a balance don't exist until they exist, so how do you test the swap step in advance? eth_call accepts a state override: write a fake balance and allowance into storage for that one call only. For WETH on Base (WETH9 layout), balances sit in storage slot 3 and allowances in slot 4:

import { getAddress, createPublicClient, http, keccak256, encodeAbiParameters, parseUnits, type Address, type Hex } from 'viem';
import { BlazePhoenix, decodeBlazeError, type SwapPlan } from '@blazephoenix/sdk';

const RPC = process.env.BASE_RPC_URL ?? 'https://mainnet.base.org';
const WETH: Address = '0x4200000000000000000000000000000000000006';
const me = getAddress('0x00000000000000000000000000000000000a11ce');   // holds nothing on-chain
const blaze = new BlazePhoenix({ rpc: { base: RPC } });
const client = createPublicClient({ transport: http(RPC) });
const req = { chain: 'base', tokenIn: 'WETH', tokenOut: 'USDC', amount: '0.5', recipient: me, slippageBps: 50 } as const;

const slot = (k: Address, s: bigint) => keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [k, s]));
const word = (n: bigint) => ('0x' + n.toString(16).padStart(64, '0')) as Hex;
const rehearse = (p: SwapPlan, weth: bigint) => client.call({
  account: me, to: p.tx.to, data: p.tx.data,
  stateOverride: [{ address: WETH, stateDiff: [
    { slot: slot(me, 3n), value: word(weth) },                                                        // balanceOf[me]
    { slot: keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'bytes32' }],                 // allowance[me][router]
                      [p.tx.to, slot(me, 4n)])), value: word(weth) },
  ] }],
});

async function preflight(label: string, p: SwapPlan, weth = parseUnits('1', 18)) {
  try { await rehearse(p, weth); console.log(label.padEnd(26), '-> passes, nothing reverted'); }
  catch (e) {
    const d = decodeBlazeError(e);
    console.log(label.padEnd(26), '-> BLOCKED:', d && d.contract !== 'unknown'
      ? `${d.contract}E(${d.code}) ${d.reason}`
      : 'reverted, not a protocol error: ' + (e as Error).message.split('\n')[0].slice(0, 80));
  }
}

await preflight('fresh plan', await blaze.buildSwap(req));
await preflight('deadline 60 s in the past', await blaze.buildSwap({ ...req, deadline: BigInt(Math.floor(Date.now() / 1000) - 60) }));
await preflight('wallet holds only 0.25 WETH', await blaze.buildSwap(req), parseUnits('0.25', 18));
Enter fullscreen mode Exit fullscreen mode

The override trick needs an RPC that supports state overrides (the public Base endpoint I used does). Run it with npm i @blazephoenix/sdk viem tsx and npx tsx preflight.mts.

What it printed

fresh plan                 -> passes, nothing reverted
deadline 60 s in the past  -> BLOCKED: RouterE(4) deadline passed
wallet holds only 0.25 WETH -> BLOCKED: reverted, not a protocol error: Execution reverted with reason: BPC:transferFrom.
Enter fullscreen mode Exit fullscreen mode
  • Fresh plan: passes.
  • Expired deadline: RouterE(4). In the 2.0.0 Router source the deadline is compared with block.timestamp (if (block.timestamp > deadline) revert RouterE(4)), and decodeBlazeError turns the raw revert into a readable reason. This is the classic "the transaction sat in the mempool too long" case, caught before sending.
  • Underfunded wallet: the revert isn't a BlazePhoenix error at all. It's the token transfer failing (BPC:transferFrom, the Router's transfer helper). The decoder honestly says "not a protocol error" rather than inventing one.

SDK buildSwap defaults to a 120-second deadline (configurable between 10 and 3,600 seconds), which is why I had to pass an explicit past deadline to provoke the refusal.

A preflight you can paste in front of a signer

const log = console.log;
async function safeToSign(p: SwapPlan): Promise<boolean> {
  const sim = await blaze.simulate(p, me);            // allowance gate + real eth_call
  if (!sim.ok) return log('refused', sim.error), false;
  if (sim.amountOut! < p.minOut) return log('output below the signed minimum'), false;
  return true;                                        // only now hand p.steps to the wallet
}
Enter fullscreen mode Exit fullscreen mode

Pair it with a calldata check (verifySwapExactIn compares the bytes with what you asked for) and the order becomes: quote → build → verify → simulate → sign. Each step is free and each can say no.

What a simulation cannot tell you

I would be lying if I sold this as a guarantee:

  • It's a snapshot. The dry-run executes against one block's state. Your real transaction lands later, after other transactions. A rehearsal can pass and the swap can still revert or fill worse by then. The thing that protects you at execution time is the minimum output you signed, which the Router enforces on-chain; a preflight can't replace it. (Background on the on-chain floors: the iron floor article.)
  • It can't see ordering. A simulation has no sandwich in it.
  • State overrides are a lie by design. The "wallet" above holds a faked balance. That's right for rehearsing the logic and wrong as proof that a real wallet is funded; check the real balance separately.

So treat it as a seatbelt, not a force field: it catches mistakes, not markets.

Project: blazephoenix.xyz · SDK: @blazephoenix/sdk · contracts: gitlab.com/blazephoenix-group/dex.

BlazePhoenix is pre-launch and an independent external audit is scheduled. The outputs above are a dry-run sample against Base and not advice.

Top comments (0)