Error Handling & Failure Modes
The bug catalogue every senior security engineer carries in their head. For each: the shapes it takes, how to find it, how to formally rule it out, how to design it out.
The bug taxonomy
You should be able to recite the high-impact bug classes cold. The eight categories below cover roughly 90% of DeFi incidents over the past decade:
- Reentrancy (classic + variants)
- Oracle manipulation
- Math / rounding / precision
- Access control failures
- Donation / share-dilution / first-depositor
- MEV-induced griefing
- Cross-chain message ordering
- Upgradeability / storage layout / initialization
Reentrancy — all the flavors
Classic reentrancy
External call before state update; callee re-enters and exploits stale state. The DAO hack. Solved by CEI or nonReentrant.
// VULNERABLE
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool ok,) = msg.sender.call{value: bal}(""); // external call
require(ok);
balances[msg.sender] = 0; // state update after
}Cross-function reentrancy
Function A makes external call. Callee re-enters via function B (not A). A's nonReentrant doesn't help if B is on a different lock. Fix: shared lock across all state-touching functions.
Cross-contract reentrancy
Contract A calls Contract B. B re-enters A via a third contract that calls A. Cascading. Requires reasoning across multiple contracts.
Read-only reentrancy
The callee re-enters a view function on the original contract that other protocols depend on. The view returns stale state mid-call. Curve's 2023 incident hit several protocols this way. Mitigation: the view function should also revert if a reentrancy is in progress, or consumers should not call views from within state-modifying paths.
ERC-777 callback reentrancy
ERC-777 tokens trigger tokensReceived hooks on the recipient. If your contract receives such a token, the sender's send() call hands execution to the recipient's hook before your contract's internal state has been updated. Mitigation: avoid ERC-777 support without explicit guards, or use OpenZeppelin's pull-pattern.
ERC-721 onERC721Received
Safe transfers call onERC721Received on the recipient. Same pattern; same hazard. Marketplaces and lending protocols have shipped reentrancy bugs through this hook.
How to prove it out (CVL)
// rule: state updates happen before external calls (CEI)
rule effectsBeforeInteractions(method f) filtered { f -> !f.isView } {
env e;
calldataarg args;
// Track that no external call has been observed before any state modification
// (in practice: use hooks on Sstore / Call opcodes and ghost state)
// Sketch only — full proof requires hook scaffolding
f(e, args);
assert true; // placeholder
}Oracle manipulation
Single-block manipulation
Attacker takes a flash loan, swaps in a target AMM pool, reads the manipulated mid-price, exploits a protocol that uses that price as oracle, swaps back. All in one block. Cream Finance, bZx variants, many others.
Mitigation: don't use spot AMM price as oracle. Use Chainlink, or use a TWAP (time-weighted average) which is much harder to manipulate over a window.
Multi-block manipulation
Attacker controls block production for N consecutive blocks (rare on Ethereum, more plausible on validator-light L2s) and pushes a TWAP. Mitigation: longer TWAP windows; cross-source validation.
Donation manipulation
Attacker donates tokens directly to a pool (transfer, not deposit). Pool's internal accounting and on-chain balance diverge. Code that reads balanceOf(address(this)) as a proxy for "deposits" gets fooled. Mitigation: track deposits in storage explicitly; don't use raw balanceOf as accounting.
Flash-loan amplified manipulation
Flash loans give an attacker effectively-unlimited working capital within one block. Any oracle that's manipulable with capital becomes manipulable for free. Mitigation: oracle sources should be either off-chain (Chainlink) or TWAP-based with windows > 1 block.
How to design it out
Use Chainlink (with hardened wrapper from 06) for asset prices. Use protocol-internal exchange rates (vault.totalAssets() / vault.totalSupply()) only when these can't be manipulated within the call. Pin oracle reads to the start of the call; don't re-read after state changes.
Math bugs
Rounding direction
Convert assets-to-shares: round down. Convert shares-to-assets for withdrawal: round down (user receives less, protocol keeps the dust). For repayment / borrow tracking: round up against the user. The wrong direction lets users extract value via repeated tiny operations.
// OpenZeppelin ERC4626 standard pattern
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(totalSupply() + 10**decimalsOffset, totalAssets() + 1, Math.Rounding.Floor);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
return shares.mulDiv(totalAssets() + 1, totalSupply() + 10**decimalsOffset, Math.Rounding.Floor);
}Share inflation / first depositor
Empty vault, attacker deposits 1 wei → gets 1 share. Donates 10000 underlying → vault has 10001 assets, 1 share. Next depositor's 9999 wei rounds to 0 shares → attacker captures the deposit. Mitigation: virtual shares / dead shares / decimal offset.
Overflow (pre-0.8)
Pre-Solidity-0.8, uint256 wraps around on overflow. SafeMath was required. Modern code uses 0.8+, which reverts. But unchecked {} blocks disable checks; review them carefully.
Precision loss in fixed-point
DeFi typically uses fixed-point with 18 decimals (WAD) or 27 (RAY). The classic mistake: (a * b) / c if a*b overflows; or a / c * b which truncates before multiplying. Use mulDiv from OpenZeppelin or Solady.
How to prove it (CVL)
rule conversionRoundsAgainstUser() {
uint256 assets;
uint256 shares = convertToShares(assets);
uint256 backToAssets = convertToAssets(shares);
assert backToAssets <= assets; // user never extracts more than they deposited
}Access control failures
Missing modifier
A privileged function ships without onlyOwner. Anyone calls it; protocol drained. This still happens. Defense: a CVL parametric rule that every state-changing function either reverts for non-admin or doesn't touch admin storage.
Wrong msg.sender in delegatecall
Implementation behind a proxy: msg.sender is the proxy caller, not the proxy. A check like require(msg.sender == owner) in a library called via delegatecall works because the storage and context are the proxy's. But a library called via call gets a different sender. Confusion here ships bugs.
Frontrunnable initialize
Proxy deployed; implementation has an initialize() not yet called; an attacker calls it first, becomes owner. Mitigation: deploy + initialize in the same tx (factory pattern), or use OpenZeppelin's _disableInitializers() in the implementation constructor.
Privileged-role grant
Role-based access control (OZ AccessControl). Granting a powerful role through governance is normal; granting it through a less-protected function is a bug. Audit every grantRole path.
Donation / first-depositor share dilution
Already covered under math, but worth its own callout: this single bug class has cost DeFi nine figures cumulatively. Cream, Hundred Finance, several smaller vault protocols. Mitigation patterns:
- Virtual shares / decimal offset (OpenZeppelin ERC4626 default).
- Dead shares minted to address(0) on first deposit.
- Minimum first deposit enforced in the contract.
- Track deposits internally instead of reading balanceOf.
For new vault designs, default to the OpenZeppelin pattern. Audit how it composes with your fee logic — fee accounting can re-introduce the bug.
MEV-induced griefing
Not always loss-of-funds, but griefing through ordering. Examples:
- Sandwich attacks on AMM swaps. Attacker frontruns your swap, backruns it; you pay worse price.
- Liquidation MEV. Multiple liquidators race for the same position; protocol gets liquidated, healthy users see gas spikes.
- Just-in-time liquidity. Concentrated-liquidity AMMs see attackers add LP just before a known swap, capture fees, remove.
- Initialize / first-action front-running. Already covered.
Mitigations: slippage tolerance on user-facing calls; private mempool / order flow; commit-reveal for sensitive operations; auction mechanisms (intents, MEV-share).
Cross-chain message ordering
When messages cross between chains (LayerZero, Wormhole, native bridges, Hyperlane, Axelar), ordering and replay become first-class concerns:
- Replay. Same message delivered twice; same effect applied twice. Mitigation: per-message nonces stored on receiving chain.
- Out-of-order delivery. Message B (decrement) arrives before message A (deposit). Mitigation: causal ordering or commutative-only operations.
- Message forging. Validators / oracles compromised; arbitrary message forged. Wormhole 2022 was here. Mitigation: validator-set audit, threshold signatures, on-chain proof verification.
- Source-chain rollback. Optimistic rollups have a 7-day window; a finalized message on L2 might not be finalized on L1.
Worked example: for each bug class, the three motions
The triad every senior security engineer should be able to apply:
- How to find it — what does it look like in code? What's the signature pattern?
- How to formally rule it out — what CVL invariant / Foundry invariant test proves you don't have it?
- How to design it out — what architectural pattern makes the bug class unrepresentable?
Worked: donation attack
- Find: Vault contract; first-deposit handler; ratio of shares to assets calculated from balanceOf; no virtual shares.
- Prove: CVL invariant:
forall users: convertToAssets(convertToShares(x)) <= x; ANDtotalSupply > 0 => totalAssets > 0. - Design out: Apply OpenZeppelin ERC4626 with decimal offset; track deposits internally; mint 1000 wei of dead shares on first deposit.
Worked: read-only reentrancy
- Find: A view function whose return value is consumed by an external protocol. State updated after the call. The view returns a value derived from in-flight state.
- Prove: Hard to prove without modeling external callers. CVL hook on Sstore + Call: assert no external call within a function body modifies the view's storage between callers' reads.
- Design out: View function reverts under lock; or expose a "checkpoint" view that's only updated at end-of-tx; or use transient storage to expose only post-update values.