Arithmetic & Precision Vulnerability Patterns
Distilled from 300+ Code4rena audit findings. Arithmetic bugs are among the hardest to spot during review because the logic looks correct at a glance — the error only manifests at boundary conditions or with unusual input values. These patterns explain where precision loss and overflow/underflow most commonly appear in DeFi protocols.
Overflow & Underflow
1. Unchecked Subtraction (Underflow)
Pattern: A balance or counter is decremented without verifying it is >= the amount being subtracted. In Solidity ≥0.8.0, this reverts — but code inside unchecked {} blocks bypasses the check. Older Solidity silently wraps to 2^256 - 1.
Example (vulnerable, Solidity 0.8 with unchecked):
unchecked {
balances[user] -= amount; // no require(balances[user] >= amount)
}
Real-world context: Repayment functions in lending protocols. If a user repays more than their debt (off-by-one in interest calculation), the debt underflows, treating the user as having an astronomically large debt or zero debt depending on how the value is used.
Fix:
require(balances[user] >= amount, "insufficient balance");
balances[user] -= amount;
2. Accumulation Overflow
Pattern: A value accumulates through repeated additions without a ceiling check. If the accumulator overflows uint256 (2^256 − 1 ≈ 1.16 × 10^77), it wraps to 0, silently resetting accounting.
Real-world context: Locked token accumulators in veToken contracts. If totalLocked wraps to zero, the protocol believes no tokens are locked, potentially allowing users to exit without burning lock positions.
Fix: Add require(totalLocked + amount <= MAX_TOTAL_LOCKED) or use SafeMath patterns for critical accumulators even in Solidity 0.8.
3. Type Casting Truncation
Pattern: A uint256 value is cast to a smaller type (e.g., uint128, uint64) without checking whether the value fits. The upper bits are silently dropped.
Example (vulnerable):
uint64 timestamp = uint64(block.timestamp); // safe until year 2554
uint64 amount = uint64(largeAmount); // DANGEROUS if largeAmount > type(uint64).max
Fix: Use OpenZeppelin's SafeCast library for all downcast operations.
Precision Loss (Fixed-Point Arithmetic)
1. Division Before Multiplication
Pattern: Dividing before multiplying causes truncation of intermediate results because Solidity performs integer division (floor). The correct order is always multiply first, divide last.
Example (vulnerable):
uint256 fee = (amount / 10000) * bps; // precision lost in first division
Fix:
uint256 fee = (amount * bps) / 10000; // multiply first
Rule of thumb: In any expression (a / b) * c, the precision loss is up to (b-1) * c / b. For large c, this matters.
2. Scaling Factor Misuse in Reward Distribution
Pattern: A reward-per-token calculation uses an incorrect scaling factor that either causes integer division to zero (when the weight is large) or creates huge multipliers (when the weight is small).
Real-world example:
// Vulnerable: gaugeWeight is an absolute token amount, not a fraction
uint256 rewardPerGauge = (totalReward * 1e18) / gaugeWeight;
If gaugeWeight is 1 wei and totalReward is 1e18, rewardPerGauge = 1e36 — which exceeds available funds and corrupts accounting.
Fix: Normalize gauge weights to percentages (0–10000 bps) before using them as divisors. Validate that the result is within expected bounds with require.
3. Rounding Direction Bias
Pattern: When computing how much a user owes (debt) or how much protocol earns (fees), always round against the user and in favor of the protocol to prevent dust accumulation or insolvency. Rounding the wrong direction, even by 1 wei per operation, can drain a protocol over millions of transactions.
Fix:
- For fees/debts owed by user: round up (
(a + b - 1) / b) - For amounts claimable by user: round down (standard integer division)
4. shares/assets Discrepancy in ERC-4626 Vaults
Pattern: ERC-4626 convertToShares and convertToAssets must be inverses. If the protocol rounds in the same direction in both functions, arbitrage is possible: deposit 1000 assets → get N shares → redeem N shares → get 1001 assets.
Fix: Follow ERC-4626 spec: convertToShares rounds down; convertToAssets rounds down (for withdrawal, round in vault's favor).
Compound Interest & Time-Based Precision
5. Block Timestamp vs. Block Number
Pattern: Using block.number for time-based calculations assumes fixed block times. On L2s and sidechains, block times differ significantly from Ethereum mainnet (12s). Code originally written for ETH on BSC (3s blocks) runs 4× faster, compressing intended time windows.
Fix: Use block.timestamp for durations. If block counts are required (e.g., voting periods), make them configurable at deployment time per chain.
6. Exponential Interest Rate Precision
Pattern: Compound interest rates represented as (1 + r)^n in integer math lose precision when n is large or r is small. Using linear approximation (1 + r*n) instead of compound interest introduces systematic undercharging.
Fix: Use a well-tested interest accrual library (e.g., Aave's MathUtils.calculateCompoundedInterest) with ray-precision (1e27) rather than implementing from scratch.
Audit Checklist
- Every subtraction: is there a
require(a >= b)or equivalent guard? - Every addition to a running total: is there an overflow ceiling check?
- Every downcast (
uint256 → uint128): isSafeCastused? - Every division: does multiplication precede it in the expression?
- Reward/fee calculations: are scaling factors (1e18 vs absolute values) consistent?
- Rounding direction: do user-favoring paths round down, protocol-favoring paths round up?
- Time-based code: does it use
block.timestamp, and are constants chain-aware? - ERC-4626 vaults: are
convertToShares/convertToAssetsround-trip safe?