DEV Community

Cover image for Your swap reverted with 36 bytes of hex. Here is the dictionary (viem, 30 lines)
BlazePhoenix
BlazePhoenix

Posted on

Your swap reverted with 36 bytes of hex. Here is the dictionary (viem, 30 lines)

execution reverted. That is all most wallets tell you. The chain told them more: 36 bytes, and every one of them means something.

0x62b0a386
0000000000000000000000000000000000000000000000000000000000000005
Enter fullscreen mode Exit fullscreen mode

Four bytes of name, 32 bytes of argument. Below: how to get those bytes out of a failed eth_call, how to turn them into a sentence, and why the sentence is often good news.

Anatomy of 36 bytes of revert data

What a custom error looks like on the wire

A Solidity custom error is encoded like a function call: the first four bytes are the start of keccak256 of its signature, followed by the ABI-encoded arguments. BlazePhoenix's contracts declare RouterE(uint16 code), SolverE(uint16), QuoterE(uint16) and HubE(uint16) (you can see the first in src/BlazePhoenixRouter.sol). RouterE(5) is therefore the selector of RouterE(uint16) plus the number 5 padded to 32 bytes. The generic Error(string) and Panic(uint256) have fixed selectors, 0x08c379a0 and 0x4e487b71; anything else is a custom error and needs the declaring contract's ABI to be read.

The project publishes that dictionary at https://blazephoenix.xyz/api/abi?contract=errors (the response carries the source revision it was generated from; background in learn/reading-a-revert).

Step 1: the dictionary, and what each code means

// lib.mjs — turn revert bytes into a name. Node 20+, viem.
import { decodeErrorResult } from 'viem';

// The dictionary: the project publishes its errors ABI and names the source revision it came from.
const res = await fetch('https://blazephoenix.xyz/api/abi?contract=errors');
export const { abi, revision, generation } = await res.json();

// What each RouterE code means: Whitepaper v2.3.0, section 11.7, copied by hand.
const ROUTER = {
  1: 'unauthorised control call', 2: 'paused', 3: 'bad input (zero address, hop or leg count, discontinuity)',
  4: 'deadline passed', 5: 'output below a floor (per-leg, per-hop, route or userMinOut)',
  6: 'callback not authorised', 7: 're-entrancy', 8: 'swap failed (zero output, unknown kind, fee >= amount)',
  9: 'V4 hook alters deltas', 10: 'userMinOut == 0 with amountIn > 0',
  13: 'fee-on-transfer token on a concentrated-only route', 14: 'rescue not queued or inside its 48-hour delay',
  15: 'settled without paying the protocol fee', 16: 'fee paid twice on an anchored route',
};

export function explain(data) {
  const sel = data.slice(0, 10);
  if (sel === '0x08c379a0') return { kind: 'Error(string)', note: 'a require() with a message' };
  if (sel === '0x4e487b71') return { kind: 'Panic(uint256)', note: 'a compiler-inserted check' };
  try {
    const e = decodeErrorResult({ abi, data });
    const code = Number(e.args?.[0]);
    return { kind: e.errorName, code, meaning: e.errorName === 'RouterE' ? (ROUTER[code] ?? 'unassigned') : undefined };
  } catch {
    return { kind: 'UNKNOWN ' + sel, note: 'not in this ABI: do not guess' };
  }
}
Enter fullscreen mode Exit fullscreen mode

The meaning column is the table of §11.7 "The Router's refusals" in the whitepaper v2.3.0 (DOI), copied by hand. Codes 11 and 12 are unassigned there, and the paper says why that matters: it adds that a revert surface this enumerable is what lets the paper's tests judge every row by one rule: settle, or refuse with a selector of the project's own.

Step 2: selectors are just hashes, check them yourself

import { keccak256, toHex, encodeErrorResult } from 'viem';
import { abi, revision, generation } from './lib.mjs';
import { explain } from './lib.mjs';

console.log('errors ABI: revision', revision.slice(0, 7), '· generation', generation);
for (const sig of ['RouterE(uint16)', 'SolverE(uint16)', 'QuoterE(uint16)', 'HubE(uint16)', 'Error(string)', 'Panic(uint256)'])
  console.log(sig.padEnd(17), keccak256(toHex(sig)).slice(0, 10));

const five = encodeErrorResult({ abi, errorName: 'RouterE', args: [5] });
console.log('\nbytes of RouterE(5):', five);
console.log(explain(five));
console.log(explain('0xdeadbeef'));
Enter fullscreen mode Exit fullscreen mode
errors ABI: revision 6323963 · generation 2.0.0
RouterE(uint16)   0x62b0a386
SolverE(uint16)   0x3d0ccdde
QuoterE(uint16)   0x765afc20
HubE(uint16)      0x9a3fb915
Error(string)     0x08c379a0
Panic(uint256)    0x4e487b71

bytes of RouterE(5): 0x62b0a3860000000000000000000000000000000000000000000000000000000000000005
{
  kind: 'RouterE',
  code: 5,
  meaning: 'output below a floor (per-leg, per-hop, route or userMinOut)'
}
{ kind: 'UNKNOWN 0xdeadbeef', note: 'not in this ABI: do not guess' }
Enter fullscreen mode Exit fullscreen mode

Nothing in that output needs trust. The hashes come from your local keccak256, the table comes from a paper you can open, and an unknown selector is reported as unknown instead of being matched to the nearest name.

Step 3: getting the bytes out of a failed eth_call

viem wraps an RPC error several levels deep. walk() returns the first level that carries hex data:

// extract.mjs — get the revert bytes out of a failed eth_call with viem.
// The RPC here is a throwaway local mock that answers every eth_call with a revert,
// so the exact error path of viem runs without a network or a chain.
import http from 'node:http';
import { createPublicClient, http as vhttp, RawContractError, BaseError, encodeErrorResult } from 'viem';
import { base } from 'viem/chains';
import { explain } from './lib.mjs';

const abi = [{ type: 'error', name: 'RouterE', inputs: [{ name: 'code', type: 'uint16' }] }];
const data = encodeErrorResult({ abi, errorName: 'RouterE', args: [5] });

const srv = http.createServer((req, res) => {
  let b = ''; req.on('data', c => (b += c)); req.on('end', () => {
    const { id } = JSON.parse(b);
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify({ jsonrpc: '2.0', id, error: { code: 3, message: 'execution reverted', data } }));
  });
}).listen(0);
const url = `http://127.0.0.1:${srv.address().port}`;

const client = createPublicClient({ chain: base, transport: vhttp(url) });
try {
  await client.call({ to: '0x0000000000000000000000000000000000000001', data: '0x12345678' });
} catch (err) {
  // viem wraps the RPC error several levels deep; walk() returns the first level that carries hex data
  const hit = err.walk(e => typeof e?.data === 'string' && e.data.startsWith('0x'));
  console.log('revert bytes:', hit.data);
  console.log(explain(hit.data));
}
srv.close();
Enter fullscreen mode Exit fullscreen mode
revert bytes: 0x62b0a3860000000000000000000000000000000000000000000000000000000000000005
{
  kind: 'RouterE',
  code: 5,
  meaning: 'output below a floor (per-leg, per-hop, route or userMinOut)'
}
Enter fullscreen mode Exit fullscreen mode

Honesty note: the RPC in that script is a throwaway local mock that answers every eth_call with a revert, so what ran is the real viem error path, not a real BlazePhoenix revert. Point client at your own RPC and a quote or a simulated swap that the contract refuses will land in the same catch.

viem error chain down to the hex data

Reading the verdict: three codes you will meet first

Code Whitepaper §11.7 What to change
RouterE(5) output below a floor: per-leg, per-hop aggregate, route floor or userMinOut size, slippage or route; the floor did its job
RouterE(4) deadline passed re-quote, sign again
RouterE(10) userMinOut == 0 with amountIn > 0 the Router refuses a swap with no minimum on every door (INV-6)

RouterE(5) covers four different floors under one code, so the code tells you a floor stopped the trade, not which. That is a limit of the code, not of the decoder; the learn article on refused trades explains why a refusal at a floor beats a bad fill.

The three first-contact codes and the action each one asks for

Check it yourself

curl -s 'https://blazephoenix.xyz/api/abi?contract=errors' | jq '.revision, .abi[].name'
cast sig 'RouterE(uint16)'          # Foundry: 0x62b0a386
node selectors.mjs && node extract.mjs
Enter fullscreen mode Exit fullscreen mode

Status

BlazePhoenix is pre-launch; the V2 Router whose refusals §11.7 lists is not deployed (§19), and an independent external audit is scheduled before launch. The decoder is generic: it works on whatever ABI the endpoint returns for the revision it names.

Sources: learn/reading-a-revert, whitepaper v2.3.0 §11.7 and INV-6, src/BlazePhoenixRouter.sol, the errors ABI endpoint. Learn and whitepaper text: CC BY 4.0, credit BlazePhoenix, https://blazephoenix.xyz.

Top comments (0)