When a contract reverts it returns bytes. Those bytes use exactly the same ABI encoding as a function call: a 4-byte selector, then 32-byte words for each argument.
The selector
The selector is the first 4 bytes of keccak256 of the error signature. Three are special: 0x08c379a0 is Error(string) from require with a message, 0x4e487b71 is Panic(uint256) from the compiler, and anything else is a custom error.
$ cast sig "ERC20InsufficientBalance(address,uint256,uint256)" 0xe450d38c $ cast 4byte 0xe450d38c ERC20InsufficientBalance(address,uint256,uint256)
The arguments
After the selector, each static argument fills one 32-byte word: an address is left-padded with zeros, a uint256 is big-endian. Decode with the same signature you would use for calldata.
import { decodeErrorResult, parseAbi } from "viem"; const abi = parseAbi(["error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)"]); const { errorName, args } = decodeErrorResult({ abi, data: revertData }); // errorName: "ERC20InsufficientBalance" // args: [sender, balance, needed]
No ABI? Paste the full revert data into the EVM selector lookup. It knows the OpenZeppelin v5 errors and the compiler panic codes.
Panics are numbered
Panic(0x11) is arithmetic overflow, 0x12 division by zero, 0x32 an out-of-bounds array index. Since Solidity 0.8 these replace silent wraparound, so a panic usually means a real logic bug, not a user error.