🌿 Intermediate ⏱️ 45 min

Integer Arithmetic Bugs

Integer arithmetic bugs have caused some of the most spectacular losses in crypto history. The BEC token hack wiped $900M from the market in minutes using a single integer overflow. Even after Solidity 0.8 introduced automatic overflow/underflow protection, arithmetic bugs remain endemic — they just moved to precision loss, rounding direction errors, type truncation, and the silent dangers of the unchecked block. Understanding numbers in Solidity is essential for any serious auditor.

🚨 Critical

Solidity 0.8+ prevents overflow/underflow by default, but it did NOT eliminate arithmetic bugs. Precision loss, incorrect rounding direction, type truncation, and unchecked block misuse continue to cause real exploits. These are harder to spot than classic overflows.

Integer Types in Solidity

TypeBitsMin ValueMax ValueCommon Use
uint880255Small counters, flags
uint1616065,535Basis points (0-10000)
uint323204,294,967,295Timestamps (safe until 2106)
uint12812803.4 × 10^38Token amounts with 18 decimals
uint25625601.16 × 10^77Default for all amounts
int256256−5.79 × 10^765.79 × 10^76Signed balances, deltas

Classic Overflow: uint256 Max + 1 Wraps to 0

In Solidity before version 0.8, arithmetic would silently wrap around when it exceeded the type's bounds. Adding 1 to uint256 max would produce 0. This was the root cause of the BEC and SMT token hacks.

⚠️ Integer Overflow (Pre-0.8 Solidity)
// SPDX-License-Identifier: MIT pragma solidity ^0.6.0; // Pre-0.8 — no automatic overflow protection contract VulnerableToken { mapping(address => uint256) public balances; // The BEC Token batchTransfer() vulnerability (simplified) function batchTransfer(address[] memory receivers, uint256 value) public { uint256 cnt = receivers.length; // ❌ OVERFLOW: If value is huge, cnt * value wraps to a small number // Example: cnt=2, value=2^255 → 2 * 2^255 = 2^256 = 0 (overflow!) // amount = 0, so the require passes even though value is astronomical uint256 amount = cnt * value; require(amount <= balances[msg.sender]); // 0 <= balance → passes! balances[msg.sender] -= amount; // Deducts 0 from sender for (uint256 i = 0; i < cnt; ++i) { balances[receivers[i]] += value; // Each receiver gets 2^255 tokens! } } } // Result: Attacker mints astronomical token supply for free // BEC token market cap dropped from $900M to near zero in hours

Classic Underflow: uint256(0) - 1 Wraps to 2^256 - 1

⚠️ Integer Underflow — The Classic Token Hack Pattern
pragma solidity ^0.6.0; contract UnderflowToken { mapping(address => uint256) public balances; function transfer(address to, uint256 amount) public { // ❌ No check that sender has enough — underflow attack: // If balances[msg.sender] = 0 and amount = 1: // 0 - 1 = 2^256 - 1 (max uint256!) — attacker gets enormous balance balances[msg.sender] -= amount; balances[to] += amount; } } // ✅ Solidity 0.8+ automatically reverts on underflow: pragma solidity ^0.8.20; contract SafeToken { mapping(address => uint256) public balances; function transfer(address to, uint256 amount) public { // ✅ This automatically reverts if sender's balance < amount in 0.8+ balances[msg.sender] -= amount; // Reverts on underflow balances[to] += amount; } }

The unchecked Block: Power and Danger

Solidity 0.8's automatic checks cost gas because each arithmetic operation compiles to additional comparison instructions. The unchecked block disables these checks for gas optimization. It is legitimate in loops where you have already proven bounds — but dangerous in any other context.

⚠️ Dangerous Use of unchecked
pragma solidity ^0.8.20; contract UncheckedDanger { mapping(address => uint256) public balances; // ❌ Legitimate-looking gas optimization that reintroduces underflow function withdraw(uint256 amount) external { unchecked { // ❌ NO overflow/underflow protection inside unchecked // If balances[msg.sender] = 0 and amount = 1: wraps to max uint256 balances[msg.sender] -= amount; } (bool ok,) = msg.sender.call{value: amount}(""); require(ok); } // ✅ CORRECT: Check first, then use unchecked safely function withdrawSafe(uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient"); unchecked { balances[msg.sender] -= amount; // ✅ Safe: already proved balance >= amount } (bool ok,) = msg.sender.call{value: amount}(""); require(ok); } // ✅ CORRECT: Loop counter unchecked is safe because i < arr.length function processArray(uint256[] calldata arr) external { for (uint256 i = 0; i < arr.length; ) { // Process arr[i] unchecked { ++i; } // ✅ i < arr.length ensures no overflow } } }

Precision Loss from Integer Division

Solidity uses integer (floor) division. The result is always truncated toward zero — there are no decimals. In financial calculations, this rounding direction matters enormously and determines whether the protocol or the user is favored.

📊 Division Truncation — Rounding Direction Matters
pragma solidity ^0.8.20; contract RoundingExamples { // Basic truncation: // 1 / 3 = 0 (not 0.333...) // 10 / 3 = 3 (not 3.333...) // 7 / 2 = 3 (not 3.5) // ❌ Rounding DOWN when calculating fees (bad — undercharges users) function calculateFeeWrong(uint256 amount) external pure returns (uint256) { return (amount * 3) / 1000; // 0.3% fee — rounds DOWN // If amount = 100: (100 * 3) / 1000 = 300 / 1000 = 0 ← Zero fee! } // ✅ Rounding UP when calculating fees (protocol is protected) function calculateFeeCorrect(uint256 amount) external pure returns (uint256) { // Round up: (a + b - 1) / b = ceil(a / b) return (amount * 3 + 999) / 1000; // If amount = 100: (300 + 999) / 1000 = 1299 / 1000 = 1 ← Minimum 1 wei fee } // ❌ Rounding DOWN collateral value (bad — overvalues user collateral) function getCollateralValueWrong(uint256 shares, uint256 pricePerShare) external pure returns (uint256) { return shares * pricePerShare / 1e18; // Rounds down — user benefits slightly } // ✅ Rounding DOWN collateral (protocol is protected) // For collateral calculations, round DOWN to underestimate collateral value // This is intentional — the floor division protects the protocol }

Fixed-Point Math: Why Protocols Use 1e18

Since Solidity has no floating-point numbers, protocols represent fractional values by scaling them up by a large constant. The most common is 1e18 (WAD), meaning 1.0 is stored as 1000000000000000000. Operations must account for this scaling factor or precision will be lost.

📐 WAD and RAY Fixed-Point Math
pragma solidity ^0.8.20; contract FixedPointMath { uint256 constant WAD = 1e18; // 18 decimals — used for tokens, ratios uint256 constant RAY = 1e27; // 27 decimals — used for MakerDAO interest rates // ✅ Correct: WAD multiplication (result divided by WAD to maintain scale) function wadMul(uint256 a, uint256 b) internal pure returns (uint256) { return (a * b) / WAD; } // ❌ Wrong: Multiplying two WAD values without dividing gives WAD^2 scale function wadMulBroken(uint256 a, uint256 b) internal pure returns (uint256) { return a * b; // ❌ Result is 1e36 scale, not 1e18! } // Example: Calculate 1.5% of 1000 tokens (with 18 decimals) function applyFee(uint256 amount) external pure returns (uint256) { uint256 feeRate = 15 * WAD / 1000; // 1.5% as WAD = 0.015 * 1e18 return wadMul(amount, feeRate); // Returns 1.5% of amount } }

Type Casting Bugs: Silent Truncation

Casting a larger integer type to a smaller one silently discards the high-order bits. In Solidity 0.8, this does NOT revert — it silently truncates. This is one of the most common sources of precision and correctness bugs in DeFi protocols.

⚠️ Silent Truncation in Type Casting
pragma solidity ^0.8.20; contract CastingBugs { // ❌ Silent truncation — no revert, just wrong answer function badCast(uint256 largeAmount) external pure returns (uint128) { // If largeAmount > type(uint128).max (3.4e38), truncation occurs // Example: largeAmount = 2^128 + 1 → cast result = 1 return uint128(largeAmount); // ❌ Silently drops high bits } // ✅ Safe casting with bounds check function safeCast128(uint256 value) internal pure returns (uint128) { require(value <= type(uint128).max, "Value exceeds uint128"); return uint128(value); } // ✅ Use OpenZeppelin SafeCast library import "@openzeppelin/contracts/utils/math/SafeCast.sol"; using SafeCast for uint256; function safeConversion(uint256 amount) external pure returns (uint128) { return amount.toUint128(); // ✅ Reverts if value doesn't fit } }

Multiplication Before Division

Always multiply before you divide. Dividing first truncates the result, and that truncated value is then multiplied — compounding the precision loss. The rule: never divide an intermediate value that will be multiplied later.

📐 Multiply First — Preserve Precision
pragma solidity ^0.8.20; contract PrecisionOrder { // Scenario: Calculate 1/3 of 1000 tokens, then apply 10x multiplier // ❌ WRONG: Divide first — severe precision loss function divFirst(uint256 amount) external pure returns (uint256) { return (amount / 3) * 10; // 1000 / 3 = 333 (truncated), 333 * 10 = 3330 // Should be: 1000 * 10 / 3 = 3333 // Error: 3 tokens lost due to premature truncation } // ✅ CORRECT: Multiply first — minimal precision loss function mulFirst(uint256 amount) external pure returns (uint256) { return (amount * 10) / 3; // 1000 * 10 = 10000, 10000 / 3 = 3333 ✅ } // Real DeFi example: Interest rate calculation // Annual rate = 5%, elapsed = 30 days, total = 365 days, principal = 1000e18 function interestWrong(uint256 principal, uint256 rate, uint256 elapsed) external pure returns (uint256) { // ❌ Divides principal/365 first — truncates almost everything return (principal / 365) * rate * elapsed / 1e18; } function interestCorrect(uint256 principal, uint256 rate, uint256 elapsed) external pure returns (uint256) { // ✅ Multiplies all numerators first, divides all denominators at the end return principal * rate * elapsed / (365 * 1e18); } }
💡 Tip

The general rule: collect all multiplication factors in the numerator and all division factors in the denominator. Do one division at the end. Watch out for overflow — if multiplying large numbers before dividing could exceed uint256 max, use a library like PRBMath or Solmate's mulDivUp/mulDivDown which use 512-bit intermediate results.

Foundry Fuzz Test for Arithmetic Bugs

🧪 Foundry Fuzz Test — Arithmetic Invariants
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/FeeCalculator.sol"; contract ArithmeticFuzzTest is Test { FeeCalculator calc; function setUp() public { calc = new FeeCalculator(); } /// @notice Fee should never exceed 1% of amount function testFuzz_feeBoundedByOnePercent(uint256 amount) public { vm.assume(amount > 0 && amount <= type(uint128).max); // Reasonable range uint256 fee = calc.calculateFee(amount); // Invariant: fee must be <= 1% of amount assertLe(fee, amount / 100 + 1, "Fee exceeds 1%"); // Invariant: fee must be >= 0.05% of amount (not under-charging) assertGe(fee * 2000, amount, "Fee below minimum"); } /// @notice Cast should never silently lose data function testFuzz_safeCastNeverTruncates(uint256 value) public { if (value > type(uint128).max) { vm.expectRevert(); calc.safeCastToUint128(value); } else { uint128 result = calc.safeCastToUint128(value); assertEq(uint256(result), value, "Cast lost data"); } } }

Real-World Exploits

ProtocolYearLossBug TypeRoot Cause
BEC Token2018$900M market capOverflowbatchTransfer() overflow: cnt * value wrapped to 0
SMT Token2018$69M market capOverflowSame pattern as BEC — multiplication overflow
Compound2021$80MLogic / PrecisionCOMP distribution bug — incorrect reward calculation
bZx Fulcrum2020$954kPrecision LossOracle manipulation + precision loss in collateral ratio

Key Takeaways

  • Solidity 0.8 checked math is not a silver bullet. It prevents classic overflow/underflow, but precision loss, casting bugs, and unchecked block misuse remain real attack surfaces.
  • Always multiply before dividing. Premature division causes truncation that compounds when multiplied again.
  • Round in the direction that protects the protocol. When calculating fees to charge, round up. When valuing collateral, round down. When minting shares, round down.
  • Use SafeCast for all downcasts. Casting from uint256 to uint128 silently truncates without SafeCast. One bad cast can corrupt all accounting.
  • Treat unchecked blocks like assembly. They disable safety guarantees. Only use them when you have mathematically proven that overflow cannot occur in that code path.
  • Write fuzz tests for arithmetic invariants. Properties like "fee never exceeds X% of amount" are easy to express as fuzz test assertions and will catch rounding bugs across thousands of inputs.

Common DeFi Precision Bugs

⚠️ Fee Calculation Rounding Errors
// Example: Protocol charges 0.3% fee on deposits // With 18-decimal tokens (standard ERC20): contract FeeCalculationBugs { uint256 public constant FEE_BPS = 30; // 30 basis points = 0.3% uint256 public constant BPS_DENOM = 10000; // ❌ WRONG ORDER: Divides first, multiplies second function calculateFeeWrong(uint256 amount) external pure returns (uint256) { return amount / BPS_DENOM * FEE_BPS; // For amount = 999: 999/10000 = 0, 0*30 = 0 → Zero fee! // For amount = 9999: 9999/10000 = 0, 0*30 = 0 → Zero fee! // Fee is zero for all amounts < 10000 wei (very small deposits) } // ✅ CORRECT: Multiplies first, then divides function calculateFeeCorrect(uint256 amount) external pure returns (uint256) { return (amount * FEE_BPS) / BPS_DENOM; // For amount = 999: 999*30 = 29970, 29970/10000 = 2 (rounds down) // For amount = 9999: 9999*30 = 299970, 299970/10000 = 29 } // Interest accrual: shares vs absolute amounts // WRONG: Track absolute interest amounts (rounding each period) // CORRECT: Track a growing interest index (single rounding per operation) uint256 public interestIndex = 1e18; // Starts at 1.0 in WAD function accrueInterest(uint256 ratePerSecond) external { // Interest compounds: index grows by rate each second interestIndex = interestIndex * (1e18 + ratePerSecond) / 1e18; } }

Share Price Manipulation via Donation

⚠️ Share Price Inflation — Vault Donation Attack
// Any vault where assets can be sent directly (not via deposit) // is vulnerable to share price inflation by a first depositor contract VulnerableVault { uint256 public totalShares; mapping(address => uint256) public shares; IERC20 public asset; function deposit(uint256 assets) external returns (uint256 sharesToMint) { uint256 totalAssets = asset.balanceOf(address(this)); if (totalShares == 0) { sharesToMint = assets; // First deposit: 1:1 ratio } else { sharesToMint = (assets * totalShares) / totalAssets; } asset.transferFrom(msg.sender, address(this), assets); shares[msg.sender] += sharesToMint; totalShares += sharesToMint; } } // Attack with 10,000 USDC in vault (from other users): // 1. Deposit 1 wei → get 1 share (totalShares=1, totalAssets=1 wei) // 2. Donate 10,000 USDC directly to vault (totalShares=1, totalAssets≈10000 USDC) // 3. Next depositor: 5000 USDC × 1 / 10000 = 0 shares! Gets nothing. // 4. Attacker redeems 1 share → gets entire vault balance // ✅ FIX: Virtual shares (dead shares) — OZ ERC4626 offset approach uint256 private constant VIRTUAL_SHARES = 1e3; // Dead shares offset uint256 private constant VIRTUAL_ASSETS = 1; // Dead assets offset // Modified share calculation: // sharesToMint = assets * (totalShares + VIRTUAL_SHARES) / (totalAssets + VIRTUAL_ASSETS) // The virtual shares/assets make the initial price manipulation very expensive
Arithmetic BugDetection MethodSlither Detector
Classic overflow (pre-0.8)Look for uint arithmetic without SafeMathinteger-overflow
unchecked block misuseGrep for unchecked blocks, review eachtautology (indirect)
Division before multiplicationManual code review of calculationsdivide-before-multiply
Type casting truncationGrep for uint128(x), uint32(x) downcaststautology
Wrong rounding directionVerify each division favors protocolManual review required

Key Takeaways

  • Solidity 0.8+ checks overflow/underflow by default — but unchecked blocks opt out. Every unchecked block must be manually verified to be safe.
  • Integer division truncates toward zero — always multiply before dividing, and round in the protocol's favor (up for debts owed, down for withdrawals claimed).
  • The BEC token overflow exploit ($900M paper loss) was caused by a multiplication overflow in a batch transfer — a single missing SafeMath call.
  • Fixed-point arithmetic (WAD/RAY scaling) is the standard for precision in DeFi — use MulDiv-style libraries for safe intermediate computation without overflow.
  • ERC4626 share price inflation attacks can drain vaults — mitigated by virtual shares or minimum first deposit requirements enforced at the contract level.
  • Unsafe type casting (uint128(x) when x > type(uint128).max) silently truncates — use OpenZeppelin's SafeCast library for all downcasts.

Foundry Fuzz Test for Arithmetic Invariants

Fuzz Testing Rounding and Overflow Properties
contract ArithmeticFuzzTest is Test { MathLibrary lib; function setUp() public { lib = new MathLibrary(); } /// @dev Fuzz: mulDiv should never overflow and should satisfy a*b/c <= result+1 function testFuzz_MulDivRoundingDown( uint256 a, uint256 b, uint256 c ) public { vm.assume(c > 0); vm.assume(a < type(uint256).max / b + 1); // Prevent overflow uint256 result = Math.mulDiv(a, b, c); // Result should never exceed the true value assertLe(result, (a * b) / c + 1); // Result should be within 1 of true value assertGe(result + 1, (a * b) / c); } /// @dev Fuzz: deposit then withdraw should never profit (rounding in protocol's favor) function testFuzz_DepositWithdrawNeverProfit(uint256 depositAmount) public { vm.assume(depositAmount >= 1000 && depositAmount <= 1e36); uint256 shares = vault.deposit(depositAmount, address(this)); uint256 assets = vault.redeem(shares, address(this), address(this)); // User should never receive MORE than they deposited assertLe(assets, depositAmount, "Vault rounding gives profit to user"); } }