Integer Overflow Before Solidity 0.8
Reentrancy and The DAO was a bug of control flow: a contract handed control to an attacker mid-transaction and paid the price. This page is about a quieter enemy — a bug of arithmetic. Nothing re-enters, nobody calls back. A contract does one subtraction, the result is mathematically wrong, and an attacker walks away with a balance no one ever funded.
The uncomfortable truth is that on the EVM, a - b does not mean what a mathematician means by
a - b. It means “a - b, modulo 2²⁵⁶.” Before Solidity 0.8, that difference was silent: no
error, no revert, no warning — just a number that had wrapped around the edge of its type. This
page explains why the machine works that way (it has to), how that gap turned into real
token-minting exploits in 2018, and the two-stage fix — a library, then the compiler itself —
that finally closed it. It connects directly to the book’s throughline: untrusting strangers can
only agree on a shared state if every node computes the same arithmetic. Fixed-width modular math
is what makes that agreement possible — and what made these bugs possible.
Fixed-width arithmetic: numbers that wrap
Section titled “Fixed-width arithmetic: numbers that wrap”The EVM has exactly one integer type under the hood: a 256-bit word. Every uint256,
address, and hash is that same 32-byte slot. Solidity’s smaller types (uint8, uint128) are
conveniences the compiler masks down to fit; the machine itself only knows 256-bit words.
A 256-bit unsigned integer can hold values from 0 to 2²⁵⁶ − 1. That is a colossal range —
about 1.16 × 10⁷⁷, more than the number of atoms in the observable universe — but it is still
finite. And finite means there are edges. What happens at the edges is the whole story:
the uint256 number line is a CIRCLE, not a line:
0 ┌─────●─────┐ 2²⁵⁶-1 ● ● 1 │ wraps │ │ around │ └─────●─────┘ 2²⁵⁵ (halfway)
0 - 1 → 2²⁵⁶ - 1 (underflow: fall off 0, land at the top) (2²⁵⁶-1)+1 → 0 (overflow: fall off the top, land at 0) 2²⁵⁵ * 2 → 0 (multiply past the top, wrap to 0)The arithmetic is modular: results are computed modulo 2²⁵⁶. This is not a quirk of Ethereum —
it is how fixed-width integer hardware has always worked, and the EVM inherits it deliberately.
You saw the exact same behaviour in this book’s mini-EVM, where ADD uses Rust’s
wrapping_add:
// from ethmini/src/vm.rs — our machine wraps mod 2^64; real EVM wraps mod 2^256Op::Add => { let b = pop(&mut stack, "ADD")?; let a = pop(&mut stack, "ADD")?; push(&mut stack, a.wrapping_add(b))?; // u64::MAX + 1 → 0 pc = next;}The mini-EVM’s own test spells out the rule: u64::MAX + 1 wraps to 0, “just as EVM wraps mod
2²⁵⁶.” The width differs (64 bits versus 256); the behaviour is identical. And crucially: the
machine does not consider wrapping to be an error. It is a perfectly valid result of a valid
opcode. There is nothing for it to revert.
Why the machine must wrap
Section titled “Why the machine must wrap”You might ask: why not just make ADD throw when it overflows? Because determinism is
non-negotiable. Recall the throughline — thousands of nodes must replay every transaction and land
on the bit-identical state root, or consensus fails. The simplest way to guarantee that is to
give arithmetic one totally unambiguous definition with no special cases: every operation
produces a 256-bit result, always, by taking the true result modulo 2²⁵⁶. No “sometimes it
errors” branch to disagree about. The wrapping is not sloppiness; it is the price of every machine
in the world agreeing on what a + b is.
The consequence is that “should this overflow be an error?” is a question about your application’s intent, not the machine’s. And a machine cannot know your intent. Before Solidity 0.8, that meant you had to say so — and if you forgot, the silence was total.
The exploit shape: subtract into a fortune
Section titled “The exploit shape: subtract into a fortune”Here is the canonical vulnerable pattern. A token contract lets holders transfer balances. The
naive transfer looks reasonable:
// VULNERABLE — Solidity < 0.8, no SafeMathmapping(address => uint256) public balanceOf;
function transfer(address to, uint256 amount) public { // BUG: no check that balanceOf[msg.sender] >= amount balanceOf[msg.sender] -= amount; // underflows if amount > balance balanceOf[to] += amount;}Walk it as an attacker with a balance of 0:
balanceOf[attacker] = 0 attacker calls transfer(victim, 1)
balanceOf[attacker] = 0 - 1 = 2²⁵⁶ - 1 ← underflow! now astronomically rich balanceOf[victim] = 0 + 1 = 1
result: attacker holds ~1.16 × 10⁷⁷ tokens, funded from nothing.One missing require(balanceOf[msg.sender] >= amount) and the subtraction that was meant to
decrease the attacker’s balance instead maximised it. No exception was thrown; the EVM did
exactly what it was told.
Multiplication is the subtler cousin. A batch-transfer function computes a total by multiplying a per-recipient amount by the number of recipients:
// VULNERABLE — the batchOverflow shapefunction batchTransfer(address[] memory receivers, uint256 amount) public { uint256 total = receivers.length * amount; // OVERFLOWS for crafted amount require(balanceOf[msg.sender] >= total); // passes: `total` wrapped to a tiny number balanceOf[msg.sender] -= total; // deducts almost nothing for (uint i = 0; i < receivers.length; i++) { balanceOf[receivers[i]] += amount; // credits the huge `amount` in full }}The attacker picks an amount near 2²⁵⁵ and passes 2 receivers. Then
total = 2 × 2²⁵⁵ = 2²⁵⁶, which wraps to 0. The require sees total == 0 and happily
passes. The sender is debited 0. But each receiver is credited the un-wrapped amount —
around 2²⁵⁵ tokens each, conjured from nothing. The overflow happened in the check, so the check
protected nothing.
The SafeMath era: wrap every operator in a check
Section titled “The SafeMath era: wrap every operator in a check”The 2018 incidents made one practice non-negotiable: never use a bare +, -, or * on
untrusted quantities. The community’s answer, popularised by OpenZeppelin, was a library called
SafeMath — a set of functions that do the arithmetic and verify the result makes sense,
reverting if it doesn’t:
// SafeMath — the pre-0.8 defense (simplified from OpenZeppelin)library SafeMath { function add(uint256 a, uint256 b) internal pure returns (uint256) { uint256 c = a + b; require(c >= a, "SafeMath: addition overflow"); // sum can't be smaller than an addend return c; } function sub(uint256 a, uint256 b) internal pure returns (uint256) { require(b <= a, "SafeMath: subtraction underflow"); return a - b; } function mul(uint256 a, uint256 b) internal pure returns (uint256) { if (a == 0) return 0; uint256 c = a * b; require(c / a == b, "SafeMath: multiplication overflow"); // divide back out to verify return c; }}The trick in each function is to detect the wrap after it happens by exploiting a property that only holds when there was no wrap:
- Addition: a true sum is never smaller than either input. If
c < a, it wrapped. Revert. - Subtraction: guard the underflow before it happens — require
b <= a. - Multiplication: if
a * bdidn’t overflow, then(a * b) / a == bexactly. If the wrapped product fails that identity, it overflowed. Revert.
Contracts then wrote every operation through the library, usually via using SafeMath for uint256;
so the calls read almost like operators:
using SafeMath for uint256;
function transfer(address to, uint256 amount) public { balanceOf[msg.sender] = balanceOf[msg.sender].sub(amount); // reverts if amount > balance balanceOf[to] = balanceOf[to].add(amount); // reverts on overflow}SafeMath worked. For roughly three years it was the discipline of safe Solidity, and any audit
would flag a raw arithmetic operator on a balance. But it had two real costs. First, it was
opt-in: safety depended on every developer remembering to route every operation through it, and
a single forgotten - reopened the hole. Second, it was verbose and gas-hungry — every
operation now carried an extra function call and a comparison, turning a + b into a small
subroutine.
The language-level fix: checked-by-default in Solidity 0.8
Section titled “The language-level fix: checked-by-default in Solidity 0.8”The deeper fix was to stop treating overflow safety as a library the developer must remember, and
make it the compiler’s default. Released in December 2020, Solidity 0.8.0 flipped the
polarity: from 0.8 onward, the compiler injects overflow and underflow checks into every +,
-, and * automatically, and a wrapping operation reverts with a Panic(0x11) error
instead of silently wrapping.
// Solidity >= 0.8 — checked by default, no library neededfunction transfer(address to, uint256 amount) public { balanceOf[msg.sender] -= amount; // AUTO-CHECKED: reverts if amount > balance balanceOf[to] += amount; // AUTO-CHECKED: reverts on overflow}This exact code — the vulnerable pattern from earlier in the page — is now safe, because the compiler emits the check the developer forgot. The default became the safe default. SafeMath, as a library, quietly became unnecessary for the common case; OpenZeppelin now marks it as obsolete for Solidity 0.8+.
Under the hood — what the compiler emits
Section titled “Under the hood — what the compiler emits”The compiler doesn’t add a new opcode. It emits ordinary EVM instructions around each operation that recompute the SafeMath-style guard and jump to a revert if it fails. Conceptually, an addition becomes:
a + b compiled (>= 0.8) to roughly:
c = a + b ; the raw, wrapping ADD if c < a: revert with Panic(0x11) ; overflow detected → abort use c
a `Panic(0x11)` is the standardised "arithmetic over/underflow" error selector, distinct from a plain require() revert, so tools can tell them apart.Because the check is in the emitted bytecode, it is unforgeable at the source level: you cannot
“forget” it the way you could forget to call SafeMath.sub. The safety moved from the developer’s
memory into the compiler’s output.
The opt-out: unchecked and why hot paths still use it
Section titled “The opt-out: unchecked and why hot paths still use it”Checks cost gas — a handful of extra instructions on every arithmetic operation, executed on every
call, on every node, forever. For most code that cost is invisible and worth paying. But in
hot paths — a loop counter incremented millions of times, a math routine where the developer
has proven overflow is impossible — that overhead adds up. So 0.8 pairs the safe default with an
explicit escape hatch, the unchecked block:
function sumTo(uint256 n) public pure returns (uint256 total) { for (uint256 i = 0; i < n; ) { total += i; // checked: this genuinely could overflow, keep the guard unchecked { ++i; } // opt out: i < n already bounds i, so ++i cannot overflow }}Inside unchecked { ... }, arithmetic goes back to the old silent-wrapping behaviour — no checks,
no reverts, cheaper gas. This is the crucial trade-off, and it is a deliberate one: the language
makes the safe path the default and the dangerous path loud and explicit. You can only
reintroduce wrapping by typing the word unchecked, which is exactly the flag an auditor greps
for. The 2018 bugs were dangerous because the risk was invisible; unchecked keeps the escape
hatch but makes the risk visible.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Fixed-width modular arithmetic exists because the EVM must give every
node one unambiguous, deterministic definition of
a + b— a 256-bit result computed modulo 2²⁵⁶, with no error branch to disagree about. Wrapping is the by-product of that determinism. - What problem does it solve? It lets untrusting strangers replay the same transactions and reach the bit-identical state root, because there is exactly one possible result for every arithmetic op. That determinism is the foundation the whole shared world computer stands on.
- What are the trade-offs? Safety (checked math that reverts) versus gas (a few extra
instructions per operation). Solidity 0.8 chose safety by default and made the gas-saving
uncheckedopt-out explicit and visible, so the dangerous choice is never the accidental one. - When should I avoid it? Avoid
uncheckedunless you can prove the operation cannot over/underflow — a loop counter already bounded below its limit, for instance. Reaching for it to shave gas without a proof re-opens the exact hole that minted an astronomical pile of BEC tokens. - What breaks if I remove it? Remove the deterministic modular definition and nodes could
compute different results for the same transaction — consensus itself breaks. Remove the 0.8
checks (via blanket
unchecked) and you are back in 2018: a forgotten guard silently mints balances from nothing.
Check your understanding
Section titled “Check your understanding”- Explain in one sentence why
0 - 1on auint256produces2²⁵⁶ − 1rather than an error, and why the EVM is designed to behave this way rather than throwing. - In the vulnerable
transfer, an attacker with a balance of0callstransfer(victim, 1). Trace the two balance updates and state the resulting balance of the attacker. - In the
batchTransferoverflow, therequire(balanceOf[msg.sender] >= total)check passes even though the attacker is broke. What value doestotaltake, and why does the check fail to protect anything? - SafeMath’s
mulchecks overflow withrequire(c / a == b). Why does that identity hold exactly when — and only when — the multiplication did not wrap? - Solidity 0.8 makes arithmetic checked by default but adds
uncheckedblocks. State the trade-off this pair encodes, and give one situation where usinguncheckedis legitimately correct.
Show answers
uint256is a fixed-width, 256-bit type, so arithmetic is done modulo 2²⁵⁶;0 − 1wraps around the bottom edge to the largest representable value,2²⁵⁶ − 1. The EVM defines it this way so every node computes one unambiguous result — determinism — with no “sometimes it errors” branch that nodes could disagree on and break consensus.balanceOf[attacker] = 0 - 1, which underflows to2²⁵⁶ − 1(an astronomically large number);balanceOf[victim] = 0 + 1 = 1. The attacker ends up holding roughly1.16 × 10⁷⁷tokens, funded from nothing, because there was norequire(balance >= amount)guard.- The attacker picks
amount ≈ 2²⁵⁵and2receivers, sototal = 2 × 2²⁵⁵ = 2²⁵⁶, which wraps to0. The check becomesbalance >= 0, which is trivially true, so the sender is debited0— yet each receiver is still credited the full un-wrappedamount. The overflow happened inside the value the check reads, so the check protects nothing. - If
a * bdoes not overflow, the stored productcis the true mathematical product, soc / arecoversbexactly. If it did overflow,cis the wrapped (smaller) value, andc / awill not equalb— the identity is broken, and SafeMath reverts. (Thea == 0case is handled separately to avoid dividing by zero.) - The trade-off is safety versus gas: checked math reverts on overflow but costs a few extra
instructions per operation;
uncheckedrestores silent wrapping to save that gas, but makes the risk explicit and greppable. It is legitimately correct when overflow is provably impossible — e.g. incrementing a loop counterithat is already bounded byi < n < 2²⁵⁶.