Why decimal math needs dedicated tests

A contract that stores 100 may mean 100 wei, 100 tokens, or 100.00 in a scaled representation. That ambiguity is the root of many bugs. Decimal math tests are useful whenever a contract:

  • calculates fees or interest
  • converts between token units and human-readable amounts
  • distributes rewards proportionally
  • prices assets using oracle data
  • applies percentage-based discounts or penalties

The main risk is not arithmetic overflow in modern Solidity versions; it is semantic error. A function may compile and even pass basic tests while still returning values that are off by 1 wei, rounded in the wrong direction, or scaled inconsistently across code paths.

Common failure modes

Failure modeExampleTypical symptom
Scale mismatchMixing 1e18 and 1e6 valuesResults off by 1,000,000x
Premature division(a / b) c instead of (a c) / bLarge precision loss
Wrong rounding directionTruncation where ceiling is expectedUnderpayment or failed thresholds
Double scalingApplying 1e18 twiceInflated outputs
Hidden unit assumptionsBasis points treated as percentagesFees 100x too large

A good test suite should make these mistakes visible immediately.


Choose one decimal convention and test it everywhere

Before writing tests, define the unit system used by the contract. The most common conventions are:

  • Wad: 18 decimals, typically 1e18
  • Ray: 27 decimals, often used in interest calculations
  • Basis points: 1/100 of a percent, where 10_000 = 100%
  • Token-native decimals: matching the ERC-20 decimals() value, such as 6 or 8

The contract should document the convention in code comments and function names. Tests should then assert that every public function respects the same convention.

For example, if a fee function expects basis points, the test should not pass 5 and assume it means 5%. It should explicitly pass 500 for 5%.

Example contract

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

contract FeeCalculator {
    uint256 public constant BPS_DENOMINATOR = 10_000;

    function fee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
        return (amount * feeBps) / BPS_DENOMINATOR;
    }

    function netAmount(uint256 amount, uint256 feeBps) external pure returns (uint256) {
        return amount - fee(amount, feeBps);
    }
}

This contract is simple, but it already creates a testing question: should fee(1, 1) return 0 or should the system round up? The answer depends on product requirements, and the test suite should encode that decision.


Test exact values, boundary values, and rounding edges

Decimal math tests should not only check a few happy-path examples. They should focus on values where integer division changes the result.

What to test

  1. Exact divisibility
  • amount = 10_000, feeBps = 250
  • Expected fee: 250
  1. Non-divisible values
  • amount = 1, feeBps = 1
  • Expected fee: 0 with truncation
  1. Large values
  • Near type(uint256).max when multiplication order matters
  1. Threshold values
  • Inputs that should produce 0 or 1 depending on rounding
  1. Repeated operations
  • Applying the same formula multiple times to detect drift

Example test cases

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

import "forge-std/Test.sol";

contract FeeCalculatorTest is Test {
    FeeCalculator calc;

    function setUp() public {
        calc = new FeeCalculator();
    }

    function testFeeExactDivision() public {
        uint256 result = calc.fee(10_000, 250);
        assertEq(result, 250);
    }

    function testFeeTruncatesDown() public {
        uint256 result = calc.fee(1, 1);
        assertEq(result, 0);
    }

    function testNetAmountWithSmallValue() public {
        uint256 result = calc.netAmount(1_000, 100);
        assertEq(result, 990);
    }
}

These tests are intentionally small. The point is to verify the arithmetic contract, not to simulate a full protocol.


Use fixed-point assertions, not just raw equality

Raw equality is useful when the expected value is deterministic and exact. But many decimal operations involve rounding, oracle inputs, or chained calculations where a small tolerance is acceptable.

A fixed-point assertion compares values after normalization to a common scale. This is especially helpful when testing code that converts between decimals.

Example: comparing 6-decimal and 18-decimal values

Suppose a contract receives USDC-like amounts with 6 decimals and converts them to 18-decimal internal units. A test should verify the conversion in both directions.

function toWad(uint256 amount6) internal pure returns (uint256) {
    return amount6 * 1e12;
}

function fromWad(uint256 amount18) internal pure returns (uint256) {
    return amount18 / 1e12;
}

A useful test pattern is:

  • convert input to internal scale
  • perform the operation
  • convert back
  • compare against the expected external representation

This catches off-by-factor errors that raw equality on one side might miss.

When to use tolerance

Use tolerance only when the business logic allows it. For example:

  • oracle-based pricing may tolerate a small deviation
  • interest accrual may allow a 1 wei difference due to rounding
  • token swaps may permit a bounded slippage range

A tolerance should be explicit and justified, not a way to hide imprecision.


Test rounding direction deliberately

Rounding is often more important than the arithmetic formula itself. Truncation, ceiling, and banker’s rounding each produce different outcomes.

Typical rounding strategies

StrategyBehaviorGood for
Truncate downDrops fractional remainderFees charged conservatively to users
Round upAny remainder increases resultMinimum payments, collateral requirements
Round to nearestChooses closest integerReporting and analytics
Custom thresholdBusiness-specific rounding ruleProtocol-specific economics

If your contract uses truncation, test that it always truncates. If it uses rounding up, test the edge case where the remainder is 1.

Example: testing a ceiling division helper

function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) {
    return a == 0 ? 0 : ((a - 1) / b) + 1;
}

Test cases should include:

  • ceilDiv(0, 7) == 0
  • ceilDiv(1, 7) == 1
  • ceilDiv(7, 7) == 1
  • ceilDiv(8, 7) == 2

These are small tests, but they prove the rounding contract precisely.


Verify scaling invariants across public functions

Decimal bugs often appear when one function uses a different scale than another. A strong test suite checks that scaling is consistent across the contract’s public API.

For example, if a vault stores shares in 1e18 precision and accepts deposits in token-native decimals, then:

  • deposit should convert input to shares consistently
  • withdraw should reverse the conversion
  • preview functions should match state-changing functions within the documented rounding rule

Invariant-style checks for scaling

You can test properties such as:

  • fromWad(toWad(x)) == x for values that fit the scale exactly
  • toWad(fromWad(y)) <= y if truncation is expected
  • previewDeposit(x) == deposit(x) for the same input when no state changes affect the result

These checks are especially useful for protocols with multiple entry points that should agree on the same math.

Example: round-trip test

function testRoundTripForExactValues() public {
    uint256 original = 123_456;
    uint256 wad = original * 1e12;
    uint256 back = wad / 1e12;

    assertEq(back, original);
}

This test only passes for values that are exactly representable in the target scale. That is a feature, not a limitation: it confirms the conversion is lossless where it should be.


Test percentage math with explicit units

Percentages are a frequent source of confusion because developers often mix 5, 5%, and 500 bps. Tests should make the unit explicit in both variable names and assertions.

Recommended naming

  • feeBps
  • rateRay
  • amountWad
  • price6
  • collateralRatioBps

Avoid generic names like rate or value unless the unit is obvious from context.

Example: percentage fee test

function testFivePercentFee() public {
    uint256 amount = 2_000;
    uint256 feeBps = 500; // 5%
    uint256 expectedFee = 100;

    assertEq(calc.fee(amount, feeBps), expectedFee);
}

This test is simple, but it prevents a common bug: passing 5 and expecting 5%, which would actually mean 0.05% in basis points.


Use property checks for arithmetic relationships

Even if you are not doing full fuzz testing, you can still write relationship-based tests that validate decimal math over a range of representative values.

Useful relationships

  • fee(amount, 0) == 0
  • fee(amount, BPS_DENOMINATOR) == amount
  • netAmount(amount, bps) + fee(amount, bps) == amount when truncation does not lose value
  • fee(a + b, r) >= fee(a, r) + fee(b, r) may fail under truncation, so only assert it if mathematically valid for your design

The last point matters: not every intuitive property is true under integer division. Tests should reflect the actual arithmetic, not idealized real-number behavior.

Example: monotonicity test

function testFeeIsMonotonic() public {
    uint256 fee1 = calc.fee(1_000, 100);
    uint256 fee2 = calc.fee(2_000, 100);

    assertTrue(fee2 >= fee1);
}

Monotonicity is a powerful sanity check. If a larger amount produces a smaller fee, something is wrong with the scaling or rounding.


Practical debugging workflow for precision bugs

When a decimal test fails, the fastest path to diagnosis is to inspect the intermediate units, not just the final result.

Debugging checklist

  1. Print or inspect every intermediate value
  2. Confirm the unit of each variable
  3. Check multiplication before division
  4. Verify denominator constants
  5. Compare against a hand-calculated example
  6. Test the smallest non-zero input
  7. Test a value that should divide evenly

If a function returns 0 unexpectedly, the issue is often premature division. If a function returns a value 100x too large, the issue is often a scale mismatch.

Example of a safer formula

Instead of:

uint256 result = (amount / denominator) * numerator;

prefer:

uint256 result = (amount * numerator) / denominator;

The second version usually preserves more precision, though it can increase overflow risk in older code patterns. In Solidity ^0.8.x, overflow reverts by default, so tests should also include large inputs to confirm the expression remains safe for expected ranges.


Summary of test patterns

PatternPurposeBest use case
Exact equalityConfirms deterministic mathFees, conversions, fixed ratios
Boundary testingExposes rounding edgesDivision, threshold logic
Round-trip checksValidates reversible scalingUnit conversion
Monotonicity checksEnsures outputs move in the right directionFees, prices, collateral ratios
Tolerance-based checksAccepts bounded precision errorOracle-driven calculations

A mature decimal math test suite usually combines all five.


Conclusion

Testing Solidity decimal math is about protecting economic correctness. Once you define a scaling convention, your tests should verify exact values, rounding direction, and unit consistency across every public function. The most effective tests focus on edge cases: tiny inputs, exact divisibility, and values that expose truncation or scaling mistakes.

If you treat decimal math as a first-class testing concern, you will catch bugs that are otherwise invisible in normal unit tests. That discipline pays off quickly in any contract that handles fees, prices, rewards, or interest.

Learn more with useful resources