Deep Dives β Errors: The Complete Picture
Difficulty: Intermediate
Estimated reading time: ~90 minutes | Exercises: ~2 hours
π Table of Contents
EVM Failure Modes
Error Encoding
Solidity Error Primitives
Error Propagation
- How Errors Travel Up the Call Stack
- Low-Level Calls β Manual Error Handling
- Propagation Through Proxies
- Constructor Reverts
Try/Catch
Decoding & Detection
DeFi Error Patterns
- Multicall Error Strategies
- Error Handling in Flash Loans
- Router & Aggregator Error Bubbling
- Liquidation Bot Patterns
- Build Exercise: ErrorHandler
π‘ EVM Failure Modes
Every failed transaction youβve ever seen on Etherscan ended in one of three ways. They look similar from the outside β βtransaction revertedβ β but at the EVM level, they behave completely differently in terms of gas consumption, returndata, and what information reaches the caller.
π‘ Concept: The Three Ways Execution Fails
Why this matters: When your DeFi transaction fails, the failure mode determines whether you lose all your gas, whether you get an error message, and whether your caller can react to the failure. Understanding these three modes is the foundation for everything else in this deep dive.
The three failure modes:
| REVERT | INVALID | Out-of-gas | |
|---|---|---|---|
| Opcode | REVERT (0xFD) | INVALID (0xFE) | N/A (execution halts) |
| Gas behavior | Refunds remaining gas | Consumes ALL remaining gas | Consumes ALL remaining gas |
| Returndata | Yes β caller receives error data | None β returndata is empty | None β returndata is empty |
| When it happens | require(), revert, custom errors | Handwritten assembly, very old contracts | Insufficient gas, infinite loops, deep recursion |
| State changes | Reverted in current frame | Reverted in current frame | Reverted in current frame |
The critical distinction: REVERT is the only failure mode that gives the caller useful information. The other two are black holes β gas gone, no explanation.
REVERT β the controlled failure:
Caller sends 100,000 gas
βββΊ Sub-call uses 30,000 gas, then hits REVERT
ββ Returndata: error message bytes (forwarded to caller)
ββ State: all changes in this frame rolled back
ββ Gas: 70,000 remaining gas returned to caller
This is what Solidityβs require(), revert, and custom errors compile to. The caller gets its unused gas back and can read the error data to decide what to do.
INVALID β the hard crash:
Caller sends 100,000 gas
βββΊ Sub-call uses 30,000 gas, then hits INVALID (0xFE)
ββ Returndata: empty (nothing to read)
ββ State: all changes in this frame rolled back
ββ Gas: ALL 100,000 consumed β nothing returned
Pre-0.8.0 Solidity compiled assert() to the INVALID opcode. Since 0.8.0, assert uses REVERT with a Panic code instead β so INVALID is now only encountered in handwritten assembly or contracts compiled with very old Solidity versions. Youβll still see it in deployed contracts like early Uniswap V2 or original MakerDAO.
Out-of-gas β the silent death:
Caller sends 100,000 gas
βββΊ Sub-call keeps executing... runs out of gas
ββ Returndata: empty (nothing to read)
ββ State: all changes in this frame rolled back
ββ Gas: ALL consumed β the definition of "out of gas"
No opcode triggers this β execution simply halts when the gas counter hits zero. From the callerβs perspective, it looks identical to INVALID: success = false, no returndata. This makes out-of-gas failures difficult to distinguish from INVALID crashes programmatically.
π» Quick Try:
See all three failure modes in Remix:
contract FailureModes {
// Mode 1: REVERT β controlled, returns data, refunds gas
function failRevert() external pure {
revert("something went wrong");
}
// Mode 2: INVALID β consumes all gas, no returndata
function failInvalid() external pure {
assembly {
invalid()
}
}
// Mode 3: Out-of-gas β consumes all gas, no returndata
function failOutOfGas() external pure {
uint256 i;
while (true) {
i++;
}
}
}
Call each with a gas limit of 100,000. Compare the gas consumed: failRevert will consume much less than the other two. Check the return data in the Remix console β only failRevert returns error bytes.
π Deep Dive: Gas Behavior on Each Failure Mode
Why does gas behavior matter in DeFi? Because it affects the cost of failed transactions β and in protocols like liquidation bots or aggregators, failures are expected and frequent.
REVERT gas accounting in detail:
Transaction gas limit: 200,000
CALL to sub-contract (forwards 150,000 gas)
β
β Sub-contract executes:
β SLOAD β 2,100 gas used
β MSTORE β 3 gas used
β comparison β 3 gas used
β REVERT β 0 gas used (REVERT itself is free)
β βββββββββββββββββ
β Total used: 2,106 gas
β Returned: 147,894 gas (150,000 - 2,106)
β
βββ Caller gets 147,894 gas back
Caller continues execution with remaining gas
Key detail: the REVERT opcode itself costs 0 gas. You only pay for the work done before the revert, plus the memory expansion cost of the returndata. This is why custom errors (small returndata) are cheaper than string errors (larger returndata) β less memory expansion.
INVALID / out-of-gas β the 63/64 rule saves the caller:
Even though INVALID and out-of-gas consume all gas in the sub-call, the caller doesnβt necessarily lose everything. EIP-150 introduced the 63/64 rule: when making a sub-call, at most 63/64 of the remaining gas is forwarded. The caller always retains at least 1/64.
Caller has 128,000 gas remaining
β
β CALL forwards at most 63/64 = 126,000 gas
β Caller retains at least 1/64 = 2,000 gas
β
βββΊ Sub-call hits INVALID β all 126,000 consumed
Caller still has ~2,000 gas to check success and react
This is why a sub-call hitting INVALID doesnβt always kill the entire transaction β the caller retains enough gas to check the return value and potentially continue. But 1/64 isnβt much β if the caller needs to do significant work after the failure (like emitting events or updating storage), it may still run out.
β οΈ Common Mistakes
Mistake 1: Assuming all failures return error data
// WRONG β this only works if the sub-call used REVERT
(bool success, bytes memory data) = target.call(payload);
if (!success) {
// data might be EMPTY if the sub-call hit INVALID or ran out of gas
// Trying to decode it will fail
(string memory reason) = abi.decode(data, (string)); // Reverts on empty data!
}
// CORRECT β check data length first
(bool success, bytes memory data) = target.call(payload);
if (!success) {
if (data.length > 0) {
// Sub-call used REVERT β decode the error
assembly {
revert(add(data, 0x20), mload(data))
}
} else {
// INVALID or out-of-gas β no data to decode
revert("sub-call failed without data");
}
}
Mistake 2: Confusing INVALID with out-of-gas
Both produce success = false with empty returndata. You cannot distinguish them from within the EVM. If your code needs to know which happened, you have to check off-chain (via tracing) or infer from the gas remaining after the call.
π‘ Concept: REVERT Opcode Mechanics
Why this matters: Every Solidity error β require, revert, custom errors, panics β compiles down to the same opcode: REVERT. Understanding exactly what this opcode does gives you the mental model for everything that follows: encoding, propagation, try/catch, and decoding.
What REVERT does at the opcode level:
The REVERT opcode takes two values from the stack:
REVERT(offset, size)
β β
β ββ How many bytes of returndata to send back
ββ Where in memory the returndata starts
It does three things, in order:
- Copies
sizebytes from memory starting atoffsetinto the returndata buffer - Rolls back all state changes made in the current execution frame (storage writes, balance transfers, logs)
- Returns remaining gas to the caller
Thatβs it. The returndata bytes are whatever the contract put in memory before calling REVERT. Solidity puts ABI-encoded error data there β but at the EVM level, itβs just arbitrary bytes.
REVERT vs RETURN β the same mechanics, different outcome:
RETURN(offset, size) β success = true, state changes KEPT, returndata sent
REVERT(offset, size) β success = false, state changes ROLLED BACK, returndata sent
Both opcodes send returndata. Both refund remaining gas. The only difference is whether the frameβs state changes persist. This symmetry is important β it means the returndata mechanism works identically for errors and successful returns.
What βcurrent execution frameβ means:
REVERT only rolls back the current frame β the sub-call that executed it. The calling frame is unaffected and can continue:
Transaction
βββ Frame 0 (your contract)
β βββ SSTORE (slot 1 = 100) β persists (not in the reverted frame)
β β
β βββ CALL to Contract B βββββββΊ Frame 1 (Contract B)
β β βββ SSTORE (slot 5 = 999) β rolled back
β β βββ SSTORE (slot 6 = 888) β rolled back
β β βββ REVERT(0x00, 0x24) β error data sent back
β β
β βββ success = false, returndata = error bytes
β β
β βββ SSTORE (slot 2 = 200) β persists (Frame 0 continues)
β βββ RETURN
Frame 0βs storage writes at slot 1 and slot 2 both persist. Frame 1βs writes are gone. This is why try/catch works β the calling contract can catch a sub-callβs revert without losing its own state.
π» Quick Try:
Verify that state persists in the calling frame after a sub-call reverts:
contract Inner {
function failAfterWork() external pure {
revert("I failed");
}
}
contract Outer {
uint256 public beforeCall;
uint256 public afterCall;
function test(address inner) external {
beforeCall = 1; // This persists
(bool success, ) = inner.call(
abi.encodeWithSignature("failAfterWork()")
);
// success is false, but we're still running
afterCall = 2; // This also persists
}
}
Deploy both, call Outer.test(), then read beforeCall and afterCall β both are set despite the inner call failing.
π Deep Dive: The Returndata Buffer
The returndata buffer is a per-frame memory region that holds the output of the most recent external call. Understanding it is key to understanding error propagation.
How the buffer works:
Frame 0 makes CALL to Frame 1
β
Frame 1 executes REVERT(offset, size)
β ββ copies memory[offset..offset+size] into Frame 0's returndata buffer
β
βββ Frame 0 can now access this data:
β
βββ RETURNDATASIZE β returns the length of the buffer
βββ RETURNDATACOPY β copies buffer bytes into Frame 0's memory
βββ Solidity's abi.decode uses these under the hood
Critical behavior β the buffer is overwritten by every external call:
(bool s1, bytes memory data1) = contractA.call(payload1);
// returndata buffer = data from contractA
(bool s2, bytes memory data2) = contractB.call(payload2);
// returndata buffer = data from contractB
// contractA's data is GONE from the buffer
// BUT: data1 still exists β Solidity copied it to memory
// This is why Solidity returns `bytes memory` β it copies out of
// the volatile buffer into persistent memory immediately
If youβre working in assembly and donβt copy the returndata before making another call, itβs gone. Solidity handles this automatically, but in assembly you must use RETURNDATACOPY before making any subsequent external call.
Returndata and memory expansion costs:
The returndata itself doesnβt cost gas to receive β the caller doesnβt pay for the sub-callβs memory. But when the caller uses RETURNDATACOPY to copy returndata into its own memory, it pays for memory expansion in its own frame. This is why returning huge error strings is wasteful β the caller pays to copy those bytes into memory.
Custom error: revert InsufficientBalance(required, actual)
β 4 bytes selector + 64 bytes params = 68 bytes
String error: revert("Insufficient balance: required X but got Y")
β 4 bytes selector + 32 bytes offset + 32 bytes length +
N bytes string = 100+ bytes
The caller copies all of these bytes into memory. Fewer bytes = less memory expansion = less gas.
π Key Takeaways: EVM Failure Modes
After this section, you should be able to:
- Identify which of the three failure modes (REVERT, INVALID, out-of-gas) occurred given a failed callβs gas consumption and returndata, and explain why two of them are indistinguishable from the callerβs perspective
- Explain why REVERT costs 0 gas itself and why custom errors produce cheaper reverts than string errors (less memory expansion for the returndata)
- Trace a REVERT through nested call frames and explain which state changes persist and which are rolled back
- Describe how the returndata buffer works, why itβs overwritten by every subsequent external call, and what happens if you donβt copy it in assembly before making another call
- Explain how the 63/64 rule (EIP-150) protects the caller from losing all gas when a sub-call hits INVALID or runs out of gas
Check your understanding
- Three failure modes: REVERT returns unused gas and sends returndata (cheapest, most informative). INVALID consumes all forwarded gas and returns nothing. Out-of-gas also consumes all forwarded gas and returns nothing. INVALID and out-of-gas are indistinguishable to the caller β both show success=0 with empty returndata.
- REVERT cost and custom errors: REVERT itself costs 0 gas; the cost comes from the memory expansion needed to write the returndata. Custom errors produce smaller returndata than string errors (4-byte selector + params vs 4-byte selector + offset + length + padded string), requiring less memory expansion.
- Returndata buffer: Overwritten by every external call (including calls that return no data). In assembly, you must
returndatacopythe bytes you need before making another call, or the data is lost. The buffer persists only until the nextcall/staticcall/delegatecall. - 63/64 rule (EIP-150): The caller retains 1/64th of available gas when making a sub-call. If the sub-call hits INVALID or runs out of gas, the caller still has its reserved 1/64th to detect the failure (success=0) and handle it β preventing complete gas exhaustion from propagating up the entire call chain.
π‘ Error Encoding
You now know that REVERT sends bytes back to the caller. But whatβs actually in those bytes? Solidity doesnβt send raw strings β it ABI-encodes error data using the exact same scheme as function calls. Understanding this encoding is what lets you decode errors from any contract, even ones you donβt have the source for.
π‘ Concept: The Anatomy of Error Data
Why this matters: When you see raw revert data on Etherscan or in a Foundry trace, itβs just hex bytes. Knowing the structure lets you decode any error from any contract β custom errors, string messages, panics β without needing the ABI. Itβs the same skill you use to decode function calldata, because the encoding is identical.
The structure:
Error data follows the exact same ABI encoding as function calldata: a 4-byte selector followed by ABI-encoded parameters.
Revert data layout:
ββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββ
β Bytes 0-3 β Bytes 4+ β
β β β
β Selector β ABI-encoded parameters β
β (4 bytes) β (variable length) β
ββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββ
The selector is keccak256("ErrorName(paramTypes)") truncated to 4 bytes β exactly like a function selector.
Example β a custom error:
error InsufficientBalance(uint256 required, uint256 actual);
revert InsufficientBalance(1000, 500);
Produces this revert data:
Selector: keccak256("InsufficientBalance(uint256,uint256)")[0:4]
= 0xcf479181
Full revert data (68 bytes):
cf479181 β selector
00000000000000000000000000000000000000000000000000000000000003e8 β 1000
00000000000000000000000000000000000000000000000000000000000001f4 β 500
This is byte-for-byte identical in structure to calling a function InsufficientBalance(uint256,uint256) with arguments (1000, 500). The ABI encoder doesnβt know β or care β whether itβs encoding a function call or an error.
Example β a string error:
revert("insufficient balance");
Produces:
Selector: keccak256("Error(string)")[0:4]
= 0x08c379a0
Full revert data:
08c379a0 β selector
0000000000000000000000000000000000000000000000000000000000000020 β offset to string data (32)
0000000000000000000000000000000000000000000000000000000000000014 β string length (20 bytes)
696e73756666696369656e742062616c616e636500000000000000000000000000 β "insufficient balance" + padding
Notice the extra indirection: strings are dynamic types in ABI encoding, so thereβs an offset pointer, then the length, then the data. This is why string errors use more gas β more bytes to encode and more memory expansion.
π» Quick Try:
See the raw encoding yourself in Remix:
contract ErrorEncoding {
error InsufficientBalance(uint256 required, uint256 actual);
function getCustomErrorData() external pure returns (bytes memory) {
// Encode error data without actually reverting
return abi.encodeWithSelector(
InsufficientBalance.selector,
1000,
500
);
}
function getStringErrorData() external pure returns (bytes memory) {
return abi.encodeWithSignature("Error(string)", "insufficient balance");
}
// Compare the byte lengths
function compareSizes() external pure returns (uint256 custom, uint256 str) {
custom = abi.encodeWithSelector(InsufficientBalance.selector, 1000, 500).length;
str = abi.encodeWithSignature("Error(string)", "insufficient balance").length;
}
}
Call compareSizes() β the custom error is 68 bytes, the string error is 100+ bytes. Call the other two functions to see the raw hex and match it against the layouts above.
π Deep Dive: Error Selectors vs Function Selectors
Error selectors and function selectors are computed identically: keccak256(signature) truncated to 4 bytes. This means they share the same 4-byte selector space β and collisions are theoretically possible.
// Function selector
bytes4 funcSelector = bytes4(keccak256("transfer(address,uint256)"));
// = 0xa9059cbb
// Error selector
bytes4 errSelector = bytes4(keccak256("InsufficientBalance(uint256,uint256)"));
// = 0xcf479181
// Same computation, same 4-byte space
Why this matters for decoding:
When you receive raw revert data and extract the first 4 bytes, youβre looking up the selector against a known list of error signatures. Tools like openchain.xyz/signatures and 4byte.directory maintain databases of known selectors β for both functions and errors.
Selector collisions: With 2^32 (~4.3 billion) possible selectors, collisions exist. The Solidity compiler checks for collisions within each category in a single contract β no two functions can share a selector, and no two errors can share a selector. But a function and an error can have the same selector (theyβre dispatched differently). Cross-contract collisions are also possible. In practice this is rarely a problem β you typically know which contract reverted and can match against its specific error definitions.
π‘ Concept: Three Error Formats
Why this matters: Not all revert data looks the same. Solidity produces three distinct formats depending on how the error was triggered. When youβre decoding errors β whether in a try/catch, from a low-level call, or in off-chain tooling β you need to recognize which format youβre dealing with before you can decode the parameters.
Format 1: String errors β Error(string)
Selector: 0x08c379a0
// Produced by:
require(condition, "message");
revert("message");
08c379a0 β always this selector
[ABI-encoded string] β offset + length + data
This was the only user-defined error format before Solidity 0.8.4 (Panic existed since 0.8.0 but is compiler-generated). Itβs verbose and gas-expensive because strings are dynamic types. Still used widely β OpenZeppelinβs access control messages, many require statements in production code.
Format 2: Custom errors β ErrorName(params...)
Selector: first 4 bytes of keccak256("ErrorName(paramTypes)")
// Produced by:
error InsufficientBalance(uint256 required, uint256 actual);
revert InsufficientBalance(1000, 500);
cf479181 β error-specific selector
[ABI-encoded params] β packed 32-byte words
Introduced in Solidity 0.8.4. Gas-efficient because parameters are statically typed (no offset/length overhead for simple types). This is the modern standard β Uniswap V4, Aave V3, and most new protocols use custom errors exclusively.
Format 3: Panic codes β Panic(uint256)
Selector: 0x4e487b71
// Produced automatically by the compiler:
assert(false); // Panic(0x01)
uint256 x = 1 / 0; // Panic(0x12)
uint256 y = type(uint256).max + 1; // Panic(0x11) β if checked arithmetic
4e487b71 β always this selector
0000000000000000000000000000000000000000000000000000000000000011 β panic code
Always exactly 36 bytes: 4-byte selector + 32-byte uint256 code. Panic codes are compiler-generated β you never write revert Panic(0x11) yourself. They indicate bugs in the code (failed assertions, arithmetic overflow, out-of-bounds access), not expected error conditions.
Recognizing the format from raw bytes:
First 4 bytes of revert data:
0x08c379a0 β String error β decode as Error(string)
0x4e487b71 β Panic code β decode as Panic(uint256)
anything else β Custom error β need the error ABI to decode params
empty (0 bytes) β No data β INVALID, out-of-gas, or bare revert()
This four-way check is the foundation of every error decoder β whether youβre building one in Solidity, in a bot, or itβs what Foundry does internally when it shows you readable error messages.
π» Quick Try:
Trigger all three formats and compare the raw revert data:
contract ThreeFormats {
error CustomError(uint256 code);
function stringError() external pure {
revert("bad input");
}
function customError() external pure {
revert CustomError(42);
}
function panicError() external pure {
assert(false);
}
function bareRevert() external pure {
revert(); // No data at all
}
}
Call each in Remix and look at the revert data in the console. stringError starts with 08c379a0, customError with the custom selector, panicError with 4e487b71, and bareRevert has empty returndata.
β οΈ Common Mistakes
Mistake 1: Assuming all revert data is a string
// WRONG β only works for Error(string) format
try target.someFunction() {
// ...
} catch Error(string memory reason) {
// This ONLY catches string errors (0x08c379a0)
// Custom errors and panics fall through to the next catch clause
}
// You need multiple catch clauses or catch (bytes memory) to handle all formats
Mistake 2: Forgetting bare revert()
revert() with no arguments produces zero bytes of returndata β not even a selector. Your decoder must handle the empty case:
if (data.length == 0) {
// bare revert(), INVALID, or out-of-gas β no information available
} else if (data.length >= 4) {
bytes4 selector;
assembly { selector := mload(add(data, 0x20)) } // bytes memory needs assembly
if (selector == 0x08c379a0) {
// string error
} else if (selector == 0x4e487b71) {
// panic
} else {
// custom error β need ABI to decode further
}
}
Mistake 3: Assuming data.length >= 4
Revert data can technically be any length β including 1, 2, or 3 bytes from handwritten assembly. Always check data.length >= 4 before extracting a selector.
π‘ Concept: Panic Codes Reference
Why this matters: When you see Panic(0x11) in a Foundry trace or a failed transaction, you need to know instantly what triggered it. Panic codes are the compilerβs way of telling you which internal safety check failed. Unlike custom errors that you define, panics are built into the compiler β and the list is exhaustive.
Complete panic code table:
| Code | Hex | Trigger | Common DeFi scenario |
|---|---|---|---|
| 0x00 | 0x00 | Generic compiler-inserted panic | Rare β compiler internal |
| 0x01 | 0x01 | assert(false) | Failed invariant check |
| 0x11 | 0x11 | Arithmetic overflow/underflow | Token math without unchecked, price calculation overflow |
| 0x12 | 0x12 | Division or modulo by zero | Division by totalSupply when pool is empty |
| 0x21 | 0x21 | Conversion to enum with invalid value | Casting invalid uint to enum (e.g., order types) |
| 0x22 | 0x22 | Access to incorrectly encoded storage byte array | Rare β corrupted storage |
| 0x31 | 0x31 | .pop() on an empty array | Removing from an empty queue/stack |
| 0x32 | 0x32 | Array, bytesN, or slice index out of bounds | Accessing pools[i] with invalid index |
| 0x41 | 0x41 | Too much memory allocated or array too large | Creating a huge dynamic array |
| 0x51 | 0x51 | Calling a zero-initialized internal function variable | Rare β uninitialized function pointer |
The ones youβll actually see in DeFi:
-
0x11 (overflow) β the most common. Happens when checked arithmetic catches an overflow. In DeFi: price calculations, reward accumulator math, or token amount computations that exceed uint256. When you see this, the question is whether the inputs were valid (code bug) or the inputs were invalid (missing validation).
-
0x12 (division by zero) β second most common. In DeFi: dividing by
totalSupplyortotalAssetswhen a pool is empty, dividing by a reserve amount thatβs been fully drained. This is why production code checksif (totalSupply == 0)before any division. -
0x32 (out of bounds) β array access with an invalid index. In DeFi: iterating over a dynamic list of positions, pools, or tokens with a stale length.
-
0x01 (assertion failure) β
assert()is used for invariant checks that should never fail. If you see this in production, it means a fundamental invariant was violated β itβs a serious bug, not an expected error condition.
Panic vs custom error β when to use which:
// Use CUSTOM ERRORS for expected failure conditions:
error InsufficientBalance(uint256 required, uint256 actual);
if (balance < amount) revert InsufficientBalance(amount, balance);
// Use ASSERT for invariants that should NEVER be false:
assert(totalShares == 0 || totalAssets > 0); // If shares exist, assets must exist
If your code triggers a panic in production, itβs a bug. If it triggers a custom error, itβs working as designed β rejecting invalid inputs or states.
π DeFi Pattern Connection
Where panic codes surface in DeFi:
-
Vault math (0x11, 0x12) The classic empty vault problem: when
totalSupply == 0, any division by it panics with 0x12. This is why ERC-4626 vaults use virtual shares/assets or check for the zero case explicitly. The inflation attack exploits the boundary between 0 and 1 shares β and a panic at that boundary would halt deposits entirely. -
AMM reserve calculations (0x11) The constant product formula
x * y = kinvolves multiplying two reserve values. If reserves grow large enough (e.g., rebasing tokens), the multiplication can overflow. Uniswap V2 usesUQ112x112fixed-point to bound this. Uniswap V3 usesmulDivfor 512-bit intermediates. Without these, youβd see Panic(0x11) on large swaps. -
Reward accumulators (0x11) Staking contracts accumulate
rewardPerTokenby adding(reward * 1e18) / totalStakedeach period. Ifreward * 1e18overflows, you get 0x11. This is why production accumulators use careful scaling and sometimes 256-bit-safe math.
The pattern: Panic codes in DeFi almost always mean missing boundary checks β empty pools, zero supplies, or overflow-prone calculations. Production code prevents them by validating before the arithmetic, not by catching them after.
π Key Takeaways: Error Encoding
After this section, you should be able to:
- Look at raw revert data hex and identify which of the four cases it is (string error, custom error, panic, or empty) by checking the first 4 bytes against the known selectors
0x08c379a0and0x4e487b71 - Explain why error encoding uses the same ABI scheme as function calldata β same selector computation, same parameter encoding β and why this means the same decoding tools work for both
- Decode a custom errorβs parameters by extracting the selector and ABI-decoding the remaining bytes, given the errorβs signature
- Map any panic code to its trigger and identify the most common ones in DeFi contexts (0x11 overflow, 0x12 division by zero, 0x32 out of bounds)
- Explain why custom errors are cheaper than string errors in terms of returndata size and memory expansion cost
Check your understanding
- Identifying error format from raw hex: Check bytes 0-3:
0x08c379a0=Error(string),0x4e487b71=Panic(uint256), empty = bare revert/INVALID/OOG, anything else = custom error. This works because error encoding uses the same selector scheme as function calls. - Error encoding = function call encoding: Both use
keccak256(signature)[0:4]for the selector followed by ABI-encoded parameters. This meanscast 4byte,abi.decode, and the same decoding libraries work for both calldata and revert data. - Decoding custom error parameters: Extract the 4-byte selector, match it against known error signatures, then
abi.decode(data[4:], (paramTypes))to recover the parameters. Without the error signature, you can still identify the selector via a 4byte directory lookup. - Common panic codes: 0x11 = arithmetic overflow/underflow (most common in DeFi math), 0x12 = division by zero, 0x32 = array out-of-bounds access. These are emitted by
assertfailures and checked arithmetic in Solidity 0.8+. - Custom errors are cheaper: A parameterless custom error produces just 4 bytes of returndata.
require(false, "Insufficient balance")produces 4 + 32 + 32 + 32 + padded-string bytes. Less returndata means less memory expansion cost at the REVERT instruction.
π‘ Solidity Error Primitives
You know the encoding. You know the three formats. Now letβs look at the Solidity constructs that produce them β require, revert, assert, and custom error declarations. Each compiles to different bytecode, and what the compiler emits has changed across versions.
π‘ Concept: require, revert, assert β What Each Compiles To
Why this matters: These three keywords look similar at the Solidity level, but they compile to fundamentally different bytecode. Knowing what each produces β and how that changed across compiler versions β tells you exactly what error format a contract will emit, which matters when youβre decoding errors from contracts compiled with different Solidity versions.
The current behavior (Solidity 0.8.x):
| Construct | Bytecode | Error format | When to use |
|---|---|---|---|
require(cond, "msg") | REVERT | Error(string) β selector 0x08c379a0 | Input validation, access control |
require(cond, CustomError()) | REVERT | Custom error β error-specific selector | Input validation (0.8.26+) |
revert("msg") | REVERT | Error(string) β selector 0x08c379a0 | Explicit failure with message |
revert CustomError() | REVERT | Custom error β error-specific selector | Explicit failure (modern) |
require(cond) | REVERT | Empty returndata (0 bytes) | Cheap validation (no message) |
revert() | REVERT | Empty returndata (0 bytes) | Bare revert |
assert(cond) | REVERT | Panic(uint256) β selector 0x4e487b71 | Invariant checks |
What changed from pre-0.8.0 to 0.8.x:
This is critical when reading old contracts that are still deployed on mainnet.
Pre-0.8.0 (Solidity 0.7.x and earlier):
βββββββββββββββββββββββββββββββββββββββββ
require(cond, "msg") β REVERT with Error(string) β same as today
require(cond) β REVERT with empty data β same as today
assert(cond) β INVALID opcode (0xFE) β DIFFERENT!
β²
β Consumes ALL gas, no returndata
β This is why old assert() was so dangerous
Since 0.8.0:
βββββββββββββββββββββββββββββββββββββββββ
assert(cond) β REVERT with Panic(0x01) β controlled failure
β²
β Refunds gas, returns panic code
β Much safer β caller can detect and react
Why the assert change matters: Pre-0.8.0, assert(false) in a sub-call would consume all forwarded gas and return nothing. The caller couldnβt distinguish it from out-of-gas. Since 0.8.0, assert is just a REVERT with a specific error format β the caller gets gas back and can read the panic code. If youβre reading a pre-0.8.0 contract and see assert, know that itβs far more punishing than the modern version.
Solidity 0.8.26 β require with custom errors:
// Before 0.8.26: had to use if/revert for custom errors
error Unauthorized(address caller);
if (msg.sender != owner) revert Unauthorized(msg.sender);
// Since 0.8.26: require accepts custom errors directly
require(msg.sender == owner, Unauthorized(msg.sender));
This is syntactic sugar β the bytecode is identical. But it makes custom errors as convenient as string messages, removing the last reason to prefer require(cond, "string").
π» Quick Try:
Compare the gas cost of each error style:
contract ErrorGas {
error Unauthorized();
function withString() external pure {
require(false, "unauthorized access attempt");
}
function withCustom() external pure {
revert Unauthorized();
}
function withBareRequire() external pure {
require(false);
}
function withAssert() external pure {
assert(false);
}
}
Call each in Remix and compare gas used. withCustom and withBareRequire are cheapest, withString is most expensive (string encoding overhead), and withAssert includes the Panic encoding.
π Deep Dive: Bytecode Comparison Across Versions
Letβs trace what the compiler actually emits for a simple require(x > 0, "zero"):
Solidity 0.8.x bytecode (simplified):
PUSH1 0x00 // load x
CALLDATALOAD
PUSH1 0x00
GT // x > 0 ?
PUSH1 [jump_ok]
JUMPI // if true, jump past revert
// False path β emit Error(string):
PUSH32 0x08c379a0... // Error(string) selector
MSTORE // write to memory
// ... encode "zero" as ABI string ...
REVERT // revert with the encoded data
[jump_ok]:
JUMPDEST // continue execution
The same require, pre-0.5.0:
Before Solidity 0.4.22, require didnβt even support reason strings. require(cond) and assert(cond) both produced bare reverts or INVALID opcodes with no error data at all. This is why many legacy contracts on mainnet revert with no explanation.
What revert CustomError(args) emits:
// revert InsufficientBalance(1000, 500)
PUSH4 0xcf479181 // error selector
MSTORE
PUSH2 0x03e8 // 1000
MSTORE
PUSH2 0x01f4 // 500
MSTORE
PUSH1 0x44 // 68 bytes of data
PUSH1 0x00 // starting at offset 0
REVERT
No string encoding, no offset pointers, no length fields. Just selector + packed 32-byte words. This is why custom errors save gas β the encoding is simpler and shorter.
β οΈ Common Mistakes
Mistake 1: Using assert for input validation
// WRONG β assert is for invariants, not validation
function withdraw(uint256 amount) external {
assert(amount <= balances[msg.sender]); // Panic(0x01) if false
}
// CORRECT β use require or custom error for expected failures
error InsufficientBalance(uint256 available, uint256 requested);
function withdraw(uint256 amount) external {
if (amount > balances[msg.sender]) {
revert InsufficientBalance(balances[msg.sender], amount);
}
}
assert signals βthis should be impossibleβ β if it fires, itβs a bug. Input validation is expected to fail sometimes β use require or custom errors so the caller gets a meaningful error.
Mistake 2: Mixing string requires and custom errors inconsistently
// INCONSISTENT β harder to decode, confusing for integrators
function deposit(uint256 amount) external {
require(amount > 0, "zero amount"); // Error(string)
if (paused) revert Paused(); // Custom error
require(amount <= maxDeposit, "exceeds max"); // Error(string)
}
// CONSISTENT β all custom errors
error ZeroAmount();
error Paused();
error ExceedsMax(uint256 max, uint256 actual);
function deposit(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
if (paused) revert Paused();
if (amount > maxDeposit) revert ExceedsMax(maxDeposit, amount);
}
Pick one style per contract. Modern protocols use custom errors exclusively β theyβre cheaper, carry structured data, and are easier to decode programmatically.
π‘ Concept: Custom Error Declarations
Why this matters: Custom errors (introduced in Solidity 0.8.4) are the modern standard for error handling in DeFi. Theyβre cheaper than strings, carry structured parameters, and are the foundation for how production protocols communicate failures. Understanding their mechanics β declaration, inheritance, selectors, and gas implications β is essential for reading and writing production code.
Declaration and scope:
// File-level β usable by any contract in the file
error Unauthorized(address caller);
error InsufficientBalance(uint256 required, uint256 actual);
contract Vault {
// Contract-level β scoped to this contract (and inheritors)
error DepositTooLarge(uint256 max, uint256 actual);
function deposit(uint256 amount) external {
if (msg.sender == address(0)) revert Unauthorized(msg.sender);
if (amount > maxDeposit) revert DepositTooLarge(maxDeposit, amount);
}
}
File-level errors are preferred when multiple contracts need the same error. Contract-level errors are useful when the error is specific to that contractβs domain.
Inheritance and interfaces:
interface IVault {
error Unauthorized();
error Paused();
}
contract BaseVault is IVault {
// Can use errors from IVault without redeclaring
function checkAccess() internal view {
if (msg.sender != owner) revert Unauthorized();
}
}
contract ChildVault is BaseVault {
// Inherited errors are available here too
function deposit() external {
if (isPaused) revert Paused();
checkAccess();
}
}
Errors declared in interfaces serve as the contractβs error API β integrators know exactly which errors to expect. This is why Uniswap V4βs interfaces declare all errors up front.
Custom errors with no parameters:
error Unauthorized();
error Paused();
error ZeroAddress();
These produce only 4 bytes of revert data (just the selector). Maximum gas efficiency β use them when the error name alone is descriptive enough.
Gas comparison β real numbers:
revert("unauthorized") β ~200 gas more (string encoding + larger returndata)
revert Unauthorized() β baseline (4 bytes, no encoding overhead)
revert Unauthorized(msg.sender) β ~20 gas more than no-param (one 32-byte word)
The savings come from two places: less bytecode in the deployed contract (no string literals stored) and less memory expansion at revert time (fewer bytes in returndata). For contracts that revert frequently (like routers checking many conditions), this adds up.
Named parameters for readability:
// Without names β what do these numbers mean?
error SlippageExceeded(uint256, uint256);
// revert SlippageExceeded(950, 1000); β which is expected, which is actual?
// With names β self-documenting
error SlippageExceeded(uint256 minExpected, uint256 actualReceived);
// revert SlippageExceeded(950, 1000); β clear: expected 950, got 1000
Parameter names donβt affect the selector or encoding β theyβre purely for readability in source code and tooling (Etherscan, Foundry traces). Always name your parameters.
π» Quick Try:
Verify that error selectors work like function selectors:
contract ErrorSelectors {
error Transfer(address to, uint256 amount);
function errorSelector() external pure returns (bytes4) {
return Transfer.selector;
}
function manualSelector() external pure returns (bytes4) {
return bytes4(keccak256("Transfer(address,uint256)"));
}
function areEqual() external pure returns (bool) {
return Transfer.selector == bytes4(keccak256("Transfer(address,uint256)"));
}
}
Call areEqual() β returns true. The .selector property on errors works identically to function selectors.
π DeFi Pattern Connection
Where custom errors shape DeFi protocol design:
-
Uniswap V4 β error-driven interfaces Uniswap V4βs
IPoolManagerdeclares all errors in the interface. Integrators (hooks, routers) can match against these selectors to handle specific failure modes programmatically. When a swap fails, the router knows whether it wasPoolNotInitialized,InvalidTick, orInsufficientLiquidityβ each requires a different response. -
Aave V3 β error code libraries Aave V3 uses a hybrid approach: custom errors with numeric codes defined in a
Errorslibrary. This lets them categorize errors by domain (validation errors, liquidity errors, oracle errors) while keeping the gas benefits of custom errors. -
OpenZeppelin 5.x β the migration from strings OpenZeppelin 5.x migrated from string errors to custom errors across the entire library.
require(owner == msg.sender, "Ownable: caller is not the owner")becamerevert OwnableUnauthorizedAccount(msg.sender). This is the direction the entire ecosystem is moving.
The pattern: Modern DeFi protocols declare all errors in their interfaces, use descriptive parameter names, and never use string errors in new code. The error declarations serve as documentation β reading a protocolβs errors tells you every way it can fail.
πΌ Job Market Context
What DeFi teams expect you to know:
-
βWhy did you choose custom errors over require strings?β
Answer
- Good answer: Gas savings from smaller returndata and no string encoding
- Great answer: Gas savings plus structured parameters that integrators can decode programmatically, plus they serve as the contractβs failure API when declared in interfaces
-
βHow would you design the error hierarchy for a lending protocol?β
Answer
- Good answer: Group errors by domain β authorization, validation, liquidity, oracle
- Great answer: Declare all errors in interfaces so integrators can match selectors, use descriptive parameters that carry enough context to diagnose without a trace, prefer parameterless errors for simple conditions and parameterized errors when the caller needs the values to react
Interview Red Flags:
- π© Still using
require(cond, "string")in new code - π© Declaring errors with unnamed parameters
- π© Not knowing that custom errors produce different revert data than string errors
Pro tip: When reviewing a protocolβs security, read its error declarations first. They tell you every failure mode the developers anticipated β and the ones they missed are where the bugs live.
π Key Takeaways: Solidity Error Primitives
After this section, you should be able to:
- Trace what bytecode
require,revert, andasserteach compile to, and explain howassertchanged from INVALID (pre-0.8.0) to REVERT with Panic (0.8.0+) - Explain why
require(cond, CustomError())(0.8.26+) produces identical bytecode toif (!cond) revert CustomError()and why it removes the last reason to prefer string errors - Design a custom error hierarchy for a DeFi protocol: file-level vs contract-level scope, interface declarations for integrators, parameterized vs parameterless based on caller needs
- Quantify the gas difference between string errors and custom errors and explain where the savings come from (bytecode size, memory expansion, returndata length)
- Read a pre-0.8.0 contract and identify where
assertusage means INVALID opcode behavior (all gas consumed, no returndata)
Check your understanding
- require, revert, assert bytecode:
requireandrevertboth compile to REVERT (returns remaining gas, sends error data). Pre-0.8.0,assertcompiled to INVALID (consumed all gas, no returndata); post-0.8.0, it compiles to REVERT withPanic(uint256)β same gas behavior as require, but with a panic code. - require with custom errors (0.8.26+):
require(cond, CustomError())produces identical bytecode toif (!cond) revert CustomError(). This eliminates the last advantage of the if-revert pattern over require, making custom errors work cleanly with both syntax forms. - Custom error hierarchy design: Declare errors in interfaces (for integrator access) or at file level (for shared use). Use parameters when callers need diagnostic data (e.g.,
InsufficientBalance(uint256 available, uint256 required)), omit them when the selector alone is sufficient to identify the failure. - Gas difference quantified: A
revert InsufficientBalance()with no parameters costs ~24 gas less thanrequire(false, "Insufficient balance")due to smaller bytecode (no string literal stored) and smaller returndata (4 bytes vs ~100+ bytes). The savings compound across a contract with many revert sites. - Pre-0.8.0 assert behavior: In contracts compiled before Solidity 0.8.0, every
assertstatement uses the INVALID opcode. If triggered, it consumes all forwarded gas and returns no data β making it impossible for the caller to know what went wrong. Identify these by checking the compiler version in the metadata.
π‘ Error Propagation
You know how errors are created and encoded. Now the critical question: what happens to those error bytes as they travel through nested calls? In DeFi, transactions routinely chain 3-5+ contracts deep β a user calls a router, which calls a pool, which calls a token, which calls a hook. When something fails deep in that stack, how does the error reach the surface?
π‘ Concept: How Errors Travel Up the Call Stack
Why this matters: In a typical DeFi transaction β say, a swap through a router β the error might originate 3 or 4 calls deep. If propagation breaks at any level, the user sees a generic βexecution revertedβ instead of βInsufficientLiquidityβ or βSlippageExceededβ. Understanding how errors bubble up tells you where information gets lost and how to preserve it.
Automatic bubbling in Solidity high-level calls:
When you call another contract using Solidityβs high-level syntax (e.g., token.transfer(to, amount)), a revert in the callee automatically reverts the caller with the same error data. No manual handling needed.
contract Router {
function swap(address pool, uint256 amount) external {
// If Pool.execute reverts, Router.swap also reverts
// with the SAME revert data β automatically
IPool(pool).execute(amount);
// This line never executes if execute() reverted
}
}
contract Pool {
function execute(uint256 amount) external {
// If this reverts, the error data propagates up to Router
require(reserves >= amount, "insufficient liquidity");
}
}
User β Router.swap()
βββΊ Pool.execute()
βββΊ REVERT("insufficient liquidity")
β
β Error data: 0x08c379a0...
β
ββββββ Router sees success=false
β
β Router ALSO reverts (automatic)
β with the SAME error data
β
ββββββ User sees: "insufficient liquidity"
This automatic bubbling is the default behavior for high-level calls. The compiler generates code that checks the return value and, if the sub-call failed, copies the returndata and reverts with it.
What the compiler generates (simplified):
// IPool(pool).execute(amount) compiles roughly to:
// 1. Encode calldata
// 2. Make the call
(bool success, bytes memory returndata) = pool.call(
abi.encodeWithSelector(IPool.execute.selector, amount)
);
// 3. If failed, bubble the error
if (!success) {
assembly {
revert(add(returndata, 0x20), mload(returndata))
}
}
// 4. Decode return values (if any)
This is why high-level calls βjust workβ for error propagation β the compiler handles the bubble-up logic for you.
Multi-level propagation:
Errors propagate through as many levels as needed. Each frame copies the returndata and reverts:
User tx
βββΊ Router.swap()
βββΊ Pool.swap()
βββΊ PriceOracle.getPrice()
βββΊ REVERT StalePrice(lastUpdate, now)
β
β returndata: 0x[StalePrice selector + params]
β
ββββββ Pool receives returndata, auto-reverts with same data
ββββββ Router receives returndata, auto-reverts with same data
ββββββ User sees: StalePrice(lastUpdate, now)
The error data passes through untouched β Pool and Router donβt modify it. The user (or their frontend) receives the original error from PriceOracle, 3 levels deep.
π» Quick Try:
Verify multi-level error propagation:
contract Level3 {
error DeepError(uint256 depth);
function fail() external pure {
revert DeepError(3);
}
}
contract Level2 {
function callLevel3(address level3) external view {
// High-level call β error auto-bubbles
Level3(level3).fail();
}
}
contract Level1 {
function callLevel2(address level2, address level3) external view {
Level2(level2).callLevel3(level3);
}
}
Deploy all three, call Level1.callLevel2(). Youβll see DeepError(3) in the revert β the original error from Level3 surfaces through two intermediate contracts.
π Deep Dive: Returndata at Each Call Frame
Each call frame has its own returndata buffer. When a sub-call reverts, the revert data lands in the callerβs returndata buffer. Hereβs what happens at each level:
Frame 0 (Router) Frame 1 (Pool) Frame 2 (Oracle)
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ
β β β β β β
β returndata: emptyβ βββ CALL βββΊ β returndata: emptyβ βββ CALL ββΊβ returndata: N/A β
β β β β β β
β β β β β REVERT(data) β
β β β β ββββββββββ β β
β β β returndata: data β ββββββββββββββββββββ
β β β β
β β β // Auto-bubble: β
β β β REVERT(data) β
β β βββββββββββββ β β
β returndata: data β ββββββββββββββββββββ
β β
β // Auto-bubble: β
β REVERT(data) β
ββββββββββββββββββββ
At each level, the returndata buffer is populated by the sub-callβs revert, then the current frame reverts with those same bytes. The data passes through unchanged.
When returndata gets lost:
The returndata buffer is overwritten by every external call. If a frame makes another call after catching an error, the original error data is gone from the buffer:
function riskyPattern(address a, address b) external {
(bool s1, bytes memory err) = a.call(payload1);
// returndata buffer = error from a
(bool s2, ) = b.call(payload2);
// returndata buffer = result from b (error from a is GONE from buffer)
// BUT: err variable still holds a's error (Solidity copied it to memory)
if (!s1) {
// Can still use err here β it was copied to memory
assembly {
revert(add(err, 0x20), mload(err))
}
}
}
This is why Solidity copies returndata into a bytes memory variable immediately β the buffer itself is volatile.
π‘ Concept: Low-Level Calls β Manual Error Handling
Why this matters: Low-level calls (call, staticcall, delegatecall) donβt automatically revert on failure β they return (bool success, bytes memory data) and let you decide what to do. This is both powerful and dangerous: powerful because you can handle errors selectively, dangerous because forgetting to check success means the error is silently swallowed.
The basic pattern:
(bool success, bytes memory data) = target.call(
abi.encodeWithSelector(IToken.transfer.selector, to, amount)
);
if (!success) {
// Option 1: Bubble the error (same as high-level call behavior)
assembly {
revert(add(data, 0x20), mload(data))
}
// Option 2: Wrap with context
// revert TransferFailed(token, to, amount);
// Option 3: Handle gracefully (rare β usually only in multicall patterns)
// return false;
}
Why assembly for error bubbling?
// You might wonder: why not just revert(string(data))?
// Because data isn't a string β it's ABI-encoded error data.
// You need to forward the raw bytes:
assembly {
// data is a bytes memory variable
// add(data, 0x20) skips the length prefix to get to the actual bytes
// mload(data) reads the length
revert(add(data, 0x20), mload(data))
}
This is the standard error bubbling pattern β youβll see it in OpenZeppelinβs Address.sol, Solady, and virtually every production codebase that uses low-level calls.
π» Quick Try:
Compare high-level and low-level error handling:
contract Target {
error NotAllowed(address caller);
function restricted() external view {
revert NotAllowed(msg.sender);
}
}
contract Caller {
// High-level β auto-bubbles
function highLevel(address target) external view {
Target(target).restricted(); // Automatically reverts with NotAllowed
}
// Low-level β manual handling required
function lowLevel(address target) external view returns (bool, bytes memory) {
(bool success, bytes memory data) = target.staticcall(
abi.encodeWithSelector(Target.restricted.selector)
);
// success = false, data = encoded NotAllowed error
return (success, data); // Return it instead of reverting
}
}
Call highLevel β it reverts with NotAllowed. Call lowLevel β it succeeds and returns the error data as a return value. Same underlying error, different handling.
β οΈ Common Mistakes: Silent Success on Empty Addresses
The trap: A low-level call to an address with no code (EOA or undeployed contract) succeeds silently with empty returndata. This is EVM behavior, not a Solidity bug.
address emptyAddress = address(0xdead); // No code deployed here
// This SUCCEEDS β success = true, data = empty
(bool success, bytes memory data) = emptyAddress.call(
abi.encodeWithSelector(IToken.transfer.selector, to, amount)
);
// success is TRUE even though no transfer happened!
Why this happens: The EVMβs CALL opcode checks if the target has code. If it doesnβt, the call succeeds immediately with no execution β thereβs nothing to run, so nothing fails. The returndata is empty (0 bytes), which is indistinguishable from a function that returns nothing.
The protection:
// Check code size before calling
if (target.code.length == 0) revert NoCode(target);
// Or use OpenZeppelin's Address library / Solady's SafeTransferLib
// which includes this check
This is exactly why SafeTransferLib and SafeERC20 exist β they check for code existence before calling token functions. Without this check, a transfer call to an empty address βsucceedsβ silently β the callerβs state updates proceed as if the transfer worked, but no token contract logic actually ran. The tokens arenβt moved anywhere; the caller is simply deceived into thinking the operation completed.
Where this bites in DeFi:
- Calling a token that was self-destructed (pre-Dencun)
- Calling a contract on the wrong chain (address exists on mainnet but not on L2)
- Calling a proxy whose implementation was deleted
- User provides wrong contract address
π DeFi Pattern Connection
Where low-level error handling is essential in DeFi:
-
Token transfers β the SafeERC20/SafeTransferLib pattern Some tokens (notably USDT) donβt return a bool from
transfer(). A high-level call expects a return value and reverts when itβs missing. Low-level calls sidestep this by not requiring a specific return format. This is why SafeERC20 uses low-level calls with manual success checking. -
DEX aggregators β partial failure tolerance Aggregators like 1inch route through multiple pools. If one pool fails, the aggregator catches the error and tries an alternative route rather than reverting the entire transaction. This requires low-level calls to prevent automatic bubbling.
-
Keeper/bot operations β error collection Liquidation bots attempt multiple liquidations in a single transaction. Each attempt uses a low-level call so that one failed liquidation doesnβt abort the others. The bot collects error data for logging.
The pattern: Use high-level calls when you want automatic bubbling (most cases). Use low-level calls when you need to handle failure without reverting β multicall, aggregators, or when interfacing with non-standard contracts.
π‘ Concept: Propagation Through Proxies
Why this matters: Most DeFi protocols are deployed behind proxies (UUPS, Transparent, Diamond). When a function in the implementation contract reverts, the error must travel through the proxyβs delegatecall back to the caller. Understanding how this works β and where it can break β is essential for debugging proxy-based protocols.
How delegatecall propagates errors:
User β Proxy.fallback()
β
β DELEGATECALL to Implementation
β (executes in Proxy's storage context)
β
βββΊ Implementation.deposit()
βββΊ REVERT InsufficientBalance(100, 50)
β
β returndata: 0x[InsufficientBalance encoded]
β
ββββββ DELEGATECALL returns success=false + returndata
β
β Proxy's fallback forwards returndata:
β assembly {
β returndatacopy(0, 0, returndatasize())
β revert(0, returndatasize()) // if delegatecall failed
β // OR: return(0, returndatasize()) // if delegatecall succeeded
β }
β
ββββββ User sees: InsufficientBalance(100, 50)
The proxyβs fallback function is the key piece. It uses delegatecall, then forwards the returndata regardless of whether it was a success or failure. This is why you see the same assembly pattern in every proxy implementation:
// From OpenZeppelin's Proxy.sol β the universal forwarding pattern
fallback() external payable {
address impl = _implementation();
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) } // forward revert data
default { return(0, returndatasize()) } // forward return data
}
}
Errors are transparent through proxies: The user doesnβt know or care that a proxy is involved β they see the implementationβs errors directly. This works because delegatecall preserves the returndata exactly as the implementation produced it.
When proxy error propagation breaks:
-
Missing fallback forwarding: If the proxyβs fallback doesnβt forward returndata (e.g., it uses
revert()instead ofrevert(0, returndatasize())), the original error is lost. -
Proxy-level reverts: If the proxy itself reverts before reaching the
delegatecall(e.g., access control on admin functions), the error comes from the proxy, not the implementation. This can be confusing during debugging. -
Implementation re-initialization: If someone calls
initialize()on a proxy thatβs already initialized, the error comes from the implementation β but the user called the proxy address. Knowing that errors propagate transparently throughdelegatecallhelps you trace the source.
π How to Study: Proxy Error Flows
- Start with OpenZeppelinβs Proxy.sol β read the fallback function. Itβs ~10 lines and shows the universal forwarding pattern
- Compare UUPS vs Transparent β notice how the error forwarding is identical in both. The proxy type affects upgrades, not error handling
- Test with Foundry β deploy a proxy + implementation, trigger a revert, and verify you see the implementationβs error
- Read the Diamond standard (EIP-2535) β it uses the same pattern per facet, with an extra selector lookup step before the
delegatecall
π‘ Concept: Constructor Reverts
Why this matters: When a contract deployment fails β whether via CREATE or CREATE2 β the behavior is different from regular call reverts. Understanding this is important for factory patterns, deterministic deployment, and debugging failed deployments.
CREATE/CREATE2 failure behavior:
Regular CALL failure:
βββΊ success = false, returndata = error bytes, address = N/A
CREATE failure:
βββΊ success = false (returned address = 0), returndata = error bytes
CREATE2 failure:
βββΊ success = false (returned address = 0), returndata = error bytes
Since Solidity 0.4.22, constructor reverts return error data just like regular reverts. The caller receives address(0) as the deployed address and can read the returndata for the error.
Constructor revert in practice:
contract Token {
constructor(string memory name) {
require(bytes(name).length > 0, "empty name");
}
}
contract Factory {
function deploy(string memory name) external returns (address) {
// If constructor reverts, this entire call reverts
// with the constructor's error data
Token token = new Token(name);
return address(token);
}
function safeDeploy(string memory name) external returns (address) {
// Low-level CREATE to handle failure without reverting
bytes memory bytecode = abi.encodePacked(
type(Token).creationCode,
abi.encode(name)
);
address addr;
assembly {
addr := create(0, add(bytecode, 0x20), mload(bytecode))
}
if (addr == address(0)) {
revert("deployment failed");
}
return addr;
}
}
CREATE2 and deterministic addresses:
With CREATE2, a failed deployment is particularly important to handle: the salt is NOT consumed β since the constructor reverted, the address was never occupied, and a future deployment with the same salt and bytecode will succeed at the same predicted address. But if the failure isnβt detected, the caller might think the contract exists at the predicted address when it doesnβt.
// Predicted address exists β but is there actually code there?
address predicted = computeCreate2Address(salt, bytecodeHash);
// After a failed CREATE2, predicted has no code
// Always verify: predicted.code.length > 0
π Key Takeaways: Error Propagation
After this section, you should be able to:
- Explain how Solidityβs high-level calls automatically bubble errors by checking the return value and reverting with the same returndata, and identify the compiler-generated code that does this
- Write the standard error bubbling pattern for low-level calls using assembly (
revert(add(data, 0x20), mload(data))) and explain why raw bytes must be forwarded rather than decoded - Explain why a low-level call to an address with no code succeeds silently and describe the protection patterns (code length check, SafeERC20) that prevent this
- Trace error propagation through a proxyβs
delegatecallfallback and explain why errors are transparent to the caller β they see the implementationβs errors directly - Describe how constructor reverts differ from regular call reverts (address(0) return, CREATE2 salt implications) and handle them in factory patterns
Check your understanding
- Automatic error bubbling: Solidity high-level calls (e.g.,
token.transfer(to, amount)) check the return value and, on failure, automatically revert with the calleeβs returndata. The compiler generatesif iszero(call(...)) { returndatacopy(...); revert(...) }β forwarding the exact error bytes. - Assembly error bubbling pattern: For low-level calls, use
revert(add(data, 0x20), mload(data))to forward raw revert bytes. This skips decoding entirely β you pass the bytes through as-is, preserving the original error for the caller above you. - No-code address trap: A low-level call to an address with no deployed code succeeds silently (returns success=1, empty returndata). Protection requires checking
extcodesize > 0before the call, or using SafeERC20 which includes this check. - Proxy delegatecall transparency: A proxyβs fallback does
delegatecallto the implementation, then forwards return/revert data. Errors from the implementation appear as if they came from the proxy β the caller sees the implementationβs error selectors directly. - Constructor revert behavior: A reverted constructor returns address(0) from CREATE/CREATE2. With CREATE2, the salt is NOT consumed on failure β the address was never occupied, so a future deployment with the same salt and init code will succeed at the same predicted address. But if the failure goes undetected, the caller may assume a contract exists at the predicted address when it doesnβt. Factory patterns must check
address != 0after deployment.
π‘ Try/Catch
Solidityβs try/catch is the language-level mechanism for intercepting errors from external calls without reverting your own frame. It looks straightforward β but the details of which clause catches what, and what it fundamentally cannot catch, trip up even experienced developers.
π‘ Concept: The Four Catch Clauses
Why this matters: try/catch has four distinct catch clause forms, each matching a different error format. Using the wrong clause means your error falls through to an unexpected handler β or isnβt caught at all. Knowing which clause catches which format is the direct application of the error encoding knowledge from earlier sections.
The four forms:
try target.someFunction() returns (uint256 result) {
// Success path β use result
} catch Error(string memory reason) {
// Catches: require(cond, "message") and revert("message")
// Format: Error(string) β selector 0x08c379a0
} catch Panic(uint256 code) {
// Catches: assert failures, overflow, division by zero
// Format: Panic(uint256) β selector 0x4e487b71
} catch (bytes memory lowLevelData) {
// Catches: custom errors, or any revert data that didn't match above
// Format: raw bytes β you decode manually
} catch {
// Catches: anything not caught above, including empty revert data
// No access to the error data
}
The matching order matters:
Solidity tries each clause top to bottom. The first matching clause handles the error:
Revert data arrives
β
ββ Selector == 0x08c379a0? β catch Error(string memory reason)
β
ββ Selector == 0x4e487b71? β catch Panic(uint256 code)
β
ββ Has bytes data? β catch (bytes memory lowLevelData)
β
ββ Bare catch? β catch { }
You donβt need all four. Use only the clauses you need:
// Pattern 1: Catch everything with raw bytes (most flexible)
try target.doSomething() {
// success
} catch (bytes memory data) {
// Handle ALL error types β decode manually if needed
}
// Pattern 2: Separate string errors from everything else
try target.doSomething() {
// success
} catch Error(string memory reason) {
// String errors only
} catch (bytes memory data) {
// Custom errors, panics, and anything else
}
// Pattern 3: Just know it failed (no error data needed)
try target.doSomething() {
// success
} catch {
// Failed β don't care why
}
The returns clause:
The try statement can capture return values on success:
// Without returns β just check success/failure
try oracle.getPrice(token) {
// Succeeded, but we didn't capture the price
} catch { }
// With returns β capture the return value
try oracle.getPrice(token) returns (uint256 price) {
// price is available here
latestPrice = price;
} catch {
// Use stale price or revert
}
The returns types must match the called functionβs return signature exactly.
π» Quick Try:
Test all four catch clauses:
contract Thrower {
error CustomError(uint256 code);
function throwString() external pure { revert("bad"); }
function throwCustom() external pure { revert CustomError(42); }
function throwPanic() external pure { assert(false); }
function throwBare() external pure { revert(); }
}
contract Catcher {
event Caught(string which, bytes data);
function catchString(address thrower) external {
try Thrower(thrower).throwString() {
} catch Error(string memory reason) {
emit Caught("Error(string)", bytes(reason));
} catch Panic(uint256 code) {
emit Caught("Panic", abi.encode(code));
} catch (bytes memory data) {
emit Caught("bytes", data);
} catch {
emit Caught("bare", "");
}
}
function catchCustom(address thrower) external {
try Thrower(thrower).throwCustom() {
} catch Error(string memory reason) {
emit Caught("Error(string)", bytes(reason));
} catch Panic(uint256 code) {
emit Caught("Panic", abi.encode(code));
} catch (bytes memory data) {
emit Caught("bytes", data);
} catch {
emit Caught("bare", "");
}
}
function catchPanic(address thrower) external {
try Thrower(thrower).throwPanic() {
} catch Error(string memory reason) {
emit Caught("Error(string)", bytes(reason));
} catch Panic(uint256 code) {
emit Caught("Panic", abi.encode(code));
} catch (bytes memory data) {
emit Caught("bytes", data);
} catch {
emit Caught("bare", "");
}
}
function catchBare(address thrower) external {
try Thrower(thrower).throwBare() {
} catch Error(string memory reason) {
emit Caught("Error(string)", bytes(reason));
} catch Panic(uint256 code) {
emit Caught("Panic", abi.encode(code));
} catch (bytes memory data) {
emit Caught("bytes", data);
} catch {
emit Caught("bare", "");
}
}
}
Call each function and check which event fires. catchString β Error(string), catchPanic β Panic, catchCustom β bytes (custom errors donβt have a dedicated clause), catchBare β bytes with empty data (or bare if no bytes clause).
π Deep Dive: Which Clause Triggers When
Letβs be precise about what each clause matches:
Error data Matched clause
ββββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββ
0x08c379a0 + valid string encoding catch Error(string memory)
0x08c379a0 + invalid encoding catch (bytes memory) β NOT Error!
0x4e487b71 + valid uint256 catch Panic(uint256)
0x4e487b71 + invalid encoding catch (bytes memory) β NOT Panic!
Any other selector + data catch (bytes memory)
Empty (0 bytes) catch (bytes memory) with empty bytes
OR catch { } if no bytes clause
Key subtlety: catch Error(string memory) doesnβt just check the selector β it also verifies that the remaining bytes are valid ABI-encoded string data. If the selector matches but the encoding is malformed, it falls through to catch (bytes memory). Same for catch Panic(uint256).
This means catch (bytes memory) is the true catch-all for any revert data. The bare catch only catches what falls through everything else β which in practice is only when you donβt have a catch (bytes memory) clause.
What happens with no matching clause:
If the revert data doesnβt match any of your catch clauses and thereβs no catch-all, the error propagates up as if there were no try/catch at all. Your function reverts with the original error data.
// DANGEROUS β custom errors propagate uncaught!
try target.doSomething() {
} catch Error(string memory reason) {
// Only catches string errors
}
// If target reverts with a custom error β your function reverts too
// The try/catch provided no protection for custom errors
Always include catch (bytes memory) or bare catch if you want to catch all possible errors.
β οΈ Common Mistakes
Mistake 1: Assuming catch Error catches custom errors
// WRONG β custom errors are NOT caught by catch Error
try target.withdraw(amount) {
} catch Error(string memory reason) {
// This catches: require(cond, "msg"), revert("msg")
// This does NOT catch: revert InsufficientBalance(100, 50)
emit WithdrawFailed(reason);
}
// Custom error from withdraw() propagates as if try/catch wasn't there!
// CORRECT β use catch (bytes memory) to catch everything
try target.withdraw(amount) {
} catch Error(string memory reason) {
emit WithdrawFailed(reason);
} catch (bytes memory data) {
// Custom errors land here
emit WithdrawFailedRaw(data);
}
Mistake 2: Using try/catch on internal calls
// WRONG β try/catch only works on EXTERNAL calls
function process() internal {
try this.internalHelper() { } catch { }
// ^^^^ This won't compile β internalHelper is internal
}
// ALSO WRONG β calling yourself externally just to use try/catch
function process() external {
try this.riskyOperation() { } catch { }
// ^^^^ This "works" but creates an unnecessary external call
// with its own gas cost and msg.sender change
}
try/catch requires an external call β itβs built on top of the CALL opcodeβs success/failure mechanism. For internal error handling, use regular if checks or low-level call patterns.
Mistake 3: Modifying state before the try block
// DANGEROUS β state changes before try persist even if the try fails
function deposit(uint256 amount) external {
balances[msg.sender] += amount; // This persists!
try token.transferFrom(msg.sender, address(this), amount) {
// Transfer succeeded
} catch {
// Transfer failed β but balance was already updated!
// Now user has credit without depositing tokens
}
}
// CORRECT β modify state after confirming success
function deposit(uint256 amount) external {
try token.transferFrom(msg.sender, address(this), amount) {
balances[msg.sender] += amount; // Only on success
} catch {
revert DepositFailed();
}
}
Remember: try/catch catches the sub-callβs revert, but your own frameβs state changes persist. This is the same frame-level rollback behavior from the EVM Failure Modes section β only the called frameβs changes are rolled back.
π‘ Concept: What Try/Catch Cannot Catch
Why this matters: try/catch has fundamental limitations that stem from how the EVM works. Knowing these boundaries prevents you from building on false assumptions β thinking youβve handled all error cases when you havenβt.
Limitation 1: Cannot catch out-of-gas in the current frame
try target.doSomething{gas: 100000}() {
// success
} catch {
// Catches: reverts inside doSomething
// Does NOT catch: running out of gas AFTER the try/catch returns
}
// If the outer function itself runs out of gas, there's no try/catch
// that can save it β the entire transaction reverts
The 63/64 rule means the outer frame retains ~1.5% of gas, which is usually enough to enter the catch block. But if the catch block itself needs significant gas (storage writes, events), it might fail too.
Limitation 2: Cannot catch errors in the same contract without external call
contract MyContract {
function risky() public pure {
revert("boom");
}
function safe() external {
// WRONG β can't try/catch an internal call
// try risky() { } catch { } // Won't compile
// WORKS but wasteful β external call to self
try this.risky() {
} catch {
// Caught, but paid for external call overhead
// Also: msg.sender changed to address(this)
}
}
}
Limitation 3: Creation failures consume all CREATE gas
// Using try/catch with new:
try new Token(name) returns (Token token) {
// Deployment succeeded
} catch (bytes memory data) {
// Constructor reverted β you get the error data
// But the CREATE gas is consumed
}
This works since Solidity 0.6.0, but note that you pay for the creation attempt even on failure.
Limitation 4: The gas bomb problem
A malicious contract can return huge amounts of data in its revert:
contract Malicious {
function attack() external pure {
assembly {
// Return 1MB of revert data
revert(0, 1048576)
}
}
}
contract Victim {
function callMalicious(address target) external {
try Malicious(target).attack() {
} catch (bytes memory data) {
// data is 1MB β copying it into memory costs a LOT of gas
// The memory expansion cost can drain all remaining gas
}
}
}
This is the βreturnbombβ attack. When your catch clause accepts bytes memory data, Solidity copies all returndata into memory. If the returndata is maliciously large, the memory expansion cost can consume all your gas. The defense is to limit returndata size using assembly-level returndatacopy or use libraries like Solady that cap the copy size.
π DeFi Pattern Connection
Where try/catch limitations matter in DeFi:
-
Oracle fallbacks Lending protocols use try/catch to query oracles: if the primary oracle (Chainlink) reverts or returns stale data, fall back to a secondary oracle (TWAP). The catch clause must handle both string errors (old oracles) and custom errors (new oracles), and must guard against the returnbomb from a compromised oracle.
-
Hook systems (Uniswap V4) When the PoolManager calls a hook contract, it calls the hook directly β without try/catch. If a hook reverts, the entire transaction reverts. This is by design: hooks are considered part of the poolβs logic, not optional plugins. The implication for error handling is that hook developers must ensure their code doesnβt revert unexpectedly, since thereβs no graceful degradation path.
-
Token approval race conditions Some protocols try/catch the first
approve(0)call before setting a new approval. If the token doesnβt require zero-first (most donβt), the catch block handles the revert gracefully.
The pattern: try/catch in DeFi is primarily used for graceful degradation β oracle fallbacks, optional features, and non-critical operations that shouldnβt abort the main transaction.
πΌ Job Market Context
What DeFi teams expect you to know:
-
βHow would you implement oracle fallback logic?β
Answer
- Good answer: Use try/catch around the primary oracle call, fall back to secondary on failure
- Great answer: Use try/catch with
catch (bytes memory)to handle all error formats, cap returndata size to prevent returnbomb attacks, validate the fallback oracleβs response freshness, and consider the gas budget β ensure the catch path has enough gas for the fallback call
-
βWhat are the limitations of try/catch in Solidity?β
Answer
- Good answer: Only works on external calls, canβt catch out-of-gas in current frame
- Great answer: Only works on external calls, canβt catch out-of-gas in current frame,
catch Errordoesnβt catch custom errors, state changes before the try persist in the catch path, and the returnbomb vulnerability when catching arbitrary bytes from untrusted contracts
Interview Red Flags:
- π© Using try/catch with only
catch Error(string)and thinking all errors are handled - π© Not knowing that state changes before
trypersist in thecatchblock - π© Not considering the returnbomb attack when catching errors from untrusted contracts
Pro tip: When you see try/catch in a protocolβs code, immediately ask: βWhat error formats can the callee produce, and does this catch clause handle all of them?β This one question catches a surprising number of bugs in audits.
π Key Takeaways: Try/Catch
After this section, you should be able to:
- List the four catch clause forms (Error, Panic, bytes, bare), explain which error format each matches, and describe the matching order β including the subtlety that malformed encoding falls through to the bytes clause
- Identify the βcustom errors arenβt caught by catch Errorβ trap and write try/catch blocks that handle all error formats using
catch (bytes memory) - Explain why state changes before the try block persist in the catch path and design deposit/withdrawal patterns that avoid the resulting consistency bugs
- Describe the returnbomb attack, explain why itβs possible through try/catch, and name the defense (capping returndata copy size)
- Design an oracle fallback pattern using try/catch that handles all error formats, guards against returnbomb, and ensures enough gas for the fallback path
Check your understanding
- Four catch clauses and matching order:
catch Error(string)matchesError(string)selector.catch Panic(uint256)matchesPanic(uint256)selector.catch (bytes memory)catches everything else including custom errors and malformed data. Barecatchcatches everything but gives no access to the error data. Malformed encoding (e.g., truncated string) falls through to the bytes clause. - Custom errors and try/catch: Custom errors are NOT caught by
catch Error(string)β they fall through tocatch (bytes memory)or barecatch. This is a common trap: if you only havecatch Errorandcatch Panicclauses, custom errors from the callee will cause an unhandled revert. - State persistence in catch path: State changes made BEFORE the
trystatement persist even if the catch path executes. This can cause consistency bugs: if you update a balance before the try and the call fails, the balance is still modified. Design patterns must account for this β update state after confirmed success. - Returnbomb attack: A malicious callee returns megabytes of data, forcing the caller to pay for memory expansion when
catch (bytes memory)copies all returndata into memory. Defense: capreturndatasize()before copying, or use assembly-levelreturndatacopywith a bounded size. - Oracle fallback pattern: Use
try oracle.latestRoundData() returns (...)with acatch (bytes memory)clause that falls back to a secondary oracle. Cap returndata size to prevent returnbomb, and usegasleft()checks to ensure enough gas remains for the fallback path.
π‘ Decoding & Detection
Youβve learned how errors are encoded, how they propagate, and how to catch them. Now the practical skill: taking raw revert bytes and turning them into something useful. This is what you do when debugging failed transactions, building error-handling middleware, or writing comprehensive Foundry tests.
π‘ Concept: Decoding Raw Revert Data
Why this matters: When you catch error data from a low-level call or a catch (bytes memory) clause, you have raw bytes. To react meaningfully β log the error, retry with different parameters, or surface it to users β you need to decode those bytes back into structured data.
The universal decoding algorithm:
function decodeError(bytes calldata data) external pure returns (string memory) {
// Case 1: No data
if (data.length == 0) {
return "empty revert (bare revert, INVALID, or out-of-gas)";
}
// Case 2: Too short for a selector
if (data.length < 4) {
return "malformed revert data (< 4 bytes)";
}
// Extract selector (calldata slicing works cleanly here)
bytes4 selector = bytes4(data[:4]);
// Case 3: String error
if (selector == 0x08c379a0) {
// Skip selector, decode β abi.decode handles the offset pointer automatically
string memory reason = abi.decode(data[4:], (string));
return reason;
}
// Case 4: Panic code
if (selector == 0x4e487b71) {
uint256 code = abi.decode(data[4:], (uint256));
if (code == 0x01) return "Panic: assert failed";
if (code == 0x11) return "Panic: overflow";
if (code == 0x12) return "Panic: division by zero";
if (code == 0x32) return "Panic: index out of bounds";
return "Panic: unknown code";
}
// Case 5: Custom error β need ABI to decode further
return "custom error (use selector for ABI lookup)";
}
Note: this function uses calldata because calldata slicing (data[4:]) is clean and gas-efficient. If youβre working with bytes memory from a low-level call, you need assembly to extract the selector and slice:
bytes4 selector;
assembly { selector := mload(add(data, 0x20)) }
In practice, a simpler approach for bubbling:
Most of the time you donβt need to decode β you just need to forward the raw bytes:
(bool success, bytes memory data) = target.call(payload);
if (!success) {
// Don't decode β just bubble
assembly {
revert(add(data, 0x20), mload(data))
}
}
Decoding is for when you need to make decisions based on the error type, or when you need to log/display the error.
Decoding known custom errors:
When you know the error signature, decoding is straightforward:
error InsufficientBalance(uint256 required, uint256 actual);
(bool success, bytes memory data) = target.call(payload);
if (!success && data.length >= 4) {
bytes4 selector;
assembly { selector := mload(add(data, 0x20)) }
if (selector == InsufficientBalance.selector) {
// Slice off the selector and decode
(uint256 required, uint256 actual) = abi.decode(
_sliceAfterSelector(data), (uint256, uint256)
);
// Now you can use required and actual
emit BalanceShortfall(required, actual);
}
}
Slicing the selector off:
Thereβs no built-in way to slice bytes memory in Solidity. The common pattern:
// Assembly approach (gas-efficient)
// WARNING: mutates memory in place β original `data` is corrupted after this call
function _sliceAfterSelector(bytes memory data) internal pure returns (bytes memory result) {
assembly {
result := add(data, 0x04) // shift pointer past selector
mstore(result, sub(mload(data), 4)) // adjust length (overwrites selector bytes)
}
}
// Or use abi.decode with the offset trick:
// abi.decode expects data WITHOUT the selector, so you need to slice
π» Quick Try:
Build a minimal error decoder in Remix:
contract Decoder {
function decodeRevert(bytes calldata data) external pure returns (string memory) {
if (data.length == 0) return "empty";
if (data.length < 4) return "too short";
bytes4 sel = bytes4(data[:4]);
if (sel == 0x08c379a0) {
// String error β decode the reason
string memory reason = abi.decode(data[4:], (string));
return string.concat("Error: ", reason);
}
if (sel == 0x4e487b71) {
uint256 code = abi.decode(data[4:], (uint256));
if (code == 0x01) return "Panic: assert failed";
if (code == 0x11) return "Panic: overflow";
if (code == 0x12) return "Panic: division by zero";
return "Panic: unknown code";
}
return "custom error";
}
}
Feed it the raw bytes from earlier Quick Tries and verify it correctly identifies each error type. Note how data[4:] (calldata slicing) works cleanly here β this is one advantage of calldata over memory for byte slicing.
π Deep Dive: Building a Universal Error Decoder
A production-grade error decoder handles edge cases that the simple version above doesnβt:
1. Decoding nested errors (error wrapping):
Some protocols wrap errors with additional context:
error SwapFailed(address pool, bytes innerError);
// When caught:
// data = SwapFailed.selector + abi.encode(pool, innerError)
// innerError itself might be another encoded error
To decode nested errors, you decode the outer error first, then recursively decode the innerError bytes. This is how Foundry shows nested error traces.
2. Using abi.decode with calldata slicing:
Solidity 0.8.x supports calldata slicing (data[4:]), which is cleaner and cheaper than memory slicing:
function handleError(bytes calldata data) external pure {
if (bytes4(data[:4]) == InsufficientBalance.selector) {
(uint256 required, uint256 actual) = abi.decode(
data[4:], // calldata slice β no copy needed
(uint256, uint256)
);
}
}
But this only works with calldata parameters β not bytes memory from a low-level callβs return. For memory bytes, you need the assembly slice or a helper library.
3. Selector lookup services:
When you have an unknown selector, tools can help:
cast 4byte <selector>(Foundry) β reverse-looks up a selector from the 4byte.directory databasecast 4byte-decode <calldata>(Foundry) β decodes entire calldata or error data given the raw hex- Etherscanβs βDecodeβ button on transaction reverts
- Tenderlyβs transaction trace view shows decoded errors automatically
π‘ Concept: Foundry Error Testing
Why this matters: Foundryβs testing framework has specific cheatcodes and patterns for testing error conditions. Knowing these patterns lets you write comprehensive tests that verify not just that a function reverts, but that it reverts with the exact right error.
vm.expectRevert β the core cheatcode:
// Expect any revert (don't care about the error)
vm.expectRevert();
target.functionThatReverts();
// Expect a specific string error
vm.expectRevert("insufficient balance");
target.withdraw(tooMuch);
// Expect a specific custom error
vm.expectRevert(abi.encodeWithSelector(
InsufficientBalance.selector, 1000, 500
));
target.withdraw(1000);
// Shorthand for custom errors (Foundry convenience)
vm.expectRevert(InsufficientBalance.selector);
target.withdraw(1000); // Only checks selector, not params
Testing specific panic codes:
// Expect arithmetic overflow
vm.expectRevert(abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x11)));
target.overflowingFunction();
// Or use the stdError library (forge-std)
import {stdError} from "forge-std/StdError.sol";
vm.expectRevert(stdError.arithmeticError); // Panic(0x11)
target.overflowingFunction();
vm.expectRevert(stdError.divisionError); // Panic(0x12)
target.divideByZero();
vm.expectRevert(stdError.indexOOBError); // Panic(0x32)
target.accessOutOfBounds();
vm.expectRevert(stdError.assertionError); // Panic(0x01)
target.failedAssert();
Testing that a function does NOT revert:
Thereβs no vm.expectNoRevert() β just call the function normally. If it reverts, the test fails automatically.
Testing revert data from low-level calls:
function test_lowLevelCallError() public {
(bool success, bytes memory data) = address(target).call(
abi.encodeWithSelector(target.restricted.selector)
);
assertFalse(success);
bytes4 selector;
assembly { selector := mload(add(data, 0x20)) }
assertEq(selector, Unauthorized.selector);
// Decode and verify params
(address caller) = abi.decode(
_sliceAfterSelector(data), (address)
);
assertEq(caller, address(this));
}
Testing error messages in fuzz tests:
function testFuzz_withdrawRevertsOnInsufficientBalance(uint256 amount) public {
uint256 balance = target.balanceOf(address(this));
vm.assume(amount > balance);
vm.expectRevert(abi.encodeWithSelector(
InsufficientBalance.selector, amount, balance
));
target.withdraw(amount);
}
The vm.assume filters out fuzz inputs where amount <= balance (which wouldnβt revert). The remaining inputs all trigger the expected error with the exact parameters.
π» Quick Try:
Write a Foundry test file and run it:
// test/ErrorTest.t.sol
import "forge-std/Test.sol";
import {stdError} from "forge-std/StdError.sol";
contract Target {
error Unauthorized(address caller);
function restricted() external view {
revert Unauthorized(msg.sender);
}
function overflow() external pure returns (uint256) {
return type(uint256).max + 1;
}
}
contract ErrorTest is Test {
Target target;
function setUp() public {
target = new Target();
}
function test_customError() public {
vm.expectRevert(abi.encodeWithSelector(
Target.Unauthorized.selector, address(this)
));
target.restricted();
}
function test_overflow() public {
vm.expectRevert(stdError.arithmeticError);
target.overflow();
}
}
Run with forge test -vv and verify both tests pass. The -vv flag shows the expected and actual revert data on failure β invaluable for debugging.
π Key Takeaways: Decoding & Detection
After this section, you should be able to:
- Implement the four-way error decoding algorithm (empty β too short β string/panic by selector β custom error) and handle each case appropriately
- Decode custom error parameters from raw bytes using calldata slicing (
data[4:]) or assembly-based memory slicing, given the errorβs known signature - Write Foundry tests using
vm.expectRevertwith exact custom error encoding, selector-only matching, andstdErrorconstants for panic codes - Explain the difference between bubbling raw error bytes (assembly revert) and decoding them (abi.decode), and choose the right approach based on whether you need to inspect the error or just forward it
- Use Foundryβs
-vvverbosity flag andcast 4byteto debug unknown error selectors in failed transactions
Check your understanding
- Four-way decoding algorithm: Check length == 0 (empty revert), length < 4 (too short for selector), then match selector against
0x08c379a0(string error) and0x4e487b71(panic). Anything else is a custom error β decode parameters using the known signature or look up the selector viacast 4byte. - Decoding custom error parameters: Use
abi.decode(data[4:], (type1, type2))in Solidity ordata[4:]calldata slicing. In assembly, skip the first 4 bytes withadd(data, 0x24)(0x20 length prefix + 0x04 selector) and read parameters from there. - Foundry test patterns for errors:
vm.expectRevert(abi.encodeWithSelector(CustomError.selector, param1))for exact matching,vm.expectRevert(CustomError.selector)for selector-only matching, andvm.expectRevert(stdError.arithmeticError)for panic codes. These must be called immediately before the reverting call. - Bubbling vs decoding: Bubbling (
revert(add(data, 0x20), mload(data))) forwards raw bytes without inspecting them β use when you just want to propagate the error. Decoding (abi.decode) extracts structured data β use when you need to inspect, log, or react differently based on the error type. - Debugging with Foundry:
-vvshows revert reasons in test output,-vvvvshows full call traces with revert data.cast 4byte <selector>looks up the function/error signature from the 4byte directory, letting you identify unknown errors from on-chain transactions.
π‘ DeFi Error Patterns
Every concept so far β failure modes, encoding, propagation, try/catch, decoding β comes together in production DeFi code. The patterns below show how real protocols handle errors at scale: across batched calls, within flash loans, through aggregator chains, and in time-critical liquidation bots. These are the patterns youβll read in audits, implement in protocol code, and discuss in interviews.
π‘ Concept: Multicall Error Strategies
Why this matters: Multicall contracts batch multiple calls into a single transaction. The core design decision is what happens when one call in the batch fails β do you revert the entire batch, or do you skip the failure and continue? Production protocols offer both options, and the choice has real consequences for users and integrators.
The problem:
You have 10 token approvals batched into one transaction. Approval #7 fails because the token contract is paused. Should approvals 1β6 and 8β10 still go through, or should the entire transaction revert?
Thereβs no universally correct answer β it depends on whether the calls are independent or interdependent.
Strategy 1: Revert-all (strict)
contract StrictMulticall {
function multicall(bytes[] calldata data) external returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
// delegatecall to self β preserves msg.sender context
(bool success, bytes memory result) = address(this).delegatecall(data[i]);
if (!success) {
// Bubble the original error from the failed call
assembly {
revert(add(result, 0x20), mload(result))
}
}
results[i] = result;
}
}
}
One failure β entire batch reverts. The failed callβs error data propagates unmodified to the caller.
When to use: When calls are interdependent. Example: Uniswap V3βs multicall batches mint + refund β if minting fails, the refund is meaningless.
Strategy 2: Try-each (lenient)
contract TryEachMulticall {
struct Result {
bool success;
bytes returnData;
}
function tryMulticall(bytes[] calldata data) external returns (Result[] memory results) {
results = new Result[](data.length);
for (uint256 i = 0; i < data.length; i++) {
(results[i].success, results[i].returnData) = address(this).delegatecall(data[i]);
// No revert β record and continue
}
}
}
Failed calls are recorded, successful calls persist. The caller gets an array of results and must check each .success field.
When to use: When calls are independent. Example: a portfolio rebalancer executing multiple swaps β one failed swap shouldnβt block the others.
Strategy 3: Hybrid (Uniswap V3 pattern)
Uniswap V3βs actual Multicall contract offers both options in a single deployment:
// From Uniswap V3 Periphery β Multicall.sol
function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
(bool success, bytes memory result) = address(this).delegatecall(data[i]);
if (!success) {
if (result.length < 68) revert();
assembly {
result := add(result, 0x04)
}
revert(abi.decode(result, (string)));
}
results[i] = result;
}
}
Notice the error handling: if the revert data is shorter than 68 bytes (4-byte selector + 32-byte offset + 32-byte length = minimum for an Error(string)), it does a bare revert. Otherwise, it strips the selector, decodes the string, and re-reverts with revert(string). This rewraps the error as Error(string).
The trade-off: Uniswapβs approach normalises all errors into Error(string) format, losing custom error parameters. This was acceptable in V3 because the periphery contracts mostly used string requires. In V4 (with hooks that can define arbitrary custom errors), this pattern wouldnβt preserve error data.
Multicall with deadline (Uniswap V3 Periphery extension):
function multicall(uint256 deadline, bytes[] calldata data)
external
payable
override
returns (bytes[] memory)
{
require(block.timestamp <= deadline, "Transaction too old");
return multicall(data);
}
The deadline check happens before the loop, so a stale transaction fails fast without wasting gas on individual calls.
π How to Study Multicall Contracts
- Start with the interface β look at what return type the function uses.
bytes[]means revert-all; a struct array withsuccessfields means try-each. - Find the error handling β look inside the loop for
if (!success). Assembly revert = bubbling. Decode + re-revert = rewrapping. No check = lenient. - Check for delegatecall vs call β
delegatecallpreservesmsg.senderand storage context (used when multicalling your own contract).callis for multicalling external contracts. - Look for pre-loop checks β deadlines, paused state, or access control that runs once before the batch.
π DeFi Pattern Connection
Where multicall error strategies matter in DeFi:
-
Uniswap V3/V4 Periphery All user-facing operations (mint, swap, collect) go through multicall. Understanding the error behaviour lets you batch operations safely and debug failed batches.
-
Gnosis Safe (Safe) multi-send Safeβs
multiSenduses a different pattern: it encodes operation type (call/delegatecall) per transaction. Failed sub-transactions revert the entire multi-send by default, but optional transactions can be flagged. -
Yield aggregators Protocols like Yearn batch harvest calls across multiple strategies. A failing strategy shouldnβt block others from harvesting, so they use the try-each pattern with error logging.
The pattern: If calls are interdependent β revert-all. If calls are independent β try-each with result tracking. Most protocols offer revert-all as default and try-each as an option.
π‘ Concept: Error Handling in Flash Loans
Why this matters: Flash loans are the highest-stakes error handling scenario in DeFi. The entire operation β borrow, use, repay β must succeed atomically within a single transaction. Any error in the userβs callback or in the repayment check must revert the entire transaction, including the initial transfer. The error handling is what makes flash loans βsafeβ β without it, tokens would leave the pool without guarantee of return.
The flash loan execution flow:
Pool.flashLoan(amount, callbackData)
β
βββ 1. Transfer tokens to borrower
βββ 2. Call borrower.onFlashLoan(amount, fee, data)
β β
β βββ Borrower executes arbitrary logic
β βββ (swap, arbitrage, liquidation, etc.)
β βββ Returns success indicator
β
βββ 3. Verify repayment (balance check or transferFrom)
β βββ If balance < borrowed + fee β REVERT
β
βββ Everything reverts atomically if any step fails
Aave V3βs flash loan error handling:
// Simplified from Aave V3 β FlashLoanLogic.sol
function executeFlashLoan(...) external {
// 1. Transfer tokens to receiver
IAToken(reserveData.aTokenAddress).transferUnderlyingTo(receiverAddress, amount);
// 2. Call receiver's callback
require(
IFlashLoanReceiver(receiverAddress).executeOperation(
assets, amounts, premiums, msg.sender, params
),
Errors.INVALID_FLASHLOAN_EXECUTOR_RETURN
);
// 3. Verify repayment via transferFrom
// If receiver didn't approve enough, this reverts
IERC20(asset).safeTransferFrom(receiverAddress, aTokenAddress, amountPlusFlashLoanFee);
}
Three layers of error protection:
-
Callback return value β the receiver must return
true. If the callback reverts, that revert propagates. If it returnsfalse, Aaveβsrequirecatches it. This prevents contracts that donβt properly implement the interface from silently swallowing the callback. -
transferFrom for repayment β instead of checking balances (which can be manipulated), Aave pulls the repayment. If the receiver didnβt
approveenough tokens,safeTransferFromreverts. -
Atomic transaction β since everything runs in one transaction, failure at step 2 or 3 undoes step 1 (the initial transfer).
Uniswap V2 flash swap error handling:
// Simplified from Uniswap V2 β UniswapV2Pair.sol
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external {
// 1. Optimistically transfer tokens
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
// 2. If data is non-empty, it's a flash swap β call the callback
if (data.length > 0) {
IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
}
// 3. Invariant check β k must not decrease
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
require(
balance0 * balance1 >= reserve0 * reserve1,
"UniswapV2: K"
);
}
Uniswap V2 uses a different strategy: optimistic transfer + invariant check. It sends the tokens first, calls the borrower, then verifies the constant product formula holds. If the borrower didnβt repay enough, K decreases and the require fails, reverting everything.
Key difference from Aave: Uniswap doesnβt check a return value from the callback β it only checks the invariant. This means a callback that reverts still propagates correctly (atomic revert), but a callback that succeeds without repaying also gets caught by the invariant check.
Common error in flash loan receivers:
// WRONG β forgetting to return true
function executeOperation(...) external returns (bool) {
// ... do arbitrage ...
// forgot: return true;
// Solidity returns false (default for bool) β Aave's require fails
}
// WRONG β not handling the fee
function executeOperation(...) external returns (bool) {
uint256 amountOwed = amounts[0] + premiums[0]; // amount + fee
// Only approve 'amounts[0]', not 'amountOwed'
IERC20(assets[0]).approve(msg.sender, amounts[0]); // Missing fee!
return true;
// Aave's transferFrom will revert β not enough approved
}
π DeFi Pattern Connection
Where flash loan error handling matters in DeFi:
-
Arbitrage bots Your bot takes a flash loan, executes a multi-hop swap, and repays. If any hop fails (slippage, liquidity change), the entire flash loan reverts β you lose gas but not principal. The atomicity guarantee is what makes flash loan arbitrage risk-free (except for gas).
-
Liquidation Flash-loan-funded liquidations borrow the repayment asset, liquidate the position, receive collateral, swap collateral back, and repay. Error at any step β full revert. The error propagation chain is: swap fails β callback reverts β flash loan reverts β liquidation never happened.
-
Flash mint (ERC-3156) Stablecoins like DAI can be flash-minted: mint tokens, use them, burn them in the same transaction. The error handling must ensure minted tokens are burned even if the callbackβs custom logic fails β otherwise youβve created tokens from nothing.
The pattern: Flash loans rely on atomic revert guarantees. The pool doesnβt need to trust the borrower because any failure in the callback or repayment reverts the initial transfer. This is error propagation as a security mechanism.
π‘ Concept: Router & Aggregator Error Bubbling
Why this matters: Routers and aggregators sit between users and protocols, forwarding calls and translating errors. When a swap fails three layers deep (user β aggregator β DEX router β pool), the error must bubble up intact so the user (or their frontend) can understand what went wrong. How routers handle this bubbling determines whether users see βexecution revertedβ or βInsufficientLiquidity(0x1234β¦)β.
The layered call problem:
User's wallet
βββ Aggregator.swap() β catches and re-throws
βββ Router.exactInputSingle() β bubbles or wraps
βββ Pool.swap() β original error source
βββ REVERT InsufficientLiquidity()
At each layer, the error can be:
- Bubbled β forwarded as-is (preserves original error)
- Wrapped β caught and re-reverted with additional context
- Swallowed β caught and replaced with a generic error (information lost)
Uniswap V3 Router error bubbling:
// Simplified from Uniswap V3 β SwapRouter.sol
function exactInputSingle(ExactInputSingleParams calldata params)
external
payable
override
returns (uint256 amountOut)
{
amountOut = exactInputInternal(
params.amountIn,
params.recipient,
params.sqrtPriceLimitX96,
SwapCallbackData({path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender})
);
require(amountOut >= params.amountOutMinimum, "Too little received");
}
The router adds its own check (amountOutMinimum) on top of the poolβs internal checks. If the pool reverts, that error propagates through exactInputInternal automatically (Solidity bubbles reverts from internal calls). If the pool succeeds but the output is too low, the routerβs own require fires.
The aggregator pattern β wrapping errors with context:
contract Aggregator {
error SwapFailed(address dex, bytes reason);
error AllRoutesFailed();
function swap(Route[] calldata routes) external returns (uint256 bestOutput) {
bytes memory lastError;
for (uint256 i = 0; i < routes.length; i++) {
(bool success, bytes memory result) = routes[i].dex.call(
abi.encodeCall(IRouter.swap, (routes[i].params))
);
if (success) {
uint256 output = abi.decode(result, (uint256));
if (output > bestOutput) bestOutput = output;
} else {
lastError = result;
// Don't revert β try next route
}
}
if (bestOutput == 0) {
// All routes failed β bubble the last error with context
if (lastError.length > 0) {
revert SwapFailed(routes[routes.length - 1].dex, lastError);
}
revert AllRoutesFailed();
}
}
}
This is a simplified illustration of the try-multiple-routes pattern used by aggregators like 1inch and Paraswap. In production, aggregators typically simulate routes off-chain (via eth_call) to find the best output, then execute only the winning route on-chain. The pattern here shows the error handling strategy β try routes, record failures, only revert if all fail. The SwapFailed error wraps the original error bytes as a parameter, preserving the downstream error while adding the failing DEX address.
Decoding nested errors:
When an aggregator wraps errors, the revert data contains an error-within-an-error:
SwapFailed(address,bytes) selector: 0x........
βββ address dex: 0x1234...
βββ bytes reason:
βββ InsufficientLiquidity() selector: 0x........
To fully decode this, you need to:
- Decode the outer
SwapFailedto getdexandreason - Check
reason.lengthβ if β₯ 4, extract the inner selector - Decode the inner error using the poolβs ABI
This is why having structured custom errors matters β Error(string) at the inner level loses the structured data that the outer decoder might need.
π How to Study Router Contracts
- Trace a single swap end-to-end β pick
exactInputSingleand follow every call from router β pool β callback β token transfer. Note where errors can originate at each step. - Find the slippage check β routers always have a βminimum outputβ check. Find it and note whether it uses
requireor a custom error. - Look for try/catch vs low-level calls β routers that call multiple pools often use low-level
callso they can try alternative routes on failure. Direct pool interactions usually use high-level calls that auto-revert. - Check callback error handling β swap callbacks (like
uniswapV3SwapCallback) run inside the poolβs context. Errors in the callback propagate back to the pool, which propagates to the router. The chain must be unbroken.
π‘ Concept: Liquidation Bot Patterns
Why this matters: Liquidation bots operate in the most error-hostile environment in DeFi. They compete with other bots (MEV), execute against state that changes every block, and must handle errors gracefully because every failed transaction costs gas. The error handling patterns used by liquidation bots are the most battle-tested in the ecosystem.
The liquidation error landscape:
Liquidation attempt
β
βββ Price stale β Oracle revert
βββ Position already liquidated β Protocol revert
βββ Insufficient collateral seized β Slippage on swap
βββ Front-run by another bot β State changed between simulation and execution
βββ Gas price spike β Transaction pending too long, state changes
βββ Flash loan pool drained β Can't borrow repayment asset
Every one of these produces a different error, and a production bot must distinguish between them to decide whether to retry, skip, or adjust parameters.
Probe-first pattern:
You canβt staticcall a state-modifying function like liquidationCall β staticcall reverts on any state modification (SSTORE, LOG, token transfers), so it would always fail regardless of the positionβs health. Instead, production bots use view functions to probe whether a position is liquidatable before executing:
contract LiquidationBot {
error NotLiquidatable(address account, uint256 healthFactor);
error LiquidationUnprofitable(address account, int256 expectedProfit);
function liquidate(
address account,
bytes calldata swapData
) external returns (uint256 profit) {
// Step 1: Probe with a view function β no state modification
// Aave exposes getUserAccountData() which returns health factor
(,,,,, uint256 healthFactor) = lendingPool.getUserAccountData(account);
if (healthFactor >= 1e18) {
revert NotLiquidatable(account, healthFactor);
}
// Step 2: Estimate profit from oracle prices + liquidation bonus
int256 expectedProfit = _estimateProfit(account, swapData);
if (expectedProfit <= 0) {
revert LiquidationUnprofitable(account, expectedProfit);
}
// Step 3: Execute for real
profit = _executeLiquidation(account, swapData);
}
}
The view-function probe catches healthy positions and stale data before spending gas on the actual liquidation. This is the primary gas-saving strategy for liquidation bots.
Alternative: simulate-and-revert pattern
For protocols without convenient view functions, bots use the simulate-and-revert pattern off-chain. They call eth_call at the RPC level, which simulates the full transaction (including state modifications) without committing. This is done in the botβs off-chain code, not on-chain.
Error classification for retry logic:
function _classifyError(bytes memory errorData) internal pure returns (ErrorType) {
if (errorData.length < 4) return ErrorType.UNKNOWN;
bytes4 selector;
assembly {
selector := mload(add(errorData, 0x20))
}
// Errors that mean "don't retry this account"
if (selector == ILendingPool.HealthFactorAboveThreshold.selector) {
return ErrorType.NOT_LIQUIDATABLE; // Position is healthy
}
if (selector == ILendingPool.PositionAlreadyLiquidated.selector) {
return ErrorType.ALREADY_LIQUIDATED; // Someone beat us
}
// Errors that mean "retry with different parameters"
if (selector == IRouter.InsufficientOutput.selector) {
return ErrorType.SLIPPAGE; // Adjust swap route
}
// Errors that mean "retry next block"
if (selector == IOracle.StalePrice.selector) {
return ErrorType.STALE_ORACLE; // Wait for price update
}
return ErrorType.UNKNOWN;
}
This error classification pattern appears in every serious liquidation bot. The selector-based routing turns raw error bytes into actionable decisions: skip, adjust, or retry.
Gas-aware error handling:
function batchLiquidate(address[] calldata accounts) external {
for (uint256 i = 0; i < accounts.length; i++) {
// Low-level call β don't let one failure stop the batch
(bool success, bytes memory result) = address(this).call(
abi.encodeCall(this.liquidate, (accounts[i], ""))
);
if (!success) {
ErrorType errType = _classifyError(result);
if (errType == ErrorType.STALE_ORACLE) {
break; // Oracle is stale β no point trying more accounts
}
// For other errors, continue to next account
continue;
}
}
}
The batch function uses call instead of direct calls so that one failed liquidation doesnβt revert the entire batch. But itβs smart about it β a stale oracle affects all accounts, so it breaks the loop early instead of wasting gas on calls that will all fail.
Why bots use call to self:
// This looks strange:
(bool success, bytes memory result) = address(this).call(
abi.encodeCall(this.liquidate, (account, data))
);
Calling address(this).call(...) instead of this.liquidate(...) creates a new call frame. If liquidate reverts, only the inner call frame reverts β state changes in the outer function (like loop counters or profit tracking) persist. This is essential for batch operations where partial success is acceptable.
β οΈ Common Mistakes
Mistake 1: Not probing before executing
// WRONG β wastes gas on doomed liquidations
function liquidate(address account) external {
// Directly calls the lending pool β pays full gas if position is healthy
lendingPool.liquidationCall(collateral, debt, account, type(uint256).max, false);
}
// CORRECT β probe with a view function first
function liquidate(address account) external {
(,,,,, uint256 healthFactor) = lendingPool.getUserAccountData(account);
if (healthFactor >= 1e18) return; // Position is healthy, no gas wasted
lendingPool.liquidationCall(collateral, debt, account, type(uint256).max, false);
}
Mistake 2: Reverting the entire batch on one failure
// WRONG β one failed liquidation kills the whole batch
function batchLiquidate(address[] calldata accounts) external {
for (uint256 i = 0; i < accounts.length; i++) {
this.liquidate(accounts[i]); // Reverts propagate!
}
}
Use low-level call for batch operations, as shown above.
πΌ Job Market Context
What DeFi teams expect you to know:
-
βHow would you design the error handling for a DEX aggregator?β
Answer
- Good answer: Use low-level calls to try multiple routes, catch failures, fall back to alternative routes
- Great answer: Use low-level calls with error classification β distinguish slippage errors (retry with different parameters) from liquidity errors (skip this pool) from oracle errors (abort entirely). Wrap the original error bytes in a context-providing custom error so the caller can decode both the failing DEX and the root cause. Simulate off-chain first to avoid wasting gas on-chain.
-
βA user reports βexecution revertedβ with no message on your protocol. How do you debug it?β
Answer
- Good answer: Check the transaction on Etherscan, use Tenderly to get a trace
- Great answer: Empty revert data means either a bare
revert(), INVALID opcode (pre-0.8.0 assert or handwritten assembly), or out-of-gas. Check the gas used β if it consumed nearly all forwarded gas, itβs likely INVALID or OOG. Usecast run <txHash>or Tenderly to get the full call trace. If itβs a proxy, the error might originate in the implementation but surface through the proxyβs fallback. Check if the target address has code β a call to an empty address succeeds silently, which can cause downstream reverts with confusing data.
-
βHow do flash loans stay safe without trusting the borrower?β
Answer
- Good answer: The transaction is atomic β if repayment fails, everything reverts
- Great answer: The pool relies on the EVMβs frame-level revert guarantee. The initial transfer, callback, and repayment check all run in the same top-level transaction. Any revert β whether from the callback, the repayment
transferFrom, or a return-value check β rolls back the initial transfer. The pool doesnβt trust the borrowerβs code at all; it trusts the EVMβs atomicity. This is error propagation used as a security mechanism.
Interview Red Flags:
- π© Using
staticcallto βsimulateβ state-modifying functions (it always fails) - π© Not knowing that
catch Error(string)doesnβt catch custom errors - π© Reverting the entire batch when one operation in a multicall fails (unless calls are interdependent)
- π© Not handling the empty-returndata case when decoding errors from low-level calls
Pro tip: When reviewing a protocolβs error handling in an audit, trace one happy path and one revert path end-to-end through every call layer. Check: does the error data survive each hop? Is there a layer that swallows it? Is there a layer that adds context? The most common audit finding is error data getting lost at an intermediate layer.
π― Build Exercise: ErrorHandler
Workspace: workspace/src/deep-dives/errors/exercise1-error-handler/ErrorHandler.sol
Tests: workspace/test/deep-dives/errors/exercise1-error-handler/ErrorHandler.t.sol
Build a contract that demonstrates production-level error handling patterns. Youβll implement low-level call wrappers, strict and lenient multicall strategies, error classification by selector, and string error decoding β the core patterns from this deep dive.
5 TODOs:
-
tryCall(address target, bytes calldata data)β Execute a low-level call, return success status and raw result bytes. On failure, return the raw revert data without modification. -
multicallStrict(Call[] calldata calls)β Execute an array of calls. If any call fails, bubble the original error using assembly (revert with the raw error bytes). Return all results on success. -
multicallLenient(Call[] calldata calls)β Execute an array of calls. Never revert. Return an array ofResultstructs withsuccessandreturnDatafields for each call. -
classifyError(bytes memory errorData)β Given raw revert data, classify the error: returnErrorType.EMPTYif no data,ErrorType.STRING_ERRORif the selector matchesError(string),ErrorType.PANICif it matchesPanic(uint256),ErrorType.CUSTOMfor any other 4+ byte selector, andErrorType.UNKNOWNfor data shorter than 4 bytes but non-empty. -
decodeStringError(bytes memory errorData)β Given revert data with theError(string)selector, strip the first 4 bytes and ABI-decode the remaining bytes into a string. Revert withNotAStringError()if the selector doesnβt match.
π― Goal: Practice the three core error handling patterns (bubble, record, classify) that appear in every production DeFi protocol. After completing this exercise, youβll be able to read router and aggregator error handling code fluently.
π Key Takeaways: DeFi Error Patterns
After this section, you should be able to:
- Choose between revert-all and try-each multicall strategies based on whether batched calls are interdependent or independent, and implement both using low-level calls with assembly error bubbling
- Explain how flash loans use atomic revert guarantees as a security mechanism β the pool trusts the EVMβs revert behaviour, not the borrowerβs code
- Trace error propagation through a multi-layer call chain (user β aggregator β router β pool) and identify where errors are bubbled, wrapped, or swallowed at each layer
- Implement error classification by selector to drive retry logic in bots: distinguish between βskip this accountβ, βadjust parametersβ, and βretry next blockβ
- Use view-function probes (on-chain) and
eth_callsimulation (off-chain) to check whether an operation will succeed before spending gas on the real execution, and explain why this pattern is essential for liquidation bots - Design batch operations that use
address(this).call(...)to isolate failures, with smart early-exit conditions (like stale oracles) to avoid wasting gas
Check your understanding
- Multicall error strategies: Revert-all (strict) reverts the entire batch if any call fails β use when calls are interdependent (e.g., approve + swap). Try-each (lenient) catches individual failures and continues β use when calls are independent (e.g., batch claims). Both use low-level calls with assembly error bubbling for the revert-all case.
- Flash loan atomic revert guarantee: The pool transfers tokens to the borrower, calls the borrowerβs callback, then checks repayment. If repayment fails, the entire transaction reverts β including the initial transfer. The pool trusts the EVMβs atomicity, not the borrowerβs code. This is why flash loans are safe without collateral.
- Multi-layer error propagation: In a chain like user -> aggregator -> router -> pool, errors bubble up through each layer. At each boundary, errors may be bubbled raw (assembly revert), wrapped in a higher-level error (adding context), or swallowed (try/catch with fallback logic). Understanding where information is lost helps debug failed transactions.
- Error classification for bot retry logic: Parse the revert selector to classify errors: βskipβ (InsufficientBalance β this account is done), βadjustβ (SlippageExceeded β retry with different params), or βretryβ (StaleOracle β try next block). This prevents bots from wasting gas retrying unrecoverable failures.
- Pre-flight simulation: Use
eth_call(off-chain) or view-function probes (on-chain) to check if an operation will succeed before submitting the real transaction. Essential for liquidation bots where failed transactions waste gas in competitive MEV environments. - Batch isolation with address(this).call: Wrapping each operation in
address(this).call(abi.encodeCall(...))creates a sub-call that can revert independently without reverting the parent. Add early-exit conditions (e.g., check oracle freshness once before the loop) to avoid wasting gas on operations that will all fail for the same reason.
π Resources
Solidity Error Handling:
- Solidity Docs β Error Handling β official reference for require, revert, assert, and error propagation
- Solidity Docs β Errors and the Revert Statement β ABI specification for error encoding
- Solidity Blog β Custom Errors in 0.8.4 β original announcement with gas comparison data
EVM Internals:
- EVM Codes β REVERT β opcode reference with gas behaviour
- EVM Codes β INVALID β opcode reference
- EIP-140: REVERT Instruction β the EIP that introduced REVERT in Byzantium
- EIP-150: Gas Cost Changes β introduced the 63/64 gas forwarding rule
Production Code to Study:
- Uniswap V3 Multicall β the standard multicall with error rewrapping
- Uniswap V3 SwapRouter β router error handling and slippage checks
- Aave V3 FlashLoanLogic β flash loan error handling with callback verification
- OpenZeppelin Address.sol β utility functions for low-level call error bubbling
Foundry Testing:
- Foundry Book β vm.expectRevert β cheatcode reference for error testing
- Foundry Book β stdError β standard panic error constants
Advanced Topics:
- Returnbomb Attack β the
excessivelySafeCalllibrary and the returndata bomb vector - EIP-150 and the 63/64 Rule β why try/catch canβt reliably catch out-of-gas errors
Navigation: β Deep Dives Index