Why arithmetic edge cases deserve dedicated tests

Most smart contracts perform arithmetic in one of three contexts:

  • balance updates
  • fee calculations
  • share or reward distribution

Each of these can fail in different ways. A transfer function might underflow when subtracting from a balance. A fee formula might round down too aggressively and leak value. A reward distributor might divide by zero when no participants exist.

Solidity 0.8.x automatically reverts on overflow and underflow for standard arithmetic operators, but that does not eliminate the need for tests. Instead, it changes the testing target:

  • verify that dangerous inputs revert
  • verify that safe inputs produce exact values
  • verify that rounding direction is intentional
  • verify that unchecked blocks are used only where mathematically justified

A good arithmetic test suite catches both security issues and economic drift.


Core edge cases to test

The most important arithmetic scenarios are summarized below.

Edge caseTypical riskWhat to assert
Addition overflowValue wraps or reverts unexpectedlyRevert on type(uint256).max + 1
Subtraction underflowNegative result becomes huge value in older SolidityRevert when subtracting more than available
Multiplication overflowLarge intermediate product breaks accountingRevert on large operands
Division by zeroTransaction revertsRevert when denominator is zero
Integer truncationFees or shares lose precisionExact rounded-down result
unchecked arithmeticSilent wraparound if misusedExplicitly test boundary behavior

The key idea is to test the contract’s mathematical contract, not just its happy path.


Example contract: fee calculation with bounded arithmetic

Consider a simple fee module that charges a basis-point fee and tracks collected fees.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract FeeVault {
    uint256 public constant BPS_DENOMINATOR = 10_000;
    uint256 public collectedFees;

    function quoteFee(uint256 amount, uint256 feeBps) public pure returns (uint256) {
        require(feeBps <= BPS_DENOMINATOR, "fee too high");
        return (amount * feeBps) / BPS_DENOMINATOR;
    }

    function collect(uint256 amount, uint256 feeBps) external returns (uint256 fee) {
        fee = quoteFee(amount, feeBps);
        collectedFees += fee;
    }

    function withdraw(uint256 amount) external {
        require(collectedFees >= amount, "insufficient fees");
        collectedFees -= amount;
    }
}

This contract has several arithmetic surfaces:

  • amount * feeBps may overflow for extreme values
  • division truncates toward zero
  • collectedFees += fee may overflow if the contract is fed absurd values
  • withdraw must not underflow

Even though Solidity protects against overflow and underflow, the contract still needs tests to prove the intended behavior.


Testing overflow and underflow reverts

The most direct arithmetic tests target boundary values. In a framework like Foundry, you can assert reverts using vm.expectRevert. In Hardhat, you would use await expect(tx).to.be.reverted.

Foundry-style test example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/FeeVault.sol";

contract FeeVaultTest is Test {
    FeeVault vault;

    function setUp() public {
        vault = new FeeVault();
    }

    function testQuoteFeeRevertsOnOverflow() public {
        uint256 amount = type(uint256).max;
        uint256 feeBps = 10_000;

        vm.expectRevert();
        vault.quoteFee(amount, feeBps);
    }

    function testWithdrawRevertsOnUnderflow() public {
        vm.expectRevert(bytes("insufficient fees"));
        vault.withdraw(1);
    }

    function testCollectUpdatesCollectedFees() public {
        uint256 fee = vault.collect(1_000 ether, 250);
        assertEq(fee, 25 ether);
        assertEq(vault.collectedFees(), 25 ether);
    }
}

What to look for

  • Use boundary values such as 0, 1, type(uint256).max, and type(uint256).max / 2
  • Test both the revert and the exact success case
  • Distinguish between arithmetic reverts and custom require failures

For example, quoteFee can revert because of multiplication overflow before it ever reaches the division. That is a different failure mode than feeBps > 10_000.


Testing rounding behavior explicitly

Integer division in Solidity truncates toward zero. This is one of the most common sources of subtle bugs in token math and fee logic. If your contract calculates percentages, rewards, or pro-rata shares, you should test the exact rounding outcome.

Example: fee rounding down

Suppose amount = 101 and feeBps = 100 (1%). The mathematically exact fee is 1.01, but Solidity returns 1.

function testQuoteFeeRoundsDown() public {
    uint256 fee = vault.quoteFee(101, 100);
    assertEq(fee, 1);
}

This test is small, but it protects against accidental changes to the formula. If someone later rewrites the code as (amount / BPS_DENOMINATOR) * feeBps, the result for small amounts may become zero, which is economically different.

Test multiple rounding boundaries

A robust suite should check values around the threshold:

  • amount = 99, 100, 101
  • feeBps = 1, 100, 250
  • values that produce exact division versus truncated division

For example:

function testQuoteFeeBoundaryValues() public {
    assertEq(vault.quoteFee(100, 100), 1);
    assertEq(vault.quoteFee(199, 100), 1);
    assertEq(vault.quoteFee(200, 100), 2);
}

These tests make the rounding policy visible and intentional.


Testing unchecked blocks safely

Sometimes developers use unchecked for gas savings, especially in loops or tightly controlled arithmetic. This is acceptable only when the surrounding logic guarantees safety. Tests should prove that guarantee.

Consider a loop that sums a small array of amounts:

function sum(uint256[] memory amounts) public pure returns (uint256 total) {
    for (uint256 i = 0; i < amounts.length; ) {
        total += amounts[i];
        unchecked {
            ++i;
        }
    }
}

The increment is safe because i is bounded by amounts.length. The addition is not safe unless the input values are constrained.

Test strategy for unchecked

  1. Test normal inputs and verify the exact total.
  2. Test extreme inputs and confirm whether the function should revert or wrap.
  3. If wrapping is impossible by design, add input constraints and test those constraints.

A safer version might include a precondition:

require(amounts.length <= 1_000, "too many items");

Then your tests should verify that the limit is enforced and that the loop behaves correctly within the limit.


Avoiding false confidence with “happy path only” tests

Arithmetic bugs often hide in values that are not representative of production traffic. If you only test with neat numbers like 1 ether, 10 ether, and 100 ether, you may miss failures caused by:

  • tiny values that round to zero
  • large values near uint256 limits
  • empty arrays or zero denominators
  • repeated accumulation over many calls

A better test plan includes:

  • minimum values
  • maximum values
  • values just above and below thresholds
  • repeated operations that accumulate state
  • mixed input sizes

Example: repeated collection

If collect is called many times, collectedFees grows monotonically. A test should verify that accumulation remains correct after multiple calls:

function testCollectAccumulatesFees() public {
    vault.collect(1_000, 100); // fee = 10
    vault.collect(2_000, 100); // fee = 20
    vault.collect(3_000, 100); // fee = 30

    assertEq(vault.collectedFees(), 60);
}

This catches stateful arithmetic errors that single-call tests may miss.


Testing division by zero and invalid denominators

Any formula with a denominator should be tested against zero. Even if your contract uses a constant denominator, user-supplied denominators or dynamic share counts can become zero in edge cases.

For example:

function split(uint256 amount, uint256 parts) public pure returns (uint256) {
    require(parts > 0, "parts zero");
    return amount / parts;
}

A test should confirm both the guard and the normal behavior:

function testSplitRevertsOnZeroParts() public {
    vm.expectRevert(bytes("parts zero"));
    vault.split(100, 0);
}

function testSplitRoundsDown() public {
    assertEq(vault.split(10, 3), 3);
}

This is especially important in reward distribution contracts, where parts may represent active stakers, voters, or beneficiaries.


Best practices for arithmetic test design

Use the following guidelines to keep tests precise and maintainable.

PracticeWhy it matters
Test boundary valuesArithmetic bugs usually appear at extremes
Assert exact outputsPrevent silent changes in rounding behavior
Separate revert causesOverflow, underflow, and validation failures are different
Include repeated state updatesAccumulation bugs often emerge over time
Test zero inputsZero is a common source of division and logic errors
Document rounding policyPrevent future refactors from changing economics

Practical recommendations

  • Prefer explicit helper functions for formulas, such as quoteFee, previewMint, or computeShare
  • Keep arithmetic logic small and testable
  • Avoid burying math inside large state-changing functions
  • Use named constants for denominators and scaling factors
  • Add comments that explain why a particular rounding direction is correct

When arithmetic is central to protocol economics, tests should read like executable specifications.


When to use fuzzing alongside deterministic tests

Deterministic tests are ideal for known boundaries and expected outputs. Fuzzing is useful for discovering unexpected combinations. The two approaches complement each other.

Use deterministic tests to verify:

  • exact rounding at known thresholds
  • specific overflow and underflow boundaries
  • expected revert messages or custom errors

Use fuzzing to verify:

  • no unexpected reverts within valid ranges
  • algebraic properties such as monotonicity
  • invariants like withdraw(x) never increases collectedFees

A practical pattern is to write deterministic tests first, then add fuzz tests for broader coverage. That way, you have a clear baseline for the intended math before exploring the input space.


Conclusion

Arithmetic testing in Solidity is not just about preventing crashes. It is about proving that your contract’s numerical behavior is correct, stable, and economically intentional. By testing overflow, underflow, division by zero, and rounding boundaries, you can catch bugs that would otherwise surface only under stress or adversarial conditions.

The most effective arithmetic test suites are small, explicit, and boundary-driven. They verify exact outputs, distinguish between validation failures and arithmetic failures, and document the protocol’s rounding rules. That discipline pays off in safer accounting and more predictable contract behavior.

Learn more with useful resources