🌱 Beginner ⏱️ 35 min

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.

📖 Why Error Handling Is a Security Topic

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

🔀 Branching — if/else in Solidity
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

🔄 Loop Patterns and When to Use Each
// 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:

💣 Unbounded Loop DoS — Vulnerable Pattern
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

MechanismGas Refund?Use CaseError Data?Security Rule
require(cond, msg)Yes — refunds remainingValidate inputs, check external callsString messageUse for ALL user input validation
revert("msg")Yes — refunds remainingComplex conditions, custom errorsString or custom errorUse in else branches or complex logic
assert(cond)No — burns all gas (pre-0.8)Internal invariants that must never failPanic codeNEVER use for input validation
🛡️ require vs revert vs assert — Correct Usage
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:

💰 Custom Errors — Cheaper and More Informative
// 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

🧲 try/catch — Catching External 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

⚠️ Mistake 1: Wrong Condition in require (Off-By-One)

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 (>=)?

⚠️ Mistake 2: assert() for Input Validation

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.

🚨 Mistake 3: Empty catch Blocks

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

🛡️ Fail-Early: Validate All Inputs at Entry
// 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

✏️ Try It — Overflow Behavior by Solidity Version
// 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 default

Key Takeaways

  • Use require() for all user-input validation — it reverts and refunds remaining gas. Use revert 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 catch blocks 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 unchecked blocks only when you have proven the arithmetic cannot exceed bounds.