A swap transaction is just bytes. If one field is wrong (the recipient, the minimum output) your wallet will happily sign it.
Here is a small, runnable pattern that makes a program refuse bad bytes before anything gets signed.

Verify-before-sign flow

The idea in one sentence

Don't trust the calldata a service hands you. Decode it and compare it field by field with what you asked for.

The BlazePhoenix SDK (@blazephoenix/sdk) ships two helpers for this: encodeSwapExactIn and verifySwapExactIn. BlazePhoenix is an on-chain DEX aggregator: the Quoter you call is a contract, and the quote is an eth_call on your RPC, with no API key and no signup. The route is computed inside the transaction that executes it (how it works).

The script

import { BlazePhoenix, encodeSwapExactIn, verifySwapExactIn, minOutFor } from '@blazephoenix/sdk'; const blaze = new BlazePhoenix({ rpc: { base: 'https://mainnet.base.org' } }); const q = await blaze.quote({ chain: 'base', tokenIn: 'WETH', tokenOut: 'USDC', amount: '1' }); const me = '0x000000000000000000000000000000000000dEaD'; const attacker = '0x1111111111111111111111111111111111111111'; const deadline = BigInt(Math.floor(Date.now() / 1000) + 120); const minOut = minOutFor(q.amountOut, 50, q.preview.ironFloor); // 0.50 % under the net quote // what I want, written down independently of the bytes const want = { tokenIn: q.tokenIn, tokenOut: q.tokenOut, amountIn: q.amountIn, recipient: me, deadline, minOutAtLeast: minOut, route: q.route }; const good = encodeSwapExactIn(q.route, q.amountIn, minOut, me, deadline); console.log('honest bytes ->', verifySwapExactIn(good, want).userMinOut === minOut ? 'accepted' : '?'); // same route, but a dust minimum and somebody else's address const evil = encodeSwapExactIn(q.route, q.amountIn, 1n, attacker, deadline); try { verifySwapExactIn(evil, want); } catch (e) { console.log(e.code, '\n', e.message); } 

What it printed

I ran this against Base through a public RPC (read-only, nothing is sent or signed):

honest bytes -> accepted calldata_mismatch Quoter calldata refused — it does not execute what you asked for: recipient 0x1111111111111111111111111111111111111111 ≠ 0x000000000000000000000000000000000000dEaD; userMinOut 1 < required 2324691258 

Two independent checks fired at once: the recipient differs, and the minimum output is below the one I required (2,324.691258 USDC for that block's quote). The error is a BlazeError with the stable code calldata_mismatch, so you can branch on it without parsing text.

The two refused fields

Why minOutFor takes a floor

export function minOutFor(netOut: bigint, slippageBps: number, floor: bigint): bigint { if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 5_000) throw new BlazeError('bad_request', /* … */); const bySlip = netOut - (netOut * BigInt(slippageBps)) / 10_000n; return bySlip > floor ? bySlip : floor; } 

That is the SDK source. Your slippage can push the minimum up toward the quote; it can never take it below the protocol floor (q.preview.ironFloor). In the run above the floor was 2,058.415872 USDC and the 50 bps minimum was 2,324.691258.

Where this fits

  • Wallet-side apps and bots: call verifySwapExactIn on whatever bytes you are about to sign, including bytes from a server you don't control.
  • AI agents: an agent that relays "here is your transaction" should run the same check first. The MCP server is read-only and never signs; it returns calldata for you to review.
  • CI: put the tamper test in your test suite. If a refactor stops refusing evil, the build fails.

Fields compared by verifySwapExactIn

Run it

npm i @blazephoenix/sdk viem tsx BASE_RPC_URL=https://your-base-node npx tsx verify.mts 

Source, docs and the whitepaper are linked from blazephoenix.xyz; machine-readable index at /llms.txt.

Status, so nobody is misled: BlazePhoenix is pre-launch and an independent external audit is scheduled. Quote numbers above are one block's sample and move constantly. Nothing here is financial advice.