
Building a Fixed-Point Decimal Math Library in Solidity
Why fixed-point math matters in Solidity
Most on-chain financial logic needs fractions:
- lending protocols compute interest in basis points
- DEXs calculate slippage and swap fees
- staking systems distribute rewards proportionally
- vesting contracts release tokens over time
Because Solidity only supports integers, developers typically choose a scale factor such as 1e18 or 1e4 and store values as integers. For example, 1.5 can be represented as 1_500_000_000_000_000_000 when using 18 decimals.
This approach works well, but it introduces recurring problems:
- repeated scaling and unscaling logic
- accidental precision loss from integer division
- inconsistent rounding behavior
- duplicated arithmetic code across contracts
A reusable library centralizes these rules and makes your contracts easier to audit.
Choosing a decimal scale
Before writing code, decide what scale your library will use. The two most common options are:
| Scale | Typical use case | Example representation |
|---|---|---|
1e18 | DeFi, token economics, general-purpose decimals | 1.25 → 1_250000000000000000 |
1e4 | Basis points, fees, percentages | 2.5% → 250 |
For this tutorial, we will use 1e18 because it is the most flexible and familiar in Solidity ecosystems. This is often called WAD precision.
Using a single scale has advantages:
- consistent arithmetic across the codebase
- compatibility with common ERC-20 decimal conventions
- enough precision for most financial calculations
Library design goals
A good fixed-point library should be:
- predictable: every function should define how rounding works
- safe: avoid overflow where possible and fail clearly on invalid input
- small: keep the API focused on common operations
- composable: easy to use from other contracts
We will implement the following functions:
fromUint— convert an integer to scaled decimal formtoUint— convert scaled decimal back to integer with rounding downmulWad— multiply two scaled decimalsdivWad— divide one scaled decimal by anothermulWadUpanddivWadUp— rounding-up variantspercentOf— compute a percentage using basis points
Implementing the library
Below is a complete example of a fixed-point decimal math library.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library DecimalMath {
uint256 internal constant WAD = 1e18;
uint256 internal constant HALF_WAD = WAD / 2;
error DivisionByZero();
error Overflow();
/// @notice Converts an integer to WAD-scaled decimal.
function fromUint(uint256 x) internal pure returns (uint256) {
unchecked {
if (x > type(uint256).max / WAD) revert Overflow();
return x * WAD;
}
}
/// @notice Converts a WAD-scaled decimal to an integer, rounding down.
function toUint(uint256 x) internal pure returns (uint256) {
return x / WAD;
}
/// @notice Multiplies two WAD-scaled decimals, rounding down.
function mulWad(uint256 x, uint256 y) internal pure returns (uint256) {
unchecked {
if (x == 0 || y == 0) return 0;
if (x > type(uint256).max / y) revert Overflow();
return (x * y) / WAD;
}
}
/// @notice Multiplies two WAD-scaled decimals, rounding up.
function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
unchecked {
if (x == 0 || y == 0) return 0;
if (x > type(uint256).max / y) revert Overflow();
uint256 product = x * y;
return (product + WAD - 1) / WAD;
}
}
/// @notice Divides one WAD-scaled decimal by another, rounding down.
function divWad(uint256 x, uint256 y) internal pure returns (uint256) {
if (y == 0) revert DivisionByZero();
unchecked {
if (x > type(uint256).max / WAD) revert Overflow();
return (x * WAD) / y;
}
}
/// @notice Divides one WAD-scaled decimal by another, rounding up.
function divWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
if (y == 0) revert DivisionByZero();
unchecked {
if (x > type(uint256).max / WAD) revert Overflow();
uint256 numerator = x * WAD;
return (numerator + y - 1) / y;
}
}
/// @notice Calculates a percentage using basis points.
/// @dev 10_000 basis points = 100%.
function percentOf(uint256 amount, uint256 bps) internal pure returns (uint256) {
unchecked {
if (amount > type(uint256).max / bps && bps != 0) revert Overflow();
return (amount * bps) / 10_000;
}
}
}How the library works
1. Scaling integers into decimals
fromUint(5) returns 5e18, which represents 5.0 in WAD format. This is useful when you want to combine whole numbers with decimal math.
2. Converting back to integers
toUint(1.9e18) returns 1. This is a floor conversion, which is usually the safest default in financial systems because it avoids overpaying or over-distributing.
3. Multiplying decimals
If x = 1.5e18 and y = 2e18, then:
x * y = 3e36- dividing by
1e18restores the scale - result =
3e18, which represents3.0
4. Dividing decimals
If x = 3e18 and y = 2e18, then:
x * 1e18 / y = 1.5e18- result =
1.5
This pattern preserves precision before division.
Using the library in a contract
Here is a simple example of a fee calculator that uses the library to compute a protocol fee and a net payout.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./DecimalMath.sol";
contract FeeVault {
using DecimalMath for uint256;
uint256 public constant FEE_RATE = 25e15; // 2.5% in WAD
function quoteFee(uint256 grossAmount) external pure returns (uint256 fee, uint256 net) {
fee = grossAmount.mulWad(FEE_RATE);
net = grossAmount - fee;
}
function quoteFeeRoundedUp(uint256 grossAmount) external pure returns (uint256 fee, uint256 net) {
fee = grossAmount.mulWadUp(FEE_RATE);
net = grossAmount - fee;
}
}In this example, FEE_RATE is stored as a WAD-scaled decimal:
2.5% = 0.0250.025 * 1e18 = 25e15
This makes the fee logic readable and easy to audit.
Rounding strategy: down vs up
Rounding is one of the most important design decisions in a decimal library. The wrong choice can create systematic underpayment or overpayment.
| Function | Behavior | Best for |
|---|---|---|
mulWad | rounds down | payouts, conservative estimates, user balances |
mulWadUp | rounds up | fee collection, minimum required amounts |
divWad | rounds down | quoting, conservative division |
divWadUp | rounds up | debt calculations, minimum thresholds |
Practical guidance
- Use round down when the contract should never exceed a limit.
- Use round up when the contract must not undercharge or undercollateralize.
- Be consistent within a single economic model.
For example, if a protocol charges fees, rounding up is often preferable because it avoids leaving tiny unpaid dust amounts. If a protocol distributes rewards, rounding down is usually safer because it avoids over-distribution.
Basis points for percentages
Percentages are often easier to express in basis points than in WAD decimals. One basis point is 0.01%, so:
100 bps = 1%250 bps = 2.5%10_000 bps = 100%
The percentOf helper is useful for fee schedules and discount logic:
uint256 fee = DecimalMath.percentOf(1_000_000 ether, 30); // 0.3%This returns 3_000 ether? No — because 30 bps is 0.30%, not 30%. That distinction is exactly why basis points are useful: they reduce ambiguity.
If you need percentages with more precision than basis points, store them as WAD values instead.
Common pitfalls to avoid
1. Mixing scales
Do not multiply a WAD value by a raw integer without converting it first.
Bad:
uint256 result = price.mulWad(quantity);This is only correct if quantity is already WAD-scaled. If quantity is a plain integer count, convert it first with fromUint.
2. Dividing too early
Integer division truncates. If you divide before multiplying, you may lose precision permanently.
Bad:
uint256 fee = amount / 100 * 3;Better:
uint256 fee = amount.mulWad(3e16); // 3%3. Ignoring overflow checks
Even though Solidity 0.8+ reverts on overflow, intermediate multiplication can still be dangerous when you intentionally use unchecked for gas savings. If you do, add explicit bounds checks as shown in the library.
4. Using inconsistent rounding
If one function rounds down and another rounds up without documentation, users can exploit the difference or misunderstand expected outputs.
Extending the library safely
Once the core library is stable, you can add more helpers. Useful extensions include:
sqrtWadfor volatility or pricing formulaspowWadfor compounding interestavgWadfor moving averagesminandmaxfor threshold logic
However, each new function increases audit surface area. Add only what your protocol actually needs.
A good rule is to keep the library focused on arithmetic primitives and let application contracts handle business logic.
Testing recommendations
Test fixed-point math thoroughly because small rounding errors can become large financial discrepancies over time.
Unit tests to include
- zero values
- exact multiples of
WAD - values that produce fractional remainders
- rounding-up and rounding-down boundaries
- overflow and division-by-zero reverts
Example test cases
mulWad(1e18, 2e18) == 2e18mulWad(15e17, 2e18) == 3e18divWad(3e18, 2e18) == 15e17divWadUp(1e18, 3e18) == 333333333333333334
That last case is especially important because it verifies the rounding-up behavior at a non-terminating decimal.
When to use a library instead of inline math
A library is the right choice when:
- the same decimal logic appears in multiple contracts
- the protocol has financial or accounting semantics
- rounding behavior must be explicit and auditable
- you want to reduce code duplication
Inline math may be acceptable for one-off calculations, but it becomes harder to maintain as the project grows. A library improves consistency and makes reviews easier.
Summary
A fixed-point decimal math library is one of the most practical utilities you can build in Solidity. It gives you a deterministic way to handle fractional values, reduces repeated arithmetic code, and makes rounding behavior explicit.
The key ideas are simple:
- choose a scale such as
1e18 - store decimals as integers
- multiply before dividing to preserve precision
- define rounding rules clearly
- test edge cases aggressively
With these patterns in place, your contracts can perform financial calculations that are both readable and reliable.
