Skip to main content

This site is for educational purposes only. Nothing here constitutes financial advice.

Lesson 5 — Arithmetic bugs: overflow, underflow, precision loss

Pre-0.8 Solidity wrapped integers silently. Post-0.8 it doesn't — except in `unchecked` blocks. Today: when overflow still matters, and why fixed-point precision loss has replaced overflow as the dominant arithmetic bug class.

Advanced
Evergreen
22 min readUpdated 2026-06-16Block Clarity Hub Editorial Team

For most of Solidity's history, the headline arithmetic bug was integer overflow — the BeautyChain / BEC exploit that destroyed a token in 2018 by overflowing a multiplication. Solidity 0.8.0 made overflow checking the default and that bug class largely disappeared. But it didn't go away entirely; it moved into `unchecked` blocks and into a related, subtler bug class: fixed-point precision loss in financial calculations. This lesson walks both.

**Pre-0.8 overflow.** Before Solidity 0.8.0 (December 2020), arithmetic operations on `uint256` would silently wrap around modulo 2²⁵⁶. `uint256(0) - 1` returned `2²⁵⁶ - 1` (the maximum), not zero or a revert. This was the substrate for the BeautyChain exploit: a `transfer(to[], value)` function multiplied `value * to.length`; the attacker passed a `value` and a `to[]` length whose product overflowed to a small number, passed the `require(balanceOf[msg.sender] >= total)` check, then the loop transferred `value` to each address. The bug was patched by SafeMath (manual overflow checks) and ultimately by the 0.8.0 default.

**Post-0.8 default behaviour.** Solidity 0.8.0+ inserts overflow / underflow checks into every arithmetic operation. An overflow now reverts the transaction instead of wrapping. This eliminates the entire 'silent overflow' bug class for code written with the default behaviour.

**`unchecked` blocks.** Solidity 0.8.0+ provides an `unchecked { ... }` block that disables overflow checking for performance. Used legitimately when the developer has proven the operation can't overflow (loop counters, packed-storage decoding) and the gas saving is worth the manual proof obligation. Used wrongly, it reintroduces the exact bug class 0.8.0 was meant to eliminate. Modern audit reports frequently flag `unchecked` blocks that don't have a comment justifying why overflow is impossible.

**Fixed-point precision loss.** The dominant arithmetic bug class in modern Solidity is *precision loss* in fixed-point math. The EVM has no floating-point; financial amounts are typically represented as integers scaled by 10^18 (or 10^6 for USDC). Division before multiplication, integer division of small numerators, and operations that round toward zero all leak value. Example: computing `(amount * fee) / 10000` is fine; computing `(amount / 10000) * fee` truncates small amounts to zero before the multiplication. Auditors often look for any division that happens before a multiplication as a default flag.

**Decimals mismatch.** Different tokens use different decimal scaling: ETH and most ERC-20s use 18 decimals, USDC and USDT use 6, WBTC uses 8. Contracts that mix tokens without normalising decimals produce hilariously wrong results. The Beanstalk exploit's lead-up included a decimals mismatch bug; many DEX integrations have similar bugs in cross-pair quoting.

**Rounding direction.** When a calculation rounds, the rounding direction matters for whether the rounding favours the user or the protocol. In a deposit, rounding shares *up* favours the user; in a withdrawal, rounding shares *down* favours the user. A contract that always rounds the same direction (toward zero, Solidity's default) systematically favours either users or the protocol depending on the operation. Convention: rounding should always favour the protocol on its 'in' operations and the user on its 'out' operations, OR vice versa consistently. Mixed conventions produce inflation bugs (the ERC-4626 inflation attack is the canonical example).

**The ERC-4626 inflation attack.** A first depositor in an ERC-4626 vault can manipulate the share-to-asset ratio by depositing a tiny amount and then donating a large amount directly. The next depositor's deposit, scaled by the inflated ratio + integer rounding, may round down to zero shares — and their deposit is effectively donated to the first depositor. Mitigations include `decimalsOffset` (OpenZeppelin's ERC4626 v4.9+) and minimum-deposit checks; this remains a live bug class in late-2024 DeFi.

Example

The BeautyChain 2018 exploit, distilled. Vulnerable function: `function batchTransfer(address[] memory _receivers, uint256 _value) public returns (bool) { uint256 cnt = _receivers.length; uint256 amount = uint256(cnt) * _value; require(_value > 0 && balances[msg.sender] >= amount); balances[msg.sender] -= amount; for (uint i = 0; i < cnt; i++) { balances[_receivers[i]] += _value; } return true; }`. The attacker called `batchTransfer([attackerAddr1, attackerAddr2], 2²⁵⁵)`. Then `amount = 2 * 2²⁵⁵ = 2²⁵⁶ = 0 (mod 2²⁵⁶)`. The require check passed because attacker's balance ≥ 0. The subtraction underflowed (in pre-0.8 wraparound terms) but in this case amount was zero so no harm. Each iteration added `_value = 2²⁵⁵` to each address. Total tokens created out of nothing: ~2²⁵⁶. The contract was destroyed. The post-0.8 fix is automatic; the 0.4.x fix would have been SafeMath's checked-mul. The conceptual lesson: any multiplication where you don't control both operands is a potential overflow surface, and the silent-wrap behaviour of the pre-0.8 EVM made the bug invisible in source.

Common mistakes

  • Treating 0.8.x as eliminating arithmetic bugs entirely. `unchecked` blocks and fixed-point precision loss are still active surfaces.
  • Dividing before multiplying. Small numerators truncate to zero; always multiply first when intermediate values can be small.
  • Mixing decimals across tokens without normalising. WBTC (8 decimals) × USDC (6 decimals) without conversion produces wildly wrong results.
  • Rounding inconsistently across deposit / withdrawal. Convention violation creates inflation-attack surface.
  • Using `unchecked` blocks for performance without proving the operation can't overflow.

Check your understanding

A contract written in Solidity 0.8.20 computes `uint256 shares = (depositAmount / totalSupply) * totalAssets;` to determine shares-out on a vault deposit. What is the structural problem?

Key terms covered

Sources & further reading

We prioritise primary sources. Where a topic moves quickly (regulation, security incidents), we re-check sources on the cadence shown by the page's "Next review" date.

Go deeper