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 = 25000
  • 25000 / 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

  1. Attacker is the first depositor. They deposit 1 wei. Vault now has total_shares = 1, total_assets = 1.
  2. 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_assets becomes 10,000,000,001 wei (10,000 USDC + 1 wei) but total_shares is still 1.
  3. Victim deposits 1,000 USDC via the standard deposit() flow. The share calculation:
    shares = 1,000,000,000 * 1 / 10,000,000,001 = 0  (rounds down)
    
    The victim is credited with 0 shares.
  4. Vault total assets is now ~11,000 USDC, shares is still 1, all owned by attacker.
  5. 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

PitfallWhat goes wrongDefense
Pre-0.8 underflow/overflowSilent wrap to opposite extremeUse Solidity 0.8+; use SafeMath if stuck on older
Misuse of uncheckedRemoves protection without provably-safe invariantOnly unchecked after a require that establishes the bound
Division before multiplicationPrecision lost via truncationMultiply first; use Math.mulDiv for large intermediates
Rounding direction inconsistencyArbitrage between deposit and redeemRound same direction (typically toward protocol) for inverse operations
ERC-4626 inflation attackFirst depositor donates assets to inflate share priceVirtual shares/assets, dead shares, or first-deposit minimum
Allocation remaindersStranded value in contractLast recipient gets total - sum_of_others
Cross-token decimal mismatchAssumes equal decimals; loses or gains by 10^12Scale amounts using each token's decimals

Cross-References

  • Pattern guidance — Section 3.7.2 (State & Storage Patterns) and 3.7.6 (Optimization Patterns) cover unchecked usage 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 MathMath.mulDiv and Math.Rounding are the standard tools; OZ's ERC-4626 implementation is the reference for inflation-attack defenses