JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Overflow, underflow, and the rounding bugs that move money

An odometer rolls from 999999 back to 000000. When a token balance does the same thing, one wrong subtraction can mint a fortune from nothing — and even after Solidity fixed that, a humbler bug survives: division that rounds the wrong way.

The odometer that rolls back to zero

An old car odometer has a fixed number of digits — say six. Drive past 999,999 km and it does not show 1,000,000; it has nowhere to put the leading 1, so it silently rolls back to 000,000. The number wrapped around. A computer integer is exactly the same kind of dial, just in binary, and the smart contract running on the EVM does almost all of its arithmetic on one size of dial: the 256-bit unsigned integer, `uint256`. It can hold values from 0 up to 2²⁵⁶ − 1, a 78-digit number — and not one unit more.

On most laptops this rarely matters. On a blockchain it matters enormously, because the number on the dial often is the money — your balance, an amount to mint, a price. Overflow is when a result is too big and wraps past the top back down toward 0; underflow is the mirror image: subtract 1 from 0 on an unsigned dial and you do not get −1 (the dial has no minus sign), you get 2²⁵⁶ − 1, the largest number it can hold. A single bad subtraction can turn an empty wallet into the richest address in the world.

A worked underflow: minting a fortune by spending too much

Picture a naive token written in an old compiler. It keeps a `mapping` from address to balance and lets you transfer:

// Solidity 0.7 — UNSAFE: arithmetic wraps silently, no revert
mapping(address => uint256) public balanceOf;

function transfer(address to, uint256 amount) external {
    // No check that you actually own `amount`.
    balanceOf[msg.sender] -= amount;   // <-- can underflow
    balanceOf[to]         += amount;
}
Before Solidity 0.8, this subtraction does not revert when you are short — it wraps.

Walk the numbers. Your balance is 100. You call `transfer(victim, 101)`. The line `balanceOf[msg.sender] -= amount` computes 100 − 101. On a signed pencil that is −1; on the unsigned `uint256` dial it wraps to 2²⁵⁶ − 1 ≈ 1.16 × 10⁷⁷. You did not go broke by overspending — you became, by a factor of trillions of trillions, the largest holder of the token in existence, all because the dial had no way to show a negative.

This is not a thought experiment. On 22 April 2018 the BeautyChain (BEC) token was drained by exactly this family of bug — but through overflow, in a multiplication, which is even sneakier because a guard did exist. Its `batchTransfer` paid `_value` to many receivers at once:

function batchTransfer(address[] _receivers, uint256 _value) public returns (bool) {
    uint cnt = _receivers.length;
    uint256 amount = uint256(cnt) * _value;          // <-- OVERFLOWS
    require(cnt > 0 && cnt <= 20);
    require(_value > 0 && balances[msg.sender] >= amount);  // passes!
    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
    }
    return true;
}
BeautyChain's real bug (CVE-2018-10299), simplified. Note: the multiplication is raw, not guarded.
  1. The attacker called it with two receivers (`cnt = 2`) and `_value = 2²⁵⁵`.
  2. `amount = 2 × 2²⁵⁵ = 2²⁵⁶`, which on the dial is exactly 0 — it wrapped to the top and landed on zero.
  3. So `require(balances[msg.sender] >= amount)` became `>= 0`, which is always true — the guard waved the attacker through.
  4. The loop then credited each receiver the full `2²⁵⁵` tokens, conjuring an astronomical supply from a sender who paid `amount = 0`.

The lesson hides in plain sight: the contract did use a `SafeMath` library for `.sub` and `.add`, but the fatal `cnt * _value` was a raw `*`. A guard only protects the operations you actually route through it.

The SafeMath era: bolting on the missing check

For years the standard defence was OpenZeppelin's `SafeMath`, a Solidity library that wrapped every operation in a check and reverted instead of wrapping. Its `sub` and `add` are almost trivial once you see the trick:

library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");  // if it wrapped, c < a
        return c;
    }
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction underflow"); // refuse to go below 0
        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"); // undo and compare
        return c;
    }
}
The detection trick: do the operation, then check whether the result is mathematically consistent with the inputs.

Each check encodes a property that must hold if no wrap happened. Addition: a true sum is never smaller than either operand, so `c >= a` failing means it wrapped. Subtraction: you may only subtract `b` if `b <= a`. Multiplication: if `a × b` is honest, then dividing back by `a` must return `b`. You would then write `balance = balance.sub(amount)` everywhere instead of `balance -= amount`. It worked — but it was verbose, easy to forget on a single line (as BeautyChain proved), and it cost extra gas on every operation.

Solidity 0.8: checked by default — and the new footgun

In December 2020, Solidity 0.8.0 ended the era of forgotten checks. From then on, every `+`, `-`, and `*` on integers is checked by the compiler: on overflow or underflow the transaction reverts with a `Panic(0x11)` error instead of silently wrapping. The BeautyChain bug, compiled under 0.8, simply reverts at `cnt * _value`. SafeMath became, for the common case, unnecessary boilerplate.

But the checks cost a little gas, so 0.8 also added an escape hatch: the `unchecked { ... }` block, where arithmetic wraps the old way. This is a genuine optimization — and a genuine new footgun. Use it only where you have proven a wrap is impossible.

// Solidity ^0.8.0
uint8 x = 255;
x = x + 1;     // REVERTS with Panic(0x11) — does NOT wrap to 0

// A legitimate `unchecked`: the loop index is bounded by the array length,
// so ++i cannot reach 2^256. Skipping the check saves gas every iteration.
function sumAll(uint256[] calldata xs) external pure returns (uint256 total) {
    for (uint256 i = 0; i < xs.length;) {
        total += xs[i];          // still CHECKED — a real sum could overflow
        unchecked { ++i; }       // safe to skip the check here
    }
}
Default is safe; `unchecked` is an opt-out you must justify line by line.

The subtler bug Solidity did not fix: division rounds down

Checked arithmetic stops wraps, but it cannot save you from a far quieter problem, because this one is not a bug in the EVM — it is correct behaviour you forgot to account for. The EVM has no fractions. Integer division always discards the remainder and rounds toward zero: `7 / 2` is `3`, not `3.5`. Nothing reverts. The lost half just vanishes, and over millions of operations those vanished crumbs add up to real money — sometimes into an attacker's pocket.

A concrete skim. A protocol charges a 0.3% fee with `fee = amount * 3 / 1000`. For `amount = 100`, that is `300 / 1000 = 0`. The fee rounds to zero. An attacker who splits a large trade into thousands of tiny `amount = 100` swaps pays no fee at all — the same trick, in spirit, that lets small accounts dodge per-operation charges across DeFi. And the order of operations matters intensely:

// These are NOT equal in integer math:
uint256 a = 100; uint256 b = 1000; uint256 c = 3;

uint256 wrong = a / b * c;   // (100 / 1000) * 3 = 0 * 3   = 0
uint256 right = a * c / b;   // (100 * 3)  / 1000 = 300 / 1000 = 0  (still 0 here)

// With a = 100_000:
// a / b * c = 100 * 3            = 300
// a * c / b = 300_000 / 1000     = 300   (same, but loses no precision early)

// RULE: multiply before you divide, so the big intermediate keeps the precision.
// Beware: a * c can itself overflow — use a full-precision mulDiv for large values.
Divide-then-multiply throws away precision first; multiply-then-divide preserves it (watch the intermediate for overflow).

Two professional habits follow. First, scale up: financial protocols carry values in fixed-point with 18 decimals (so 1.0 is stored as `1e18`), giving rounding 18 digits of slack before it bites. Second, round in the protocol's favour, never the user's: when you compute how much to give out, round down; when you compute how much to take in (a debt, a required deposit), round up. The invariant you protect is that the contract can never be made to pay out more than it took in, one wei at a time.

When rounding becomes a weapon: the ERC-4626 inflation attack

Rounding-down is not just a slow leak; an attacker can steer it. The textbook case is the first-depositor inflation attack on an ERC-4626 vault — a standard yield vault where you deposit assets and receive shares, and shares convert back to assets by `assets = shares × totalAssets / totalSupply`. New shares are minted with the mirror formula, which rounds down:

// shares minted for a deposit, rounding DOWN:
function deposit(uint256 assets) external returns (uint256 shares) {
    shares = totalSupply == 0
        ? assets                                   // first depositor: 1:1
        : assets * totalSupply / totalAssets();    // <-- rounds down, can hit 0
    require(shares > 0, "zero shares");            // <-- the missing guard
    _mint(msg.sender, shares);
    token.transferFrom(msg.sender, address(this), assets);
}
If `assets * totalSupply / totalAssets` rounds to 0, a depositor can pay assets and receive no shares.
  1. The vault is empty. The attacker deposits 1 wei of the asset and receives exactly 1 share. Now totalSupply = 1.
  2. The attacker then donates — transfers directly into the vault, bypassing `deposit` — 10,000 tokens. Now totalAssets ≈ 10,000e18, but totalSupply is still 1. One share is now 'worth' the whole pool.
  3. The victim deposits 10,000 tokens. Shares = 10,000e18 × 1 / (10,000e18 + 1), which rounds DOWN to 0. The victim's assets are now in the vault but they hold zero shares.
  4. The attacker redeems their single share — the only share in existence — and walks away with the entire pool, including the victim's deposit. (Many vaults add `require(shares > 0)`, which only converts the theft into a denial of service: the victim's deposit reverts, but the attacker has still poisoned the share price.)

The defences are now standard, and each one is a small invariant. Virtual shares/assets (OpenZeppelin's 4626 with a decimals offset) treat the vault as if it always holds, say, 1e3 extra virtual shares and 1 virtual asset, so the rounding can never be steered to zero by a real depositor. Dead shares: the deployer makes a first deposit and burns those shares to a dead address, so the pool is never empty for an attacker to seize. Internal accounting that tracks balances in a variable instead of reading `token.balanceOf(this)` defeats the donation step entirely. The throughline of this whole guide: arithmetic safety is not one `SafeMath` import — it is a discipline of asking, at every division, *which way does this round, and who profits from the dust?*

How auditors actually catch these

Reading code top to bottom finds the obvious `unchecked` and the missing `require(shares > 0)`. It rarely finds the rounding that only bites at one weird input. For that, professionals state an invariant — a property that must always hold — and then attack it with fuzzing: a tool throws thousands of random inputs at the function looking for any that break the property. Here, the invariant might be *'a user can never redeem more value than they deposited.'*

// Foundry fuzz test (Solidity). `amount` is randomized over the whole uint range,
// shrinking automatically toward the smallest counterexample on failure.
function testFuzz_NoFreeValue(uint256 amount) public {
    amount = bound(amount, 1, 1_000_000e18);
    uint256 shares = vault.deposit(amount);
    uint256 out    = vault.redeem(shares);
    assertLe(out, amount, "INVARIANT BROKEN: redeemed more than deposited");
}
A property the fuzzer tries to falsify; a single failing `amount` is a finding.