Introduction

The Ethereum Virtual Machine enforces fixed-size data types for integers, meaning the range of representable values for an integer variable is finite. Integer overflow and underflow are significant vulnerabilities in Solidity smart contracts, arising when arithmetic operations exceed or fall below the bounds of an integer type. But overflow and underflow are only the most visible face of a much deeper problem. Precision loss from integer division, order-of-operations mistakes, fixed-point scaling errors, share-price manipulation, and silent type truncation are all members of the same family — and they are not prevented by the compiler.

Smart contracts are programs running on the blockchain and once deployed cannot be modified. Integer overflow and underflow have always been one of the most common vulnerabilities in Ethereum, and to this day incidents still occur, resulting in financial losses or functional failures.

This article is a complete technical reference for every integer arithmetic vulnerability class that a Solidity auditor should understand. Code examples are included throughout. The final section is a systematic audit checklist you can apply to any codebase.


1. Overflow and Underflow — The 0.8 Story

The Ethereum Virtual Machine imposes size limitations on integer data types. Each type has a fixed range of values. For instance, a variable of type uint8 can only hold integer values from 0 to 255 inclusive. Any attempt to store a value greater than 255 will result in an overflow error. Similarly, subtracting 1 from 0 in a uint8 variable will yield 255 due to a phenomenon known as underflow.

In Solidity 0.8.0 and above, the compiler automatically handles checking for overflows and underflows in arithmetic operations, reverting the transaction if an overflow or underflow occurs. This ended a long era of silent wrapping bugs that had plagued the ecosystem.

The critical nuance is unchecked blocks. Newer versions of Solidity (0.8 and higher) throw an error for overflow/underflow, but all unchecked blocks should still be analysed, as seen in previous examples.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract OverflowDemo {
    // ✅ Safe in 0.8+: reverts on overflow
    function safeAdd(uint256 a, uint256 b) external pure returns (uint256) {
        return a + b;
    }

    // ⚠️  Dangerous: unchecked re-enables wrapping arithmetic
    function unsafeAdd(uint256 a, uint256 b) external pure returns (uint256) {
        unchecked {
            return a + b; // silently wraps if a + b > type(uint256).max
        }
    }

    // ⚠️  Common pattern in gas-optimised loops — must prove no overflow first
    function sumArray(uint256[] calldata values) external pure returns (uint256 total) {
        uint256 len = values.length;
        unchecked {
            for (uint256 i; i < len; ++i) {
                total += values[i]; // wraps silently on overflow
            }
        }
    }
}

Even in 2025, about 15–20% of audited DeFi contracts have potential overflow risks — mostly from unchecked arithmetic, complex math formulas, or external contract interactions.

When Is unchecked Acceptable?

unchecked is legitimate in three narrow circumstances:

  1. Loop counters: Iterating over an array whose length fits in a uint256 cannot overflow a counter that you increment once per iteration. The proof is trivial.
  2. Post-validated arithmetic: You have already established that the operands are bounded (e.g., amounts subtracted from a checked balance).
  3. Gas-critical hot paths like AMM math — only after a formal proof and fuzz campaign.

Every other use of unchecked is a bet you should not take without documentation.


2. Division Before Multiplication — The Most Common Order-of-Operations Bug

Solidity integer division truncates toward zero. Every intermediate division introduces a floor operation that discards the fractional remainder. When you divide before multiplying, that discarded fractional component is then scaled up — magnifying the rounding error by the multiplier.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract DivisionOrder {
    uint256 constant FEE_BPS = 30;   // 0.30%
    uint256 constant BPS     = 10_000;

    // ❌ Divide first: massive precision loss
    // If amount = 1_000_001, this computes:
    //   step1 = 1_000_001 / 10_000 = 100  (remainder 1 discarded)
    //   step2 = 100 * 30            = 3_000
    // True answer: 1_000_001 * 30 / 10_000 = 3_000 (happens to be same here
    //   but try amount = 99: 99/10000 = 0, * 30 = 0. True answer: 0.297 -> 0 ✓
    //   but try amount = 333: 333/10000 = 0, * 30 = 0. True answer: 0.999 -> 0 ✓
    // Losses become meaningful when intermediate result loses high-value bits.
    function badFee(uint256 amount) external pure returns (uint256) {
        return (amount / BPS) * FEE_BPS;
    }

    // ✅ Multiply first: single rounding at the final step
    function goodFee(uint256 amount) external pure returns (uint256) {
        return (amount * FEE_BPS) / BPS;
    }
}

The error magnitude is bounded by multiplier - 1 in the worst case, but across thousands of users and millions of transactions it can accumulate into a material drain on a protocol’s fee revenue — or a gift to users who game operation ordering.

A Realistic Multi-Step Example

// ❌ Anti-pattern: multiple intermediate divisions
function compoundFee(uint256 amount, uint256 ratio, uint256 scalar) 
    external pure returns (uint256) 
{
    uint256 step1 = amount / 1e18;      // precision loss #1
    uint256 step2 = step1 * ratio;
    uint256 step3 = step2 / scalar;     // precision loss #2
    return step3;
}

// ✅ Defer all division to the final step
function compoundFeeFixed(uint256 amount, uint256 ratio, uint256 scalar)
    external pure returns (uint256)
{
    return (amount * ratio) / (1e18 * scalar);
}

Rule: every / should be the last operation in any arithmetic expression unless you can prove the intermediate quotient is exact.


3. Rounding Direction and Who Benefits

In any financial protocol, every rounding decision transfers fractional value from one party to another. The EVM always rounds toward zero (floors for positive numbers). This is not always correct.

The invariant you must enforce is:

Round in favour of the protocol (vault/system), not the user, when the user is taking value out. Round in favour of the user when the user is putting value in.

This is the exact principle encoded in ERC-4626:

  • previewDeposit / mint → round down (user gets fewer shares, protecting the vault)
  • previewWithdraw / redeem → round up (user must burn more shares, protecting the vault)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// Demonstrating rounding direction choices
contract RoundingDirections {
    uint256 public totalAssets = 1_000e18;
    uint256 public totalShares = 1_000e18;

    // ✅ Shares minted on deposit: round DOWN (vault-friendly)
    function convertToShares(uint256 assets) public view returns (uint256) {
        return (assets * totalShares) / totalAssets;
        // floor division: user gets slightly fewer shares
    }

    // ✅ Assets on withdrawal: round DOWN output to user (vault-friendly)
    function convertToAssets(uint256 shares) public view returns (uint256) {
        return (shares * totalAssets) / totalShares;
        // floor: user gets slightly fewer assets
    }

    // ✅ Shares required for a withdrawal: round UP (vault-friendly)
    // User must burn more shares to withdraw a given asset amount
    function sharesRequiredForWithdrawal(uint256 assets) public view returns (uint256) {
        return (assets * totalShares + totalAssets - 1) / totalAssets;
        // ceiling division trick: (a + b - 1) / b
    }
}

When depositing tokens, the number of shares a user gets is rounded towards zero. This rounding takes away value from the user in favour of the vault — that is, in favour of all current shareholders. This rounding is often negligible because of the amount at stake.

When rounding direction is wrong, the protocol slowly bleeds. Small errors compound over millions of operations.


4. Fixed-Point Arithmetic: WAD and RAY

Since we cannot directly multiply a number by 1.15 in Solidity, this is a clear example of the need for fixed-point arithmetic.

The solution is to represent decimal values as scaled integers. WAD, which stands for “Wei as Decimal,” represents a scaling factor of 10¹⁸. This aligns with the fundamental relationship between Ether and wei. The term “WAD” has become a standard in Ethereum smart contracts, particularly in DeFi applications, where it simplifies the handling of token amounts and monetary values by maintaining 18 decimal places of precision.

RAY, on the other hand, represents a scaling factor of 10²⁷, providing even finer granularity in managing decimal places compared to WAD.

DS-Math provides arithmetic functions for two higher-level numerical concepts: wad (18 decimals) and ray (27 decimals). These are used to represent fixed-point decimal numbers. A wad is a decimal number with 18 digits of precision and a ray is a decimal number with 27 digits of precision. These functions are necessary to account for the difference between how integer arithmetic behaves normally and how decimal arithmetic should actually work.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title FixedPointMath — WAD and RAY arithmetic
library FixedPointMath {
    uint256 internal constant WAD = 1e18;
    uint256 internal constant RAY = 1e27;

    // ─── WAD ───────────────────────────────────────────────────────────────

    /// @notice Multiply two WAD-scaled values
    /// @dev    result = (x * y) / WAD. Reverts on overflow.
    function mulWad(uint256 x, uint256 y) internal pure returns (uint256) {
        return (x * y) / WAD;
        // Risk: x * y can overflow uint256 if x and y are both near 1e36
    }

    /// @notice Divide two WAD-scaled values
    /// @dev    result = (x * WAD) / y
    function divWad(uint256 x, uint256 y) internal pure returns (uint256) {
        return (x * WAD) / y;
        // Risk: x * WAD can overflow if x > type(uint256).max / WAD
    }

    // ─── RAY ───────────────────────────────────────────────────────────────

    /// @notice Multiply two RAY-scaled values
    function mulRay(uint256 x, uint256 y) internal pure returns (uint256) {
        return (x * y) / RAY;
    }

    /// @notice Convert WAD to RAY
    function wadToRay(uint256 x) internal pure returns (uint256) {
        return x * 1e9; // WAD * 1e9 = RAY
    }

    /// @notice Convert RAY to WAD, rounding down
    function rayToWad(uint256 x) internal pure returns (uint256) {
        return x / 1e9;
    }
}

contract InterestAccrual {
    using FixedPointMath for uint256;

    uint256 constant WAD = 1e18;

    // Demonstrates: balance * interestRate / WAD
    // E.g. balance = 1_000e18 tokens, rate = 1.05e18 (5% annual)
    function applyInterest(uint256 balance, uint256 ratioWad)
        external pure returns (uint256)
    {
        return balance.mulWad(ratioWad);
    }
}

The Overflow Trap in WAD Math

The naive mulWad(x, y) = x * y / WAD silently overflows when x * y > type(uint256).max. This happens at values above roughly 1.15e59 for WAD-scaled numbers. If either operand can approach 1e38 (easily possible in fee accumulators), you need the mulDiv pattern described in the next section.


5. The mulDiv Pattern — Full Precision Without Overflow

The fundamental problem: you want floor(a * b / c) but a * b overflows a 256-bit integer before the division reduces it. The solution is to compute the 512-bit product first, then divide.

OpenZeppelin’s Math.sol calculates floor(x * y / denominator) with full precision, throwing if the result overflows a uint256. Original credit goes to Remco Bloemen under MIT license.

The strategy is: compute the 512-bit product a⋅b using mulmod; make the division exact by subtracting the remainder using mulmod; remove powers of two from the fraction to make the denominator invertible mod 2²⁵⁶; compute the modular inverse of the denominator; then multiply the numerator and inverse denominator mod 2²⁵⁶.

This routine is included in Uniswap V3’s FullMath.sol, where it saves gas on each trade. It can also be found in OpenZeppelin and various other projects.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract PrecisionMath {
    using Math for uint256;

    uint256 constant WAD = 1e18;

    // ─── Naive mulWad — overflows when x, y are large ──────────────────────
    function mulWadUnsafe(uint256 x, uint256 y) external pure returns (uint256) {
        return (x * y) / WAD; // ❌ overflows if x * y > type(uint256).max
    }

    // ─── Safe mulWad via OZ mulDiv ──────────────────────────────────────────
    function mulWadSafe(uint256 x, uint256 y) external pure returns (uint256) {
        return Math.mulDiv(x, y, WAD); // ✅ full 512-bit intermediate precision
    }

    // ─── Round up variant ───────────────────────────────────────────────────
    function mulWadUp(uint256 x, uint256 y) external pure returns (uint256) {
        return Math.mulDiv(x, y, WAD, Math.Rounding.Ceil); // ✅ ceiling
    }

    // ─── Real-world use: pro-rata share of reward ───────────────────────────
    // userShares * totalReward / totalShares — all three can be 1e24+
    function proRataReward(
        uint256 userShares,
        uint256 totalReward,
        uint256 totalShares
    ) external pure returns (uint256) {
        if (totalShares == 0) return 0;
        return Math.mulDiv(userShares, totalReward, totalShares);
    }
}

The unchecked Porting Bug

The original FullMath library was created with Solidity versions below 0.8.0, which do not revert on overflows; this behaviour was relied upon as the expected intermediary overflow could be reached. However, when the library is ported to Solidity ^0.8.4, which reverts on overflow, the intermediate calculations would revert, meaning it cannot handle multiplication and division where an intermediate value overflows 256 bits. The fix is to mark the full body in an unchecked block.

This is a real bug found in production audits — importing a pre-0.8 FullMath.sol without the unchecked wrapper causes the 512-bit path to revert.

// ❌ Broken port of pre-0.8 FullMath to 0.8+ — intermediate overflow reverts
function mulDivBroken(uint256 a, uint256 b, uint256 denominator)
    internal pure returns (uint256 result)
{
    // prod1 * 2^256 + prod0 = a * b  -- but 0.8 reverts on the overflow!
    uint256 prod0 = a * b;
    // ...
}

// ✅ Correct: wrap the 512-bit arithmetic in unchecked
function mulDivFixed(uint256 a, uint256 b, uint256 denominator)
    internal pure returns (uint256 result)
{
    unchecked {
        uint256 prod0 = a * b;
        uint256 prod1;
        assembly {
            let mm := mulmod(a, b, not(0))
            prod1 := sub(sub(mm, prod0), lt(mm, prod0))
        }
        if (prod1 == 0) {
            require(denominator > 0);
            assembly { result := div(prod0, denominator) }
            return result;
        }
        // ... 512 / 256 division
    }
}

When Must You Use mulDiv?

Use Math.mulDiv (or equivalent) any time both of these are true:

  • You are computing a * b / c
  • Either a or b can exceed type(uint256).max / max(b, a)

Practically: any pro-rata reward calculation, any share-price computation, any percentage fee on large token amounts. If you’re uncertain, measure the worst-case magnitudes and check.


6. Inflation Attacks on Share-Based Vaults

An inflation attack is a widespread problem that targets the ERC-4626 tokenized vault standard and has largely gone unnoticed until recently. This attack allows malicious actors to steal the first deposits into vulnerable pools, potentially resulting in significant losses for unsuspecting investors.

In simple terms, an inflation attack is a malicious attacker front-running the vault’s first depositor, manipulating Solidity’s rounding down and taking all the shares.

The Mechanics

The shares formula in a naive ERC-4626 implementation is:

shares = deposit * totalSupply / totalAssets

At vault inception: totalSupply = 0, totalAssets = 0. The attacker exploits this:

Suppose a user is about to deposit 100 tokens into a new vault as the first depositor. If an attacker front-runs this initial deposit with even 1 wei, this minuscule deposit would still garner the attacker a 100% share of the pool. Next, the attacker donates an amount greater than or equal to 100 tokens to the vault. This action increases the total balance of the pool while maintaining the number of shares in circulation.

By the time the initial user’s deposit of 100 tokens makes it to the pool, the calculation for their share ends up being zero due to the way pool shares are calculated with the donated token balance. Finally, the attacker withdraws their share from the pool. Since they are the only one with any shares, this withdrawal equals the balance of the vault. This means the attacker also withdraws the 100 tokens deposited by the initial user, effectively stealing their deposit.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @notice VULNERABLE naive vault — do not use in production
contract NaiveVault {
    IERC20 public immutable asset;
    mapping(address => uint256) public shares;
    uint256 public totalShares;

    constructor(IERC20 _asset) { asset = _asset; }

    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this)); // ❌ manipulable via direct transfer
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 minted) {
        uint256 supply = totalShares;
        uint256 pool   = totalAssets();

        // ❌ When supply > 0 and pool is inflated, this rounds to 0
        minted = supply == 0 ? assets : (assets * supply) / pool;

        require(minted > 0, "zero shares");
        asset.transferFrom(msg.sender, address(this), assets);
        shares[receiver] += minted;
        totalShares += minted;
    }

    function redeem(uint256 sharesToBurn, address receiver) external returns (uint256 out) {
        out = (sharesToBurn * totalAssets()) / totalShares;
        shares[msg.sender] -= sharesToBurn;
        totalShares -= sharesToBurn;
        asset.transfer(receiver, out);
    }
}

The attack, step by step:

  1. Attacker deposits 1 wei → receives 1 share. totalAssets = 1, totalShares = 1.
  2. Attacker directly transfers 10,000e6 USDC to the vault (no shares minted). totalAssets = 10_000e6 + 1.
  3. Victim deposits 20,000e6. Shares = 1 * 20_000e6 / (10_000e6 + 1) = 1 (rounds down from 1.99…).
  4. Attacker redeems their 1 share out of 2 total → receives half the pool ≈ 15,000e6. Profit: ~5,000 USDC.

The key trick is manipulating the ratio of shares to assets before real users deposit, creating a mathematical trap where deposits result in zero shares.

Mitigations

Option 1 — Virtual Offset (OpenZeppelin v5)

The virtual assets enforce the conversion rate when the vault is empty. A balance offset due to virtual assets coupled with the increased precision of decimal offset reduces the rounding error when computing the amount of shares. Attacks lose their profitability because virtual assets and shares capture part of the donation.

// ✅ OpenZeppelin ERC4626 with virtual offset

contract SafeVault is ERC4626 {
    constructor(IERC20 _asset)
        ERC20("SafeVault", "svTKN")
        ERC4626(_asset)
    {}

    // ✅ Override to enable virtual offset protection
    // Each decimal added makes the attack exponentially more expensive
    function _decimalsOffset() internal pure override returns (uint8) {
        return 6; // 10^6 virtual shares — strong protection
    }
}

OpenZeppelin’s ERC-4626 implementation (v5.x) includes virtual offset protection — but you have to enable it by overriding _decimalsOffset(). The default returns 0, which provides no protection.

Option 2 — Internal Balance Tracking

The second strategy aims to negate the effect of direct transfers by keeping track of the assets held by the vault internally. This means that donated tokens are not accounted for, which effectively eliminates the risk of inflation attacks.

// ✅ Track assets internally — direct transfers are ignored
contract InternalTrackingVault {
    uint256 private _trackedAssets; // internal ledger, not balanceOf

    function totalAssets() public view returns (uint256) {
        return _trackedAssets; // ✅ donation-immune
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 minted) {
        // ...
        _trackedAssets += assets; // updated only through deposit(), not transfer
    }
}

Option 3 — Dead Shares on Initialization

This strategy is inspired by Uniswap V2, which created dead LP shares when the first liquidity was deposited. In ERC-4626 vaults, a similar approach could be implemented by minting dead shares upon the first deposit/mint.

constructor(IERC20 _asset) ERC20("Vault", "vTKN") ERC4626(_asset) {
    // Mint dead shares to address(0) on deploy — no first depositor to front-run
    _mint(address(0), 1_000); // small but meaningful seed
}

Using an epoch system means users don’t get vault tokens as soon as they deposit, but have to wait for another epoch or round to start when they can receive their shares. Bootstrapping the vault involves minting several shares to address(0) or another address such as the team or vault contract, meaning there is no first depositor to front-run.


7. Type Casting Truncation

Solidity allows explicit conversions between integer types of different sizes. For example, converting a uint256 to a uint8 is totally valid — and totally dangerous if not handled carefully.

Solidity does not revert on overflow or underflow during type casting. This means you can truncate a large number into a much smaller one and the EVM won’t complain — but your logic will silently break.

This is a critical asymmetry with the 0.8+ arithmetic checks: Solidity 0.8 introduced type checking for arithmetic operations, but not for type casting.

Casting a large uint to a smaller type (like uint256 to uint128) may cause an overflow, with the high-order bits of the larger number simply being discarded.

Solidity’s type casting is “unchecked” by default, meaning it will silently produce a potentially incorrect result without any warnings or errors. This can lead to vulnerabilities, as the contract might continue executing with incorrect data.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract CastingDemo {
    using SafeCast for uint256;

    // ❌ Unsafe downcast — silently truncates
    function unsafeCast(uint256 amount) external pure returns (uint128) {
        return uint128(amount);
        // If amount = type(uint128).max + 1 = 2^128
        // uint128(2^128) = 0  ← silent catastrophic loss
    }

    // ✅ Safe downcast — reverts on truncation
    function safeCast(uint256 amount) external pure returns (uint128) {
        return amount.toUint128(); // reverts if amount > type(uint128).max
    }

    // ⚠️  Real-world pattern: packing amounts into storage slots
    struct Position {
        uint128 collateral;
        uint128 debt;
    }

    mapping(address => Position) public positions;

    // ❌ Vulnerable version
    function depositUnsafe(uint256 amount) external {
        positions[msg.sender].collateral += uint128(amount); // silent truncation
    }

    // ✅ Safe version
    function depositSafe(uint256 amount) external {
        positions[msg.sender].collateral += amount.toUint128();
    }

    // ⚠️  Timestamp packing — another common truncation site
    function setExpiryUnsafe(uint256 durationDays) external returns (uint32) {
        uint256 expiry = block.timestamp + durationDays * 1 days;
        return uint32(expiry); // ❌ overflows in 2106 — or sooner with large input
    }

    function setExpirySafe(uint256 durationDays) external returns (uint32) {
        uint256 expiry = block.timestamp + durationDays * 1 days;
        return expiry.toUint32(); // ✅ reverts if > type(uint32).max
    }
}

When downcasting from one type to another, the Solidity compiler will not revert but will overflow, resulting in unexpected behaviour in smart contracts which can later be exploited. Developers should make use of battle-tested libraries for safe casting such as OpenZeppelin’s SafeCast library.

Common Truncation Sites

LocationTypical downcastRisk
Packed storage structsuint256 → uint128/96/64Amount silently truncated
Timestampsuint256 → uint32/40Expiry wraps to past
Array indicesuint256 → uint16Access wrong slot
Fee amountsuint256 → uint96Fees zeroed out
Token balancesuint256 → uint88/128Balance understated

8. Putting It All Together —

The Audit Checklist

Every arithmetic operation in a smart contract is a candidate for precision loss. The following checklist applies to any contract that performs financial calculations.

Division order

  • Every division is preceded by all relevant multiplications. No intermediate divisions exist in multi-step calculations.
  • For any a / b * c pattern: verify the result is acceptable at the minimum possible value of a. If a < b, the result is zero regardless of c.

Rounding direction

  • All interest and fee calculations round in favor of the protocol, not the user.
  • Withdrawal and reward calculations round down (user receives less than the theoretical amount, never more).
  • Collateral requirements round up (users must provide more, never less).
  • Math.ceilDiv or Math.mulDiv with Rounding.Ceil is used wherever rounding up is required.

Fixed-point arithmetic

  • All multiplications of WAD or RAY-scaled values use Math.mulDiv to prevent intermediate overflow.
  • The scaling factor is consistent throughout the contract. Mixed WAD/RAY arithmetic is documented and verified.
  • No fixed-point value is directly compared to a raw integer without accounting for the scaling factor.

Type casting

  • Every narrowing cast (uint256 → uint128, etc.) is preceded by an explicit bounds check or uses SafeCast.
  • Chainlink price feed results are checked for negativity before casting from int256 to uint256.
  • Block timestamps are not cast to uint32 without verifying the timestamp fits (valid until year 2106 for uint32).

Share-based vaults

  • Virtual shares and virtual assets are implemented to prevent the inflation attack on first deposit.
  • Share/asset conversion functions use consistent rounding direction throughout (never mix floor and ceil in the same conversion path).
  • The totalAssets() function cannot be manipulated by direct ETH or token donations.

Integer arithmetic bugs compound silently over time. A 1 wei rounding error per transaction across thousands of positions and millions of blocks is a material loss. The correct mental model: treat every division as a potential attack surface, verify rounding direction against the protocol’s solvency interests, and use mulDiv as the default for any multiplication that precedes a division.