3.8.3 Arithmetic & Precision
The EVM has no floating-point. Every numeric operation in Solidity is integer arithmetic — addition, subtraction, multiplication, division — all performed on uint256 or its smaller variants. This sounds restrictive but is not the main source of arithmetic bugs. The main source of bugs is the interaction between integer behavior and developer expectations shaped by other languages where division produces decimal results, where overflow is undefined behavior, and where precision is something the standard library handles.
Solidity 0.8.0 closed the most-famous arithmetic vulnerability class: overflow and underflow. Before 0.8.0, uint256(0) - 1 silently produced type(uint256).max, and type(uint256).max + 1 silently produced 0. The compiler did nothing to warn about these wrap-arounds, and developers wrote contracts that depended on the absence of overflow without enforcing it. The DAO did not have an overflow bug, but many other early Ethereum contracts did — most notably a series of ERC-20 token contracts in 2018 that allowed attackers to mint themselves arbitrary balances by exploiting transfer(to, amount) with overflowing amount values.
Post-0.8.0, overflow protection is automatic. The vulnerability class has not disappeared — it has shifted to the more subtle forms: precision loss in division, rounding-direction bugs, division-before-multiplication, and the ERC-4626 inflation attack that exploits all three.
Overflow and Underflow
Pre-0.8.0 Behavior
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
contract LegacyVault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// BUG: pre-0.8 underflow silently wraps
balances[msg.sender] -= amount; // if balance < amount, wraps to a huge number
payable(msg.sender).transfer(amount);
}
}
Calling withdraw(1) with balances[msg.sender] == 0 produces balances[msg.sender] == type(uint256).max without reverting. The attacker now has a "balance" of approximately 10^77 ETH — enough to drain the contract's entire actual balance via subsequent calls.
The pre-0.8 defense was the SafeMath library:
import "@openzeppelin/contracts/math/SafeMath.sol";
contract LegacyVault {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
balances[msg.sender] = balances[msg.sender].sub(amount); // reverts on underflow
payable(msg.sender).transfer(amount);
}
}
SafeMath's add, sub, mul, and div functions revert on overflow/underflow rather than wrapping. Every arithmetic operation in pre-0.8 contracts needed to use SafeMath or an equivalent.
Post-0.8.0 Behavior
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// Reverts automatically on underflow
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
The same code now reverts with a Panic(0x11) (arithmetic underflow/overflow) when amount > balances[msg.sender]. No SafeMath required. Existing 0.8+ codebases should not import SafeMath; it adds gas overhead for no benefit.
The unchecked Block
For arithmetic that is provably safe — a loop counter that cannot reach type(uint256).max, a subtraction that has been verified by a prior require — Solidity 0.8+ provides an unchecked block to opt out of the automatic checks:
function withdraw(uint256 amount) external {
uint256 balance = balances[msg.sender];
require(balance >= amount, "insufficient");
unchecked {
// Safe: we just checked that balance >= amount
balances[msg.sender] = balance - amount;
}
payable(msg.sender).transfer(amount);
}
The savings are ~30 gas per arithmetic operation. For frequently-executed code paths, this adds up. The universal example is the loop counter:
for (uint256 i = 0; i < arr.length; ) {
// ... loop body
unchecked { ++i; } // i cannot overflow because it's bounded by arr.length
}
This pattern is so common that newer Solidity compilers special-case it. Note that unchecked is additive — it removes safety. Use it only where the prior context provably establishes the invariant the check would enforce.
Foundry Test for Overflow Behavior
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultArithmeticTest is Test {
Vault vault;
address alice = makeAddr("alice");
function setUp() public {
vault = new Vault();
vm.deal(address(vault), 100 ether);
}
function test_withdrawMoreThanBalanceReverts() public {
vm.prank(alice);
vm.expectRevert(); // Panic(0x11)
vault.withdraw(1 ether);
}
function test_uncheckedSubtractionStillBoundedByRequire() public {
// ... test that the require keeps the unchecked block safe
}
}
vm.expectRevert() without a specific error catches the panic. For panic-specific assertions, use vm.expectRevert(stdError.arithmeticError).
Precision Loss in Integer Division
Integer division truncates the remainder. This is mathematically equivalent to flooring for positive operands. The issue is that "the developer's mental model treats division as producing a decimal, but Solidity does not."
The Classic Example
// ANTI-PATTERN: division before multiplication loses precision
function calculateFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
return amount / 10000 * feeBps;
}
For amount = 100, feeBps = 250 (2.5%), expected fee is 2 wei (truncated from 2.5). Actual computation:
100 / 10000 = 0(integer division truncates)0 * 250 = 0
The fee is silently 0. Anyone calling this function pays no fee.
Multiplication Before Division
The same calculation with operations reversed:
function calculateFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
return amount * feeBps / 10000;
}
For amount = 100, feeBps = 250:
100 * 250 = 2500025000 / 10000 = 2
Correct. The rule is: multiply before dividing whenever possible. The intermediate value (amount * feeBps) may be larger than either operand, but as long as it fits in uint256 (i.e., amount * feeBps < 2^256), the calculation is precise.
When Multiplication Overflows
For large values, amount * feeBps may exceed 2^256, causing a revert in 0.8+ or wrapping in earlier versions. OpenZeppelin's Math.mulDiv handles this by performing the multiplication in 512-bit intermediate precision:
import "@openzeppelin/contracts/utils/math/Math.sol";
function calculateFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
return Math.mulDiv(amount, feeBps, 10000);
}
mulDiv(a, b, c) computes (a * b) / c even when a * b overflows uint256. The implementation uses inline assembly to perform the multiplication and division as one operation in higher precision. Use it for any case where the operands can be large enough that the intermediate product might overflow — which in practice means most token-amount calculations.
Solidity 0.8+ Note on mulDiv
Solidity 0.8.22 added native mulDiv support via the unchecked arithmetic mode combined with explicit overflow handling. For most cases, OpenZeppelin's Math.mulDiv remains the right choice — it's battle-tested and handles edge cases (division by zero, rounding mode parameter) that the language primitive does not.
Rounding Direction
When integer division produces a remainder, the result must be rounded somewhere. Solidity rounds toward zero (floor for positive results). Whether this is correct depends on which party benefits from the rounding.
The Rule
- Calculating what the protocol owes the user (withdrawal amount, redemption value, rebate) → round down, favoring the protocol
- Calculating what the user owes the protocol (deposit cost, mint price, fee) → round up, favoring the protocol
The reason is consistent: rounding errors of one wei accumulate over many transactions. If every transaction rounds in the user's favor, the protocol slowly leaks value. If every transaction rounds in the protocol's favor, the protocol gains a tiny amount per transaction — sustainable, even if unfair, in the aggregate.
Vulnerable Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
uint256 public totalShares;
uint256 public totalAssets;
// BUG: rounding direction not considered
function depositAssets(uint256 assets) external returns (uint256 shares) {
if (totalShares == 0) {
shares = assets;
} else {
shares = assets * totalShares / totalAssets; // rounds down
}
totalShares += shares;
totalAssets += assets;
}
// BUG: rounding direction not considered
function redeemShares(uint256 shares) external returns (uint256 assets) {
assets = shares * totalAssets / totalShares; // rounds down
totalShares -= shares;
totalAssets -= assets;
}
}
Both functions round down. The depositAssets rounding favors the user (they get more shares per asset deposited than they should). The redeemShares rounding also rounds down, which favors the protocol. But the inconsistency creates an arbitrage: deposit assets, get rounded-up shares, redeem the shares, get rounded-down assets. The user loses on the redeem, gains on the deposit. Net loss.
The fix is to round in the same direction relative to the protocol in both cases:
import "@openzeppelin/contracts/utils/math/Math.sol";
function depositAssets(uint256 assets) external returns (uint256 shares) {
if (totalShares == 0) {
shares = assets;
} else {
// Round down: user gets fewer shares (favors protocol)
shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor);
}
totalShares += shares;
totalAssets += assets;
}
function redeemShares(uint256 shares) external returns (uint256 assets) {
// Round down: user gets fewer assets (favors protocol)
assets = Math.mulDiv(shares, totalAssets, totalShares, Math.Rounding.Floor);
totalShares -= shares;
totalAssets -= assets;
}
Both round down (Math.Rounding.Floor), both favor the protocol, no arbitrage.
For some operations the inverse direction is appropriate:
// Calculating "how many shares must I mint to deposit at least X assets?"
// The user is asking: I want exactly X assets credited; how many shares does that cost?
function previewMint(uint256 shares) public view returns (uint256 assets) {
// Round up: user pays more assets (favors protocol)
assets = Math.mulDiv(shares, totalAssets, totalShares, Math.Rounding.Ceil);
}
OpenZeppelin's ERC-4626 implementation has this built in. For custom share systems, follow the same convention.
The ERC-4626 Inflation Attack
The marquee precision attack of recent years. The setup combines first-deposit edge cases, integer rounding, and direct token donation to corrupt a vault's share-price calculation. Multiple production vaults have lost user funds to variations of this attack.
The Mechanics
ERC-4626 is a standard for tokenized vaults. Users deposit an underlying asset and receive "shares" that represent their proportional claim on the vault's assets. Share value floats based on the vault's strategy (yield farming, lending, etc.) and the conversion is:
shares = deposit_amount * total_shares / total_assets
assets = redeem_shares * total_assets / total_shares
When the vault is empty (total_shares == 0 and total_assets == 0), the first depositor sets the share price by convention (typically shares = deposit_amount).
The Attack
- Attacker is the first depositor. They deposit 1 wei. Vault now has
total_shares = 1, total_assets = 1. - Attacker donates 10,000 USDC directly to the vault (e.g., by sending tokens to the vault address bypassing the deposit function). The vault's
total_assetsbecomes 10,000,000,001 wei (10,000 USDC + 1 wei) buttotal_sharesis still 1. - Victim deposits 1,000 USDC via the standard
deposit()flow. The share calculation:
The victim is credited with 0 shares.shares = 1,000,000,000 * 1 / 10,000,000,001 = 0 (rounds down) - Vault total assets is now ~11,000 USDC, shares is still 1, all owned by attacker.
- Attacker redeems their 1 share for the entire vault balance, including the victim's 1,000 USDC.
The victim paid 1,000 USDC and received 0 shares — they cannot redeem anything. The attacker walks away with everything.
Defenses
Defense 1: Virtual shares and assets. Introduce a "ghost" balance that the vault always pretends to have, blunting the manipulation. OpenZeppelin's ERC-4626 v4.9+ uses this approach with _decimalsOffset():
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) {
return assets.mulDiv(totalSupply() + 10**_decimalsOffset(), totalAssets() + 1, rounding);
}
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) {
return shares.mulDiv(totalAssets() + 1, totalSupply() + 10**_decimalsOffset(), rounding);
}
The + 1 on assets and + 10**_decimalsOffset() on shares acts as if there's always a small amount of value in the vault that nobody can withdraw. The attacker's donation no longer dominates the calculation, because the "virtual" denominator never drops to a small value.
Defense 2: Dead shares on first deposit. Burn or lock a small number of shares on the first deposit so the vault is never truly empty:
function deposit(uint256 assets) external returns (uint256 shares) {
if (totalShares == 0) {
// Mint minimum dead shares to address(0)
_mint(address(0), MINIMUM_LIQUIDITY);
shares = assets - MINIMUM_LIQUIDITY;
} else {
shares = assets * totalShares / totalAssets;
}
_mint(msg.sender, shares);
totalShares += shares;
totalAssets += assets;
}
This is the Uniswap V2 LP pattern. It costs the first depositor a small permanent loss (the dead shares) but prevents the inflation attack entirely.
Defense 3: Require minimum first deposit. Some vaults set a high minimum first-deposit amount, making the attack capital-inefficient:
function deposit(uint256 assets) external returns (uint256 shares) {
if (totalShares == 0) {
require(assets >= 1e18, "first deposit too small"); // requires 1 token minimum
}
// ... rest of logic
}
This works as a partial defense. The attacker can still inflate the share price by donating, but the cost of going first is now substantial.
Foundry Test for the Inflation Attack
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Vault.sol";
import "../src/MockToken.sol";
contract InflationAttackTest is Test {
Vault vault;
MockToken token;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
token = new MockToken();
vault = new Vault(address(token));
token.mint(attacker, 10001e6); // 10,001 USDC
token.mint(victim, 1000e6); // 1,000 USDC
}
function test_inflationAttack() public {
// 1. Attacker deposits 1 wei
vm.startPrank(attacker);
token.approve(address(vault), type(uint256).max);
vault.deposit(1);
// 2. Attacker donates 10,000 USDC directly
token.transfer(address(vault), 10000e6);
vm.stopPrank();
// 3. Victim deposits 1,000 USDC
vm.startPrank(victim);
token.approve(address(vault), 1000e6);
uint256 victimShares = vault.deposit(1000e6);
vm.stopPrank();
// Without defense: victim gets 0 shares
assertEq(victimShares, 0, "victim should be defrauded without defense");
// 4. Attacker redeems their 1 share for all assets
vm.prank(attacker);
uint256 redeemed = vault.redeem(1);
assertGt(redeemed, 1000e6, "attacker stole more than they deposited");
}
}
This test demonstrates the attack with a naive vault. The corresponding test against a defended vault should show victimShares > 0 and redeemed ≈ attacker_initial_deposit.
Real-World Incidents
The pattern has hit production. Notable cases include:
- Hundred Finance (April 2023) — $7M loss from an inflation-attack-like exploit on a Compound fork
- Several smaller "yield aggregator" vaults — typically caught in audits but the pattern keeps appearing in unaudited deployments
The Euler Finance exploit (March 2023, $197M) was a different kind of precision bug — a donation-based liquidation logic flaw. Section 3.10.8 covers it in case-study form.
Other Precision Pitfalls
Off-By-One in Bounds Calculations
// ANTI-PATTERN: when allocating based on percentage shares
function allocate(uint256 total) external {
for (uint256 i = 0; i < recipients.length; ++i) {
uint256 share = total * percentages[i] / 100; // each rounds down
IERC20(token).transfer(recipients[i], share);
}
// Sum of shares is total - (rounding losses) — some tokens stuck in contract
}
If three recipients each have 33.33%, the sum rounds to 33+33+33 = 99 out of 100. One unit is stranded in the contract.
The fix is to give the rounding remainder to the last recipient (or to compute the last share as total - sum_of_others):
function allocate(uint256 total) external {
uint256 allocated;
for (uint256 i = 0; i < recipients.length - 1; ++i) {
uint256 share = total * percentages[i] / 100;
IERC20(token).transfer(recipients[i], share);
allocated += share;
}
// Last recipient gets the remainder, ensuring all of `total` is distributed
IERC20(token).transfer(recipients[recipients.length - 1], total - allocated);
}
Decimal Mismatches Across Tokens
Different tokens use different decimal places. USDC uses 6 decimals; DAI uses 18 decimals; some tokens use 8 (WBTC) or other values.
// ANTI-PATTERN: assumes both tokens have the same decimals
function exchange(uint256 amount) external {
// 1 USDC = 1 DAI? But amounts are 6 vs 18 decimals!
IERC20(dai).transfer(msg.sender, amount);
IERC20(usdc).transferFrom(msg.sender, address(this), amount);
}
The fix is to scale amounts to a common decimal base:
function exchange(uint256 daiAmount) external {
uint8 daiDecimals = IERC20Metadata(dai).decimals(); // 18
uint8 usdcDecimals = IERC20Metadata(usdc).decimals(); // 6
uint256 usdcAmount = daiAmount * (10 ** usdcDecimals) / (10 ** daiDecimals);
IERC20(dai).transfer(msg.sender, daiAmount);
IERC20(usdc).transferFrom(msg.sender, address(this), usdcAmount);
}
Hard-coded decimal assumptions in cross-token contracts are a frequent source of bugs. Section 3.11 covers DeFi-specific precision concerns including this pattern.
Quick Reference
| Pitfall | What goes wrong | Defense |
|---|---|---|
| Pre-0.8 underflow/overflow | Silent wrap to opposite extreme | Use Solidity 0.8+; use SafeMath if stuck on older |
Misuse of unchecked | Removes protection without provably-safe invariant | Only unchecked after a require that establishes the bound |
| Division before multiplication | Precision lost via truncation | Multiply first; use Math.mulDiv for large intermediates |
| Rounding direction inconsistency | Arbitrage between deposit and redeem | Round same direction (typically toward protocol) for inverse operations |
| ERC-4626 inflation attack | First depositor donates assets to inflate share price | Virtual shares/assets, dead shares, or first-deposit minimum |
| Allocation remainders | Stranded value in contract | Last recipient gets total - sum_of_others |
| Cross-token decimal mismatch | Assumes equal decimals; loses or gains by 10^12 | Scale amounts using each token's decimals |
Cross-References
- Pattern guidance — Section 3.7.2 (State & Storage Patterns) and 3.7.6 (Optimization Patterns) cover
uncheckedusage in context - Anti-patterns catalog — Section 3.7.7 covers division-before-multiplication and rounding direction briefly
- DeFi precision — Section 3.11 covers AMM precision, liquidation rounding, and oracle decimal handling
- Auditor's view — Section 4.11.10 covers math-related vulnerabilities during code review
- Real exploits — Section 3.10 covers historical incidents including Euler Finance's precision bug
- OpenZeppelin Math —
Math.mulDivandMath.Roundingare the standard tools; OZ's ERC-4626 implementation is the reference for inflation-attack defenses