
Preventing Integer Overflow and Underflow in Solidity
Why arithmetic safety matters
Solidity uses fixed-size unsigned and signed integers such as uint256, uint128, and int256. These types have strict bounds. For example, uint8 can only represent values from 0 to 255. If a value exceeds the maximum, it wraps around in older Solidity versions or in unchecked arithmetic.
That behavior can be catastrophic in financial logic:
- A deposit counter can wrap to zero.
- A token balance can become unexpectedly large after subtraction underflow.
- A fee calculation can produce incorrect results.
- A loop index can overflow and create logic errors.
Even when the contract does not directly lose funds, arithmetic bugs often become the root cause of broken invariants and downstream vulnerabilities.
Solidity’s default safety model
Since Solidity 0.8.0, arithmetic operations revert on overflow and underflow by default. This is a major improvement over earlier versions, where wrapping behavior was the default.
Example: safe by default
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeCounter {
uint256 public count;
function increment() external {
count += 1; // reverts on overflow
}
function decrement() external {
require(count > 0, "count is zero");
count -= 1; // reverts on underflow if count is 0
}
}In this version, count += 1 and count -= 1 are checked automatically. If the result would exceed the integer range, the transaction reverts.
What still requires attention
Built-in checks do not eliminate arithmetic risk entirely. You still need to consider:
uncheckedblocks- legacy contracts compiled with Solidity
<0.8.0 - explicit type conversions
- arithmetic in assembly
- assumptions about token decimals or external values
- multiplication and division ordering in financial formulas
When overflow and underflow still happen
1. unchecked arithmetic
The unchecked keyword disables overflow checks for a block of code. This is useful for gas optimization, but it must be used carefully.
function incrementUnchecked(uint256 x) external pure returns (uint256) {
unchecked {
return x + 1;
}
}If x is type(uint256).max, the result wraps to 0 instead of reverting.
Use unchecked only when you can prove the operation is safe. Common examples include loop counters with bounded ranges or arithmetic immediately preceded by a guard condition.
2. Legacy Solidity versions
Contracts compiled with Solidity 0.7.x and earlier do not revert on overflow by default. If you maintain older code, you must explicitly use a safe math library or migrate to a newer compiler version.
3. Explicit type conversion
Casting between integer types can truncate values.
uint256 bigValue = 300;
uint8 smallValue = uint8(bigValue); // becomes 44This is not overflow in the arithmetic sense, but it is still a dangerous loss of data. Truncation can break access control, accounting, and signature validation logic.
4. Assembly code
Inline assembly bypasses Solidity’s safety checks.
function addAsm(uint256 a, uint256 b) external pure returns (uint256 c) {
assembly {
c := add(a, b)
}
}The EVM add instruction wraps on overflow. If you use assembly, you are responsible for implementing your own bounds checks.
Common arithmetic pitfalls in real contracts
Token accounting
A token contract often updates balances, allowances, and total supply. A single bad subtraction can create a negative balance in logic terms, even if the type is unsigned.
balances[msg.sender] -= amount;If amount exceeds the sender’s balance, the transaction reverts in Solidity 0.8+. That is good, but only if the contract uses the compiler’s checked arithmetic and does not bypass it with unchecked or assembly.
Fee calculation
Fees are often calculated as a percentage of a transfer or deposit amount.
uint256 fee = amount * feeBps / 10_000;This looks simple, but if amount * feeBps exceeds uint256, it reverts. That may be acceptable, but you should understand the maximum possible input values and whether the formula should be rearranged or bounded.
Reward distribution
Reward systems often use accumulators and per-share accounting. These patterns can involve large intermediate values. If the contract stores scaled values, multiplication can overflow before division reduces the result.
Safe arithmetic patterns
Prefer checked arithmetic with clear bounds
The best defense is to design values so they never approach the integer limit. Use realistic caps on user input, supply, deposits, and time-based accumulation.
uint256 public constant MAX_DEPOSIT = 1_000_000 ether;
function deposit(uint256 amount) external {
require(amount > 0 && amount <= MAX_DEPOSIT, "invalid amount");
totalDeposits += amount;
}This approach reduces the chance that arithmetic becomes a hidden failure point.
Validate before subtracting
Even though Solidity 0.8+ reverts on underflow, explicit validation improves readability and error reporting.
function withdraw(uint256 amount) external {
uint256 balance = balances[msg.sender];
require(balance >= amount, "insufficient balance");
balances[msg.sender] = balance - amount;
}This pattern makes the intended invariant obvious: a user cannot withdraw more than their balance.
Use unchecked only with proof
unchecked is appropriate when the compiler’s default checks are redundant and the range is already guaranteed.
A common example is a loop counter:
function sum(uint256[] calldata values) external pure returns (uint256 total) {
for (uint256 i = 0; i < values.length; ) {
total += values[i];
unchecked {
++i;
}
}
}Here, i cannot overflow because i < values.length and values.length is bounded by calldata size. The unchecked increment saves gas without weakening safety.
Choosing the right integer type
Using uint256 everywhere is common and often safest. Smaller types can save storage in some cases, but they also increase the risk of truncation and awkward conversions.
| Type | Typical use | Security notes |
|---|---|---|
uint256 | Balances, supply, counters | Easiest to work with; default choice |
uint128 | Packed storage fields | Requires careful casting and range checks |
uint64 | Timestamps, compact counters | Can overflow sooner than expected |
int256 | Signed deltas, price changes | Must handle negative values explicitly |
uint8 | Small enums, flags | Easy to overflow or truncate accidentally |
For security-sensitive code, prefer uint256 unless there is a strong reason to use a smaller type. If you do use smaller types, enforce bounds before casting.
Ordering operations to avoid intermediate overflow
A common mistake is multiplying before dividing when the multiplication can exceed the type range.
Risky pattern
uint256 fee = amount * rate / 10_000;If amount and rate are both large, the multiplication may overflow even though the final fee would fit.
Safer approaches
- Bound inputs
- Reorder the formula when mathematically valid
- Use a fixed-point math library for complex calculations
For example, if rate is guaranteed to be at most 10_000, the formula may be safe for a bounded amount. But if amount can be very large, you should enforce a maximum or use a library designed for precision math.
Handling signed integers carefully
Signed integers introduce additional edge cases. The minimum value of a signed integer cannot be negated safely in two’s complement arithmetic.
function negate(int256 x) external pure returns (int256) {
return -x;
}If x == type(int256).min, negation overflows and reverts in Solidity 0.8+. This is a rare but important edge case, especially in financial logic that tracks gains and losses.
When using signed integers:
- validate ranges explicitly
- avoid negating untrusted values without checks
- be careful when converting between signed and unsigned types
- document whether negative values are allowed
Legacy SafeMath and modern Solidity
Before Solidity 0.8, developers commonly used libraries such as OpenZeppelin’s SafeMath to prevent overflow and underflow. In modern Solidity, SafeMath is usually unnecessary for standard arithmetic because the compiler already performs checks.
However, you may still encounter it in older codebases or libraries. When auditing such contracts:
- verify the compiler version
- check whether arithmetic is already protected
- look for custom math wrappers that may disable checks
- inspect any
uncheckedor assembly-based code paths
If you are migrating a legacy contract, removing SafeMath is not enough. You must also review assumptions about revert behavior and gas usage.
Testing arithmetic invariants
Arithmetic bugs are often best caught with tests that focus on boundary values.
Recommended test cases
- zero values
- one-unit increments and decrements
- maximum representable values
- values near type limits
- large fee or reward inputs
- signed minimum and maximum values
- cast boundaries for smaller integer types
Example invariant checks
For a vault contract, useful invariants might include:
totalAssets >= sum(userBalances)totalSupplynever decreases except on burn- withdrawals never exceed deposits
- fee calculations never exceed the input amount
Property-based testing tools can help generate edge cases automatically. Fuzzing is especially effective for arithmetic because it explores combinations that manual tests often miss.
Practical checklist for secure arithmetic
- Use Solidity
0.8+unless you have a strong compatibility reason not to. - Prefer
uint256for most state values. - Validate user input before arithmetic when the expected range is known.
- Avoid
uncheckedunless you can prove safety. - Treat explicit casts as security-sensitive operations.
- Be cautious with multiplication before division.
- Review assembly code separately from Solidity code.
- Test boundary conditions and fuzz arithmetic-heavy functions.
Conclusion
Integer overflow and underflow are no longer as easy to exploit in modern Solidity, but they remain a serious security concern in real-world contracts. The compiler’s default checks are a strong baseline, not a substitute for careful design.
The safest approach is to combine bounded inputs, clear invariants, conservative type choices, and thorough testing. When you do need to optimize with unchecked or assembly, document the reasoning and prove the arithmetic is safe under all expected conditions.
