
Testing Solidity Decimal Math with Fixed-Point Assertions
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 mode | Example | Typical symptom |
|---|---|---|
| Scale mismatch | Mixing 1e18 and 1e6 values | Results off by 1,000,000x |
| Premature division | (a / b) c instead of (a c) / b | Large precision loss |
| Wrong rounding direction | Truncation where ceiling is expected | Underpayment or failed thresholds |
| Double scaling | Applying 1e18 twice | Inflated outputs |
| Hidden unit assumptions | Basis points treated as percentages | Fees 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
- Exact divisibility
amount = 10_000,feeBps = 250- Expected fee:
250
- Non-divisible values
amount = 1,feeBps = 1- Expected fee:
0with truncation
- Large values
- Near
type(uint256).maxwhen multiplication order matters
- Threshold values
- Inputs that should produce
0or1depending on rounding
- 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
| Strategy | Behavior | Good for |
|---|---|---|
| Truncate down | Drops fractional remainder | Fees charged conservatively to users |
| Round up | Any remainder increases result | Minimum payments, collateral requirements |
| Round to nearest | Chooses closest integer | Reporting and analytics |
| Custom threshold | Business-specific rounding rule | Protocol-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) == 0ceilDiv(1, 7) == 1ceilDiv(7, 7) == 1ceilDiv(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)) == xfor values that fit the scale exactlytoWad(fromWad(y)) <= yif truncation is expectedpreviewDeposit(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
feeBpsrateRayamountWadprice6collateralRatioBps
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) == 0fee(amount, BPS_DENOMINATOR) == amountnetAmount(amount, bps) + fee(amount, bps) == amountwhen truncation does not lose valuefee(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
- Print or inspect every intermediate value
- Confirm the unit of each variable
- Check multiplication before division
- Verify denominator constants
- Compare against a hand-calculated example
- Test the smallest non-zero input
- 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
| Pattern | Purpose | Best use case |
|---|---|---|
| Exact equality | Confirms deterministic math | Fees, conversions, fixed ratios |
| Boundary testing | Exposes rounding edges | Division, threshold logic |
| Round-trip checks | Validates reversible scaling | Unit conversion |
| Monotonicity checks | Ensures outputs move in the right direction | Fees, prices, collateral ratios |
| Tolerance-based checks | Accepts bounded precision error | Oracle-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.
