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:

ScaleTypical use caseExample representation
1e18DeFi, token economics, general-purpose decimals1.251_250000000000000000
1e4Basis points, fees, percentages2.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 form
  • toUint — convert scaled decimal back to integer with rounding down
  • mulWad — multiply two scaled decimals
  • divWad — divide one scaled decimal by another
  • mulWadUp and divWadUp — rounding-up variants
  • percentOf — 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 1e18 restores the scale
  • result = 3e18, which represents 3.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.025
  • 0.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.

FunctionBehaviorBest for
mulWadrounds downpayouts, conservative estimates, user balances
mulWadUprounds upfee collection, minimum required amounts
divWadrounds downquoting, conservative division
divWadUprounds updebt 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:

  • sqrtWad for volatility or pricing formulas
  • powWad for compounding interest
  • avgWad for moving averages
  • min and max for 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) == 2e18
  • mulWad(15e17, 2e18) == 3e18
  • divWad(3e18, 2e18) == 15e17
  • divWadUp(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.

Learn more with useful resources