Control Flow & Error Handling
Control flow and error handling are where most beginner Solidity bugs live. The difference between require, revert, and assert is not just syntactic — each has dramatically different gas behavior, security properties, and appropriate use cases. This lesson covers every error-handling mechanism, common mistakes that cause real vulnerabilities, and the patterns that make contracts robust.
In traditional software, a bug in error handling might cause a crash or incorrect output. In Solidity, a bug in error handling can leave state partially changed, drain funds, or allow an attacker to bypass access control. When a function fails midway and doesn't revert properly, tokens may be transferred without corresponding accounting updates — a recipe for reentrancy or fund theft.
Conditional Logic: if/else and Gas Implications
contract ConditionalDemo {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// Gas note: SLOAD costs 2,100 gas (cold) — cache the value
uint256 bal = balances[msg.sender]; // one SLOAD
if (bal >= amount) {
balances[msg.sender] = bal - amount; // uses cached value (cheap)
payable(msg.sender).transfer(amount);
} else if (amount == 0) {
revert("amount is zero");
} else {
revert("insufficient balance");
}
}
// Nested conditions increase code complexity — keep flat when possible
// Prefer early returns (fail-fast pattern) over deep nesting
}Loops: for, while, do-while
// FOR loop: when you know the iteration count
for (uint256 i = 0; i < items.length; i++) {
process(items[i]);
}
// WHILE loop: when the count depends on runtime state
uint256 remaining = totalSupply;
while (remaining > 0) {
remaining -= distribute(remaining);
}
// DO-WHILE: executes at least once (rare in Solidity)
uint256 x = 1;
do {
x *= 2;
} while (x < 100);
// GAS NOTE: cache array length outside the loop
// BAD: for (uint i = 0; i < arr.length; i++) — SLOAD on every iteration
// GOOD: uint len = arr.length; for (uint i = 0; i < len; i++)
uint256 len = items.length; // one SLOAD
for (uint256 i = 0; i < len; i++) {
process(items[i]);
}The Unbounded Loop: Classic DoS Vulnerability
An unbounded loop is any loop whose upper bound is controlled by data that can be grown without limit — typically an array that users can append to. As the array grows, the gas cost of any function that iterates it grows proportionally until it exceeds the block gas limit, permanently bricking the function:
contract VulnerableDistributor {
address[] public recipients; // grows without limit
uint256 public rewardPerUser = 1 ether;
// Anyone can push their address
function register() external {
recipients.push(msg.sender); // no limit!
}
// Attack: push 10,000 addresses → this function OOGs permanently
function distributeRewards() external {
for (uint256 i = 0; i < recipients.length; i++) {
payable(recipients[i]).transfer(rewardPerUser);
}
}
}
// Fix: use pull-payment pattern — users claim individually
contract SafeDistributor {
mapping(address => uint256) public pendingRewards;
function claimReward() external {
uint256 amount = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0; // CEI pattern
payable(msg.sender).transfer(amount);
}
}require, revert, assert — The Three Pillars
| Mechanism | Gas Refund? | Use Case | Error Data? | Security Rule |
|---|---|---|---|---|
| require(cond, msg) | Yes — refunds remaining | Validate inputs, check external calls | String message | Use for ALL user input validation |
| revert("msg") | Yes — refunds remaining | Complex conditions, custom errors | String or custom error | Use in else branches or complex logic |
| assert(cond) | No — burns all gas (pre-0.8) | Internal invariants that must never fail | Panic code | NEVER use for input validation |
contract ErrorHandlingExamples {
uint256 public totalSupply;
mapping(address => uint256) public balances;
// CORRECT: require for user input validation
function transfer(address to, uint256 amount) external {
require(to != address(0), "zero address"); // input check
require(amount > 0, "zero amount"); // input check
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
balances[to] += amount;
// CORRECT: assert for INTERNAL invariant — should be mathematically impossible to fail
assert(balances[msg.sender] + balances[to] <= totalSupply);
}
// WRONG: assert used for input validation (burns all gas on bad input!)
function badMint(uint256 amount) external {
assert(amount > 0); // BUG: burns caller's gas instead of giving refund
totalSupply += amount;
balances[msg.sender] += amount;
}
}Custom Errors (Solidity 0.8.4+)
Custom errors are cheaper than string revert messages because they only encode a 4-byte selector (just like function selectors) rather than the full string. They also allow structured error data that off-chain tooling can decode:
// Define errors at the contract or file level
error InsufficientBalance(uint256 needed, uint256 available);
error ZeroAddress();
error Unauthorized(address caller);
contract CustomErrorDemo {
function withdraw(uint256 amount) external {
uint256 bal = balances[msg.sender];
if (bal < amount) {
revert InsufficientBalance(amount, bal);
// Returns: 0x4e487b71 + abi.encode(amount, bal)
// Selector decoded by ethers.js automatically
}
// ... proceed
}
// Gas comparison:
// require(false, "Insufficient balance"); → ~300 gas for string
// revert InsufficientBalance(x, y); → ~50 gas for selector only
}
// Testing custom errors with Foundry:
// vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, 100, 50));try/catch — Handling External Call Failures
interface IPriceFeed {
function getPrice() external view returns (uint256);
}
contract TryCatchDemo {
IPriceFeed public feed;
function safeGetPrice() external view returns (uint256 price) {
try feed.getPrice() returns (uint256 p) {
return p;
} catch Error(string memory reason) {
// Catches revert("string") and require(false, "string")
emit PriceFeedError(reason);
return fallbackPrice;
} catch Panic(uint256 code) {
// Catches assert() failures and arithmetic panics
// code 0x01 = assert, 0x11 = arithmetic overflow
return fallbackPrice;
} catch (bytes memory lowLevelData) {
// Catches custom errors and other reverts
return fallbackPrice;
}
}
// ANTI-PATTERN: empty catch silently ignores all failures
function badGetPrice() external view returns (uint256) {
try feed.getPrice() returns (uint256 p) {
return p;
} catch {} // BUG: returns 0 on failure — may trigger downstream errors
}
}Common Mistakes
A common audit finding is using > instead of >= or vice versa in require statements. For example, require(block.timestamp > deadline) when the intent is to allow actions up to and including the deadline. Always double-check boundary conditions: should the check be strict (>) or inclusive (>=)?
In Solidity 0.8.x, assert() triggers a Panic error which still refunds remaining gas. But in older Solidity (pre-0.8), assert() consumed ALL gas — meaning users who pass invalid input would lose their entire gas cost rather than getting a refund. Even in 0.8, using assert() for input validation is semantically wrong: reserve it strictly for internal invariants that represent programmer error.
An empty catch block silently swallows external call failures. If the external contract reverts, execution continues in the caller as if nothing happened. This can lead to incorrect state: your contract might record a successful operation when the external call actually failed. Always log failures, return a clear indicator, or revert if the external call result is critical.
The Fail-Early Security Pattern
// WRONG: validation scattered throughout function
function badMint(address to, uint256 amount) external {
balances[to] += amount; // state change BEFORE validation!
totalMinted += amount;
require(to != address(0)); // too late — state already changed
require(amount > 0);
emit Minted(to, amount);
}
// CORRECT: CEI pattern — Check, Effect, Interact
function goodMint(address to, uint256 amount) external {
// 1. CHECK — all validation upfront
require(to != address(0), "zero address");
require(amount > 0, "zero amount");
require(totalMinted + amount <= MAX_SUPPLY, "exceeds max");
// 2. EFFECT — state changes
balances[to] += amount;
totalMinted += amount;
// 3. INTERACT — external calls last
emit Minted(to, amount);
}Overflow and Underflow Behavior
// Solidity 0.8.x and above — arithmetic reverts on overflow
uint256 a = type(uint256).max;
uint256 b = a + 1; // REVERTS with Panic(0x11) — arithmetic overflow
// Solidity 0.7.x and below — arithmetic wraps silently
uint256 c = type(uint256).max;
uint256 d = c + 1; // d = 0 (wraps around — NO revert)
// Explicit unchecked block — use when you need wrap-around math
unchecked {
uint256 e = type(uint256).max + 1; // e = 0 (intentional wrap)
// Saves gas: avoids overflow checks — use only when bounds are proven
}
// Compile flag to check: look for pragma solidity in old contracts
// pragma solidity ^0.6.0; ← no overflow protection! SafeMath needed
// pragma solidity ^0.8.0; ← overflow reverts by defaultKey Takeaways
- Use
require()for all user-input validation — it reverts and refunds remaining gas. Userevert CustomError()for complex conditions where you need structured error data. - Reserve
assert()strictly for mathematical invariants that must be impossible to violate — never for checking user inputs. - Unbounded loops are a permanent DoS vector. Any loop over a user-growable array must be replaced with a pull-payment or pagination pattern.
- Custom errors (Solidity 0.8.4+) are 4-6x cheaper than string revert messages and provide structured, decodable error information.
- Empty
catchblocks silently swallow failures — always handle or propagate errors from external calls. - The fail-early (CEI) pattern validates all inputs at function entry before any state changes, preventing partial-state corruption on revert.
- Solidity 0.8.x reverts on arithmetic overflow/underflow by default. Use
uncheckedblocks only when you have proven the arithmetic cannot exceed bounds.