Skip to content

Fixed Point Numbers

Solidity has no floating-point type, so fractional values are stored as integers plus an agreed scale.

This document summarizes common fixed-point implementation styles.

You write the fixed-point formula directly in Solidity.

It uses raw expressions such as (x * y) / 1e18 or (x * 1e18) / y.

None; this is plain Solidity.

// SCALE = 1e18
uint256 priceWad = 2e18; // 2.0
uint256 amountWad = 3e18; // 3.0
uint256 valueWad = amountWad * priceWad / 1e18; // 6e18 = 6.0
  • Intermediate overflow: uint256 z = x * y / 1e18; can fail if x * y overflows before division.
  • Scale mismatch: usdcAmount * priceWad / 1e18 is wrong if usdcAmount is still in token-native 1e6 scale.
  • Unit confusion: assets + priceWad compiles even though the quantities are incompatible.

Instead of repeating the scaling formula, you call helpers that assume a fixed decimal scale.

Example libraries: Solady FixedPointMathLib, Solmate FixedPointMathLib, and Aave WadRayMath.

These libraries wrap common operations such as wad multiplication, wad division, and sometimes ray multiplication and division, with scale restoration built in.

// SCALE = 1e18
uint256 amountWad = 3e18; // 3.0
uint256 priceWad = 2e18; // 2.0
uint256 valueWad = FixedPointMathLib.mulWad(amountWad, priceWad); // 6e18 = 6.0
  • Unit correctness is still manual: mulWad(assetsWad, feeWad) only makes sense if both operands belong in that formula.
  • Helper names can create false confidence: divWad(usdcAmount, totalShares) is still wrong if usdcAmount was never normalized.
  • Scale lock-in: using one decimal helper everywhere can hide places where a different precision policy is better.

Instead of relying on the EVM’s native 256-bit multiply, you use primitives that compute floor(a * b / denominator) with 512-bit intermediate arithmetic.

Example libraries: Uniswap FullMath, OpenZeppelin Math.mulDiv.

These libraries provide mulDiv(a, b, denominator), which stays safe even when a * b would overflow uint256. They are not fixed-point libraries because they assume no scale, but they are a key building block for dangerous ratio paths.

// shares = assets * totalShares / totalAssets
uint256 assets = 50;
uint256 totalShares = 1000;
uint256 totalAssets = 200;
uint256 sharesOut = FullMath.mulDiv(assets, totalShares, totalAssets); // 250
  • Denominator zero: denominator = 0 reverts, so callers must guard against zero totalAssets or totalShares.
  • Result overflow: if the true result exceeds uint256, the call still reverts.
  • False sense of safety: overflow protection only covers the intermediate multiply, not scale correctness or unit mismatches.

These libraries define a fixed SCALE, store values as integers, and provide helpers that preserve that scale across operations.

They represent Solidity integers as scaled real numbers by agreeing on a denominator and restoring it after multiplication or division.

Fixed-point numbers store a scaled integer, if the real value is xx, the stored value usually is:

X=xSCALEX = x \cdot SCALE

Common rules:

  • Add/subtract: both values must use the same SCALE
  • Multiply: result = (a * b) / SCALE
  • Divide: result = (a * SCALE) / b

This pattern is the same whether the scale is decimal like 1e18 or binary like 2^64.

Fixed-point libraries mainly differ in the choice of SCALE and how they help manage it.

Some expose plain helpers, while others add stronger typed wrappers; PRBMath, for example, exposes typed decimal formats such as UD60x18.

Signed fixed-point values depend on the representation of the underlying stored integer, not on whether the scale is decimal or binary.

Common signed encodings include:

  • Two’s complement: the most significant bit participates in the sign encoding; this is the standard modern representation.
  • One’s complement: negative values are formed by inverting all bits of the positive value; it has both +0+0 and 0-0.
  • Sign-magnitude: the most significant bit is an explicit sign bit, and the remaining bits store the magnitude; it also has both +0+0 and 0-0.

For Unsigned storage there is no sign encoding, so negative values are not representable.

If an NN-bit stored integer

  • signed, that use two’s complement,
    • its integer range is 2N1-2^{N-1} to 2N112^{N-1}-1.
    • its fixed points range is [2N1scale, 2N11scale]\left[\frac{-2^{N-1}}{\text{scale}},\ \frac{2^{N-1}-1}{\text{scale}}\right]
  • unsinged
    • its integer range is 0 to 2N10 \text{ to } 2^N - 1
    • its fixed points range is 0 to 2N1scale0 \text{ to } \frac{2^N - 1}{scale}

Use a decimal scale of the form 10^n, such as 1e18 or 1e27, so the representation matches how humans think about decimals.

They implement fixed-point arithmetic in a decimal system that fits token decimals, percentages, rates, and other human-readable values.

Unlike wad-style utility libraries, they define a formal SCALE abstraction and often expose typed wrappers that bind values to that scale.

Example libraries: PRBMath decimal formats such as UD60x18 (unsigned) and SD59x18 (signed)

real_value=stored_value10n \text{real\_value} = \frac{\text{stored\_value}}{10^n}
  • real_value: the actual decimal number you want to represent, such as 6.5
  • stored_value: the scaled integer that is actually stored in memory, such as 65000
  • n: the number of decimal places fixed to the right of the decimal point
  • 10^n: the scale factor

Typical scales:

  • Wad: 10^18
  • Ray: 10^27
  • Typed decimal formats such as UD60x18, which use 18 decimal places
scale=10nstored_integer=round(real_value×10n)store stored_integer as binary\begin{aligned} \text{scale} = 10^n \\ \text{stored\_integer} = \text{round}(\text{real\_value} \times 10^n) \\ \text{store stored\_integer as binary} \end{aligned}

Example: Encode 6.5 in Decimal Fixed-Point (4.4):

6.5 × 10^4 = 6.5 × 10000 = 65000 → stored as binary 1111 1101 0110 1000

convert stored binary to integerreal_value=stored_integer÷10n \begin{aligned} \text{convert stored binary to integer} \\ \text{real\_value} = \text{stored\_integer} \div 10^n \end{aligned}

Example: Decode 65000 in Decimal Fixed-Point (4.4):

retrieve 1111 1101 0110 1000 → 65000 ÷ 10⁴ = 6.5

// UD60x18 uses 18 decimal places (60 integer bits, 18 fractional bits, scale = 10^18)
// ud() expects pre-scaled values, since we use UD60x18 we scale by e18
// Encoded:
// 3.0 stored as 3 * 10^18
// Full value: 3e18 (uint256 decimal) = 3000000000000000000 decimal
// Integer part: 3 (scaled by 10^18)
// Fractional part: 0
// Decoded:
// 3000000000000000000 / 10^18 = 3.0
UD60x18 amount = ud(3e18);
// Encoded:
// 2.0 stored as 2 * 10^18
// Full value: 2e18 (uint256 decimal) = 2000000000000000000 decimal
// Integer part: 2 (scaled by 10^18)
// Fractional part: 0
// Decoded:
// 2000000000000000000 / 10^18 = 2.0
UD60x18 price = ud(2e18);
// Encoded:
// 6.0 stored as 6 * 10^18 (result of 3.0 * 2.0 = 6.0)
// Full value: 6e18 (uint256 decimal) = 6000000000000000000 decimal
// Integer part: 6 (scaled by 10^18)
// Fractional part: 0
// Decoded:
// 6000000000000000000 / 10^18 = 6.0
UD60x18 value = amount.mul(price);

Pros:

  • It matches the human decimal system, so values like 1.5, 2.75%, and token decimals are easier to reason about.
  • It is easier to debug because logs, tests, and documentation usually already use base-10 numbers.
  • It fits naturally with ERC-20 decimals, oracle decimals, and common DeFi conventions such as wad and ray.

Cons:

  • Decimal scaling does not align naturally with binary integer hardware, so scale restoration uses constants like 1e18 instead of power-of-two shifts.
  • Operations are more expensive than binary fixed-point arithmetic.
  • Scale lock-in: forcing all protocol math into one decimal scale can hide cases where a different precision level is needed.
  • False confidence from readable helpers: divWad(usdcAmount, totalShares) is still wrong if usdcAmount was not normalized first.
  • Unit correctness is still manual unless stronger typing is added on top.

Use a binary scale of the form 2^n, so values live in a system aligned with binary integers rather than decimal notation.

Example libraries: ABDKMath64x64

real_value=stored_value2n\text{real\_value} = \frac{\text{stored\_value}}{2^n}

Where:

  • real_value: the actual number you want to represent, such as 6.5
  • stored_value: the scaled integer stored in memory
  • n: the number of fractional bits
  • 2^n: the binary scale factor

Typical format is Qm.n and UQm.n, where m is the number of integer bits, n is the number of fractional bits, and UQ means unsigned while Q usually means signed.

When you are selecting which Q format you exchange range for precision

Bits are not decimal places

  • Min value: 2m1-2^{m-1}
  • Max value: 2m12n2^{m-1} - 2^{-n}
  • Resolution (precision): 2n2^{-n}

Below you can see examples of range and precision from some Q formats

FormatTotal BitsMin ValueMax ValueResolution (decimal)
Q16.016−32768327671
Q12.416−20482047.93750.0625
Q8.816−128≈ 127.996≈ 0.003906
Q4.1216−8≈ 7.9998≈ 0.000244
Q1.1516−1≈ 0.99997≈ 0.0000305
scale=2nstored_value=round(real_value×2n)store stored_value as binary\begin{aligned} \text{scale} = 2^n \\ \text{stored\_value} = \text{round}(\text{real\_value} \times 2^n) \\ \text{store stored\_value as binary} \end{aligned}

store: multiply by 2^n = << n

Encoding Steps

  • Multiply decimal by 2n2^{n} (or left-shift integer part << 64).
  • Round to the nearest uintX/intX (depends on library).
  • Store as an unsigned/signed X-bit value.

Example: Encode 6.5 in binary fixed-point Q4.4:

6.5 × 2^4 = 6.5 × 16 = 104 → stored as binary 0110 1000

read stored bits as stored_valuereal_value=stored_value2n\begin{aligned} \text{read stored bits as stored\_value} \\ \text{real\_value} = \frac{\text{stored\_value}}{2^n} \end{aligned}

retrieve: divide by 2^n = >> n

Decoding Steps

  • Divide stored uintX/intX by 2n2^{n}
  • The result is the exact decimal (or approximation if non-exact).

Example: Decode 0110 1000 in Q4.4:

retrieve 0110 1000 → 104 ÷ 2^4 = 104 ÷ 16 = 6.5

Example 6.5 in Q4.4:

Q4.4

  • Range: −8 to 7.9375
  • Precision: 0.0625

| ◄──────── Integer ────────►| ◄─────── Fractional ──────► |

Bitb7b6b5b4b3b2b1b0
Weight-84210.50.250.1250.0625
Stored01101000

The value is: 4 + 2 + 0.5 = 6.5

Example -6.5 in Q4.4:

| ◄──────── Integer ────────►| ◄─────── Fractional ──────► |

Bitb7b6b5b4b3b2b1b0
Weight-84210.50.250.1250.0625
Stored10011000

The value is: -8 + 1 + 0.5 = -6.5

// ABDKMath64x64 uses signed 64.64 binary fixed-point
// Encoded:
// 4.0 stored as 4 << 64 or 4*2^64
// Full Hex: 0x00000000000000040000000000000000 (int128 hex) = 73786976294838206464 decimal
// High 64 bits: 0x0000000000000004 (integer part)
// Low 64 bits: 0x0000000000000000 (zero fraction)
// Decoded:
// 73786976294838206464 / 2^64 = 4
int128 four = ABDKMath64x64.fromUInt(4);
// Encoded:
// 2.5 stored as 2.5 << 64 or 2.5*2^64
// Full Hex: 0x00000000000000028000000000000000 (int128 hex) = 46116860184273879040 decimal
// High 64 bits: 0x0000000000000002 (integer part = 2)
// Low 64 bits: 0x8000000000000000 (fraction = 0.5 = 2^{-1})
// Decoded:
// 46116860184273879040 / 2^64 = 2.5
int128 twoPointFive = ABDKMath64x64.divu(5, 2); // 2.5
// Encoded:
// 10.0 stored as 10 << 64 or 10*2^64 (result of 4.0 * 2.5 = 10.0)
// Full Hex: 0x000000000000000A0000000000000000 (int128 hex) = 184467440737095516160 decimal
// High 64 bits: 0x000000000000000A (integer part = 10)
// Low 64 bits: 0x0000000000000000 (zero fraction)
// Decoded:
// 184467440737095516160 / 2^64 = 10
int128 result = ABDKMath64x64.mul(four, twoPointFive); // 10.0

Pros:

  • The scale is a power of two, so some scale-handling steps map naturally to bit shifts instead of decimal constants.
  • It matches power-of-two scaling and standard binary fixed-point formats such as Qm.n
  • Libraries in this family often provide richer math functionality, including exponential and other advanced functions.

Cons:

  • Binary formats are less intuitive for humans working with token decimals, fee percentages, and decimal-oriented protocol specs.
  • They usually require extra conversion at decimal-binary boundaries.
  • Decimal-binary conversion errors: a token amount can be converted into binary fixed-point and later treated as if it were still decimal-scaled.
  • Lower economic readability: binary values are harder to inspect mentally than decimal ones.
  • Mixed-domain scale crossings: token decimals, oracle decimals, binary internals, and decimal outputs can drift badly after one bad conversion.