
Solidity Checked Arithmetic and Overflow Safety: Writing Correct Math in Modern Contracts
Why arithmetic safety matters
Smart contracts often encode financial state:
- token balances
- share accounting
- fee calculations
- vesting schedules
- reward distribution
- time-based emission logic
A single arithmetic mistake can corrupt state permanently. For example, if a subtraction underflows in a balance update, a user may receive an enormous wrapped value in older Solidity versions. Even in modern Solidity, arithmetic checks can revert unexpectedly if your logic does not account for edge cases.
The key idea is simple:
- Default arithmetic is checked in Solidity
^0.8.0 - Overflow or underflow reverts automatically
uncheckeddisables those checks inside a limited block
That means the language is safer by default, but developers still need to understand the exact semantics.
Checked arithmetic in Solidity 0.8+
In Solidity ^0.8.0, these operations revert on overflow or underflow:
+-*- exponentiation
** - unary negation on signed integers
Division and modulo do not overflow in the same way, but they still have important edge cases, such as division by zero.
Example: safe balance updates
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
uint256 current = balances[msg.sender];
require(current >= amount, "insufficient balance");
balances[msg.sender] = current - amount;
payable(msg.sender).transfer(amount);
}
}In this contract, the subtraction is checked by default. If amount > current, the transaction reverts. The explicit require improves error clarity and makes the intended invariant obvious.
What changes from older Solidity versions?
Before 0.8.0, the same subtraction would wrap around on underflow. That meant:
0 - 1became2^256 - 1- counters could jump to huge values
- balance logic could be exploited if not protected by libraries like SafeMath
If you maintain legacy code, be careful when upgrading compiler versions. Code that once wrapped may now revert.
When unchecked is appropriate
Checked arithmetic is safer, but it adds runtime cost. In some hot paths, you can save gas by using unchecked when you can prove the operation cannot overflow or underflow.
Typical safe uses include:
- loop counters with known bounds
- decrementing after a prior
require - adding small values to a bounded accumulator
- arithmetic where invariants are enforced elsewhere in the function
Example: gas-optimized loop
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SumRange {
function sum(uint256 n) external pure returns (uint256 total) {
for (uint256 i = 0; i < n; ) {
total += i;
unchecked {
++i;
}
}
}
}Here, i starts at 0 and increments until i < n fails. If n is a normal user-supplied uint256, the loop still terminates before overflow because i cannot reach type(uint256).max without the condition failing first in any realistic execution. The increment is placed in unchecked to avoid repeated overflow checks.
Important rule
Only use unchecked when you can state the invariant in plain language. If you cannot explain why overflow is impossible, do not disable the check.
Common arithmetic patterns and their pitfalls
1. Counter increments
Counters are often safe to optimize, but only if they are bounded by logic.
function mintBatch(uint256 quantity) external {
require(quantity > 0, "invalid quantity");
for (uint256 i = 0; i < quantity; ) {
_mintOne(msg.sender);
unchecked { ++i; }
}
}If quantity is user-controlled, the loop itself may still be expensive or hit block gas limits. Arithmetic safety is only one part of the design.
2. Fee calculations
Fees are a frequent source of rounding and overflow issues.
function calculateFee(uint256 amount, uint256 bps) public pure returns (uint256) {
require(bps <= 10_000, "invalid bps");
return (amount * bps) / 10_000;
}This is correct only if amount * bps cannot overflow. In practice, amount may be large enough that multiplication reverts even though the final fee would fit.
A safer pattern is to bound inputs or reorder the math when possible:
function calculateFee(uint256 amount, uint256 bps) public pure returns (uint256) {
require(bps <= 10_000, "invalid bps");
return amount / 10_000 * bps;
}However, this changes rounding behavior and may undercharge fees. The right formula depends on your business rules. For exact financial math, you often need a deliberate rounding strategy.
3. Decrementing balances
Subtraction is safe if you check the precondition first.
require(balance >= amount, "insufficient balance");
balance -= amount;If the check is already present, placing the subtraction in unchecked can save gas, but only if the invariant is guaranteed by the code structure.
Choosing between checked and unchecked arithmetic
| Situation | Recommended approach | Reason |
|---|---|---|
| User-facing balance updates | Checked arithmetic | Safety is more important than minor gas savings |
| Loop counters with clear bounds | unchecked increment | Common optimization with low risk |
| Math after explicit invariant checks | Possibly unchecked | The precondition already guarantees safety |
| Financial calculations with large values | Checked arithmetic or specialized math | Overflow risk and rounding need careful handling |
| Legacy code migrated from <0.8.0 | Review every arithmetic operation | Behavior may change from wraparound to revert |
A good rule of thumb: default to checked arithmetic, then optimize only the parts that are proven safe and performance-sensitive.
Designing arithmetic around invariants
The best arithmetic code is not just “safe”; it is structured so the safety is obvious from the contract’s invariants.
Example: capped supply token
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CappedToken {
uint256 public totalSupply;
uint256 public immutable cap;
mapping(address => uint256) public balanceOf;
constructor(uint256 _cap) {
require(_cap > 0, "cap is zero");
cap = _cap;
}
function mint(address to, uint256 amount) external {
require(totalSupply + amount <= cap, "cap exceeded");
totalSupply += amount;
balanceOf[to] += amount;
}
}This design relies on a clear invariant: totalSupply must never exceed cap. The require protects the addition, and the state update follows immediately after. If you later refactor this function, preserve that ordering.
Why this matters
If you update state before checking the cap, a revert will roll back the transaction, but the code becomes harder to reason about. Keeping the check close to the arithmetic makes the invariant explicit.
Signed integers need extra care
Most contract math uses uint256, but signed integers are useful for deltas, price movement, or accounting adjustments. Signed arithmetic has its own edge cases:
- negative values are allowed
- overflow and underflow still revert in Solidity 0.8+
int256has one special case:-type(int256).mincannot be represented
Example: signed delta application
function applyDelta(int256 current, int256 delta) external pure returns (int256) {
return current + delta;
}This is safe under checked arithmetic, but you still need to think about domain constraints. If current represents a quantity that must never be negative, you should validate the result before storing it.
function setAdjustedBalance(int256 current, int256 delta) external pure returns (int256) {
int256 next = current + delta;
require(next >= 0, "negative balance");
return next;
}Do not assume signed arithmetic is a shortcut for “allow negatives.” It is still your responsibility to define valid ranges.
Multiplication and division order
Many bugs come from the order of operations rather than the arithmetic operator itself.
Bad pattern: multiply first without bounds
uint256 payout = amount * rate / SCALE;If amount * rate overflows, the function reverts even if the final result would have been valid.
Better pattern: constrain inputs or use a safer formula
If rate is bounded and small, multiplication may be fine. If not, you may need:
- tighter input validation
- a different scaling strategy
- a dedicated fixed-point math library
For example, if rate is basis points and SCALE = 10_000, then rate <= 10_000 is easy to enforce. But if you are working with high-precision decimals or oracle prices, the arithmetic design becomes more complex than a simple * / expression.
Practical advice
When building financial logic:
- define the unit scale explicitly
- document rounding direction
- validate upper bounds before multiplication
- test edge cases near
type(uint256).max
Testing overflow behavior
Arithmetic bugs are easy to miss unless you test boundary conditions.
Useful test cases
- zero values
- maximum values
- one less than a limit
- exact cap values
- large multiplication inputs
- loop bounds near the expected maximum
If you use Foundry or Hardhat, write tests that assert both success and revert behavior. For example, verify that a capped mint reverts when the cap would be exceeded, and that a subtraction reverts when the balance is too low.
A strong test suite should prove your invariants, not just exercise the happy path.
Best practices for safe math
Prefer explicit invariants
Write conditions that explain why the arithmetic is safe:
require(balance >= amount)require(totalSupply + amount <= cap)require(bps <= 10_000)
Use unchecked sparingly
Only disable checks when:
- the operation is in a performance-critical path
- the invariant is already enforced
- the code is easy to audit
Keep arithmetic close to validation
Do not separate the check and the update across distant code paths unless necessary. Locality improves readability and auditability.
Be careful with external inputs
Any value from calldata, a price feed, or another contract can be unexpectedly large or malformed. Validate before combining it with sensitive state.
Treat rounding as a design decision
Integer division truncates toward zero. That is not a bug, but it is often a source of subtle economic differences. Decide whether you want to round down, round up, or distribute remainders.
Summary
Checked arithmetic in modern Solidity eliminates an entire class of silent overflow bugs, but it does not remove the need for careful design. The safest contracts make arithmetic invariants explicit, validate inputs before combining them, and use unchecked only where the proof is simple and the gas savings are meaningful.
If your contract handles value, arithmetic is part of your security model. Treat it that way.
