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.
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
| Type | Bits | Min Value | Max Value | Common Use |
|---|---|---|---|---|
| uint8 | 8 | 0 | 255 | Small counters, flags |
| uint16 | 16 | 0 | 65,535 | Basis points (0-10000) |
| uint32 | 32 | 0 | 4,294,967,295 | Timestamps (safe until 2106) |
| uint128 | 128 | 0 | 3.4 × 10^38 | Token amounts with 18 decimals |
| uint256 | 256 | 0 | 1.16 × 10^77 | Default for all amounts |
| int256 | 256 | −5.79 × 10^76 | 5.79 × 10^76 | Signed 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.
// 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 hoursClassic Underflow: uint256(0) - 1 Wraps to 2^256 - 1
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.
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.
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.
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.
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.
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);
}
}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
// 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
| Protocol | Year | Loss | Bug Type | Root Cause |
|---|---|---|---|---|
| BEC Token | 2018 | $900M market cap | Overflow | batchTransfer() overflow: cnt * value wrapped to 0 |
| SMT Token | 2018 | $69M market cap | Overflow | Same pattern as BEC — multiplication overflow |
| Compound | 2021 | $80M | Logic / Precision | COMP distribution bug — incorrect reward calculation |
| bZx Fulcrum | 2020 | $954k | Precision Loss | Oracle 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
// 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
// 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 Bug | Detection Method | Slither Detector |
|---|---|---|
| Classic overflow (pre-0.8) | Look for uint arithmetic without SafeMath | integer-overflow |
| unchecked block misuse | Grep for unchecked blocks, review each | tautology (indirect) |
| Division before multiplication | Manual code review of calculations | divide-before-multiply |
| Type casting truncation | Grep for uint128(x), uint32(x) downcasts | tautology |
| Wrong rounding direction | Verify each division favors protocol | Manual review required |
Key Takeaways
- Solidity 0.8+ checks overflow/underflow by default — but
uncheckedblocks opt out. Everyuncheckedblock 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
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");
}
}