One multiplication replaces the entire loop.
O(1) gas instead of O(n). Works for any amount!
Every curve has its own shortcut (the "integral"). Linear: trapezoid. Exponential: (a/k)·(ekS2−ekS1). You don't derive them — just plug in.
Part 4: Solidity Can't Do Decimals
Quick: what's the result of these Solidity operations?
uint256 a = 7 / 2; // = ?
uint256 b = 1 / 3; // = ?
uint256 c = 5 / 10; // = ?
a = 3, b = 0, c = 0 — Solidity throws away the decimal part!
7/2 should be 3.5 → Solidity gives you 3 (truncates toward zero)
1/3 should be 0.333... → Solidity gives you 0
No float, no double, no decimal type exists in Solidity
The Trick: Pretend Integers Are Decimals
What if we agree: "1e18 means 1.0"?
Real Number
Fixed-Point (×1018)
Solidity Literal
1.0
1,000,000,000,000,000,000
1e18
0.5
500,000,000,000,000,000
5e17
0.08
80,000,000,000,000,000
8e16
2.5
2,500,000,000,000,000,000
25e17
3.14159
3,141,590,000,000,000,000
3141590000000000000
This is called fixed-point arithmetic. We multiply every number by 1018 before storing it. The 18 zeros are the "decimal places".
Fixed-Point Arithmetic: The Rules
Addition & Subtraction
Just add/subtract normally:
// 1.5 + 0.3 = 1.8
uint256 a = 15e17; // 1.5
uint256 b = 3e17; // 0.3
uint256 c = a + b; // 18e17 = 1.8 ✓
Addition & subtraction: no adjustment needed.
Multiplication
Divide by 1e18 after multiplying:
// 1.5 × 0.3 = 0.45
uint256 a = 15e17; // 1.5
uint256 b = 3e17; // 0.3
uint256 c = a * b / 1e18;
// = 45e34 / 1e18 = 45e16 = 0.45 ✓
Multiplication: must divide by 1e18 to fix the double scaling.
Fixed-Point: Division Trap
// WRONG: 3.0 / 2.0 = ???
uint256 a = 3e18; // 3.0
uint256 b = 2e18; // 2.0
uint256 wrong = a / b; // = 1 (lost all decimals!)
// RIGHT: multiply FIRST, then divide
uint256 right = a * 1e18 / b; // = 3e36 / 2e18 = 15e17 = 1.5 ✓
Rule: for fixed-point division, multiply the numerator by 1e18 before dividing.
Operation
Formula
Why
a + b
a + b
Scales match
a − b
a - b
Scales match
a × b
a * b / 1e18
Undo double-scaling
a ÷ b
a * 1e18 / b
Pre-scale numerator
Danger: Overflow
// uint256 max = 2^256 - 1 ≈ 1.16 × 10^77
// Multiplying two large fixed-point numbers:
uint256 a = 1000e18; // 1000.0
uint256 b = 1000e18; // 1000.0
// a * b = 1e42 → still fits in uint256 ✓
// But three multiplications chained:
// a * b * c before dividing = 1e63 → might overflow!
// Safe approach: divide between multiplications
uint256 ab = a * b / 1e18; // 1e42 / 1e18 = 1e24 ✓
uint256 result = ab * c / 1e18; // stays manageable
Rule of thumb: always divide by 1e18 between each multiplication, not all at the end.
PRBMath handles this for you internally — that's a big reason to use it.
Part 5: PRBMath — The Library
PRBMath gives you exp, ln, sqrt, pow, mul, div — all in fixed-point.
Without PRBMath
// How do you compute e^(0.08)?
// You can't. Solidity has no exp().
// You'd need a Taylor series:
// e^x ≈ 1 + x + x²/2 + x³/6 + ...
// Each term needs careful fixed-point
// mul and div with overflow checks.
// 20+ lines of tricky math code.
With PRBMath
import {UD60x18, ud, exp}
from "prb-math/UD60x18.sol";
UD60x18 result = exp(ud(0.08e18));
// result ≈ 1.0833e18
// Done. One line.
PRBMath wraps all the overflow-safe, precision-maximizing fixed-point math behind simple function calls.
It's still a uint256 under the hood — just wrapped in a custom type
The 18 digits = same precision as ERC-20 tokens (ETH has 18 decimals)
60 integer bits = up to ~1.15 × 1018 before the decimal point
Unsigned = no negative numbers (there's SD59x18 for signed)
// UD60x18 is just a wrapper around uint256
type UD60x18 is uint256;
// The "ud" function wraps a raw uint256 into this type
function ud(uint256 x) pure returns (UD60x18) {
return UD60x18.wrap(x);
}
// The "unwrap" function gets the raw uint256 back
function unwrap(UD60x18 x) pure returns (uint256) {
return UD60x18.unwrap(x);
}
Think of UD60x18 as a "labeled box" that says "I'm a fixed-point number — use special math on me".
The Three-Step Pattern
Every PRBMath computation follows the same workflow:
1WRAP raw numbers into UD60x18
→
2MATH using library functions
→
3UNWRAP back to uint256
import {UD60x18, ud, unwrap, exp, mul} from "prb-math/UD60x18.sol";
// STEP 1: WRAP — convert raw numbers to fixed-point
UD60x18 base = ud(0.5e18); // 0.5 in fixed-point
UD60x18 rate = ud(0.08e18); // 0.08 in fixed-point
UD60x18 supply = ud(10e18); // 10 in fixed-point
// STEP 2: MATH — compute using library functions
UD60x18 exponent = mul(rate, supply); // 0.08 × 10 = 0.8
UD60x18 growth = exp(exponent); // e^0.8 ≈ 2.2255
UD60x18 price = mul(base, growth); // 0.5 × 2.2255 ≈ 1.1128
// STEP 3: UNWRAP — get raw uint256 back
uint256 priceWei = unwrap(price); // ≈ 1_112_749_685_157_800_000
You never do * or / directly on UD60x18. Always use mul(), div(), exp(), ln().
We'll focus on Common.sol and ud60x18/Math.sol — that's where all the algorithms live.
The UNIT Constant
/// @dev The unit number, which gives the decimal precision of UD60x18.
uint256 constant uUNIT = 1e18;
UD60x18 constant UNIT = UD60x18.wrap(1e18);
Every function in PRBMath revolves around this single idea:
real_value = stored_uint256 / 1018
Real Value
Stored uint256
1.0
1_000_000_000_000_000_000
3.14
3_140_000_000_000_000_000
0.001
1_000_000_000_000_000
Why 1018? Matches ETH's wei precision. 1 ETH = 1018 wei. No conversion needed when working with token amounts.
Part 7: mul() — Phantom Overflow
The simplest operation reveals the deepest trick.
The Problem with Naive Multiply
// ❌ WRONG — overflows!
function naiveMul(UD60x18 x, UD60x18 y) pure returns (UD60x18) {
return UD60x18.wrap(
UD60x18.unwrap(x) * UD60x18.unwrap(y) / 1e18
);
}
// x = 2.0 → stored as 2e18
// y = 3.0 → stored as 3e18
// x * y = 2e18 * 3e18 = 6e36 ← PHANTOM OVERFLOW!
// 6e36 / 1e18 = 6e18 = 6.0 ← correct answer…
// IF it doesn't overflow first
Phantom overflow: the intermediate x * y value exceeds uint256 even though the final result fits. uint256.max ≈ 1.16e77, but two large UD60x18 values multiplied can reach ~1.34e77.
PRBMath's mul() — Actual Source
/// @notice Multiplies two UD60x18 numbers together,
/// returning a new UD60x18 number.
function mul(UD60x18 x, UD60x18 y) pure returns (UD60x18 result) {
result = wrap(mulDiv18(unwrap(x), unwrap(y)));
}
Just 1 line! The real work is in mulDiv18.
mulDiv18(a, b) computes (a × b) / 1e18 without intermediate overflow.
It's a specialized version of the general mulDiv(a, b, denominator).
Part 8: div() — Scale First
Division has the opposite problem of multiplication.
The Problem with Naive Division
// ❌ WRONG — loses ALL precision!
function naiveDiv(UD60x18 x, UD60x18 y) pure returns (UD60x18) {
return UD60x18.wrap(
UD60x18.unwrap(x) / UD60x18.unwrap(y)
);
}
// x = 1.0 → stored as 1e18
// y = 3.0 → stored as 3e18
// x / y = 1e18 / 3e18 = 0 ← integer division truncates!
// Expected: 0.333...e18
Precision loss: dividing two same-scale numbers cancels out the scale factor, giving you an unscaled integer (often 0).
PRBMath's div() — Scale First
/// @notice Divides two UD60x18 numbers, returning a UD60x18 number.
function div(UD60x18 x, UD60x18 y) pure returns (UD60x18 result) {
result = wrap(mulDiv(unwrap(x), uUNIT, unwrap(y)));
}
Trick: compute (x × 1e18) / y instead of x / y.
Step by step
1 x = 1e18 (represents 1.0)
2 Multiply by 1e18 → 1e36
3 Divide by 3e18 → 333...e15
4 Result ≈ 0.333e18 ✓
By scaling up before dividing, we preserve 18 decimals of precision. And mulDiv ensures the intermediate x × 1e18 doesn't overflow.
Part 9: mulDiv() — The 512-bit Heart
The most important function in PRBMath.
~80 lines of Yul assembly that make everything else possible.
mulDiv — What It Computes
mulDiv(x, y, d) = ⌊(x × y) / d⌋
…without intermediate overflow, even when x × y exceeds uint256!
Key insight: use a 512-bit intermediate. The EVM is 256-bit, so PRBMath simulates 512-bit math using two uint256 variables: prod0 (low bits) and prod1 (high bits).
Based on Remco Bloemen's algorithm & "Hacker's Delight" by Henry S. Warren.
mulDiv — Step 1: 512-bit Product
// Compute the 512-bit product [prod1 prod0]
uint256 prod0; // low 256 bits of x * y
uint256 prod1; // high 256 bits of x * y
assembly {
let mm := mulmod(x, y, not(0)) // x*y mod 2^256
prod0 := mul(x, y) // low bits (wraps)
prod1 := sub(sub(mm, prod0), lt(mm, prod0))
}
1mul(x,y) gives low 256 bits (EVM wraps)
2mulmod(x,y,2²⁵⁶-1) gives x*y mod (2²⁵⁶-1)
3 Subtract to get prod1 (high bits)
Why mulmod? The EVM MULMOD opcode internally uses a 512-bit intermediate — PRBMath exploits this to extract the high bits!
mulDiv — Step 2: Fast Path
// Handle cases where product fits in 256 bits
if (prod1 == 0) {
return prod0 / denominator;
}
If prod1 == 0, the product fits in 256 bits. Just do a normal division. This is the common case for small-to-medium values — almost free! ~40 gas
The expensive path (below) only runs when values are large enough to actually overflow 256 bits.
mulDiv — Step 3: 512-bit Division
When prod1 > 0, things get interesting:
// Make sure the result is less than 2^256
require(denominator > prod1);
// Subtract 256 bit number from 512 bit number
assembly {
// Compute remainder using mulmod
let remainder := mulmod(x, y, denominator)
// Subtract remainder from 512-bit product
prod1 := sub(prod1, gt(remainder, prod0))
prod0 := sub(prod0, remainder)
}
After subtracting the remainder, [prod1:prod0] is now evenly divisible by denominator. The problem reduces to dividing a 512-bit number by a 256-bit divisor.
mulDiv — Step 4: Factor Out Powers of 2
// Factor powers of two out of denominator
// Compute largest power of two divisor of denominator.
uint256 twos = denominator & (~denominator + 1);
assembly {
// Divide denominator by twos
denominator := div(denominator, twos)
// Divide [prod1:prod0] by twos
prod0 := div(prod0, twos)
// Flip twos such that it is 2^256 / twos.
// If twos is zero, then it becomes one.
twos := add(div(sub(0, twos), twos), 1)
}
// Shift bits from prod1 into prod0
prod0 |= prod1 * twos;
twos = d & (-d) isolates the lowest set bit — a classic Hacker's Delight trick. This factors out all powers of 2 from the denominator, simplifying the division.
mulDiv — Step 5: Modular Inverse
// Compute the modular inverse of denominator mod 2^256
// using Newton-Raphson iteration.
// Start with a seed that is correct for four bits.
uint256 inverse = (3 * denominator) ^ 2;
// Use Newton-Raphson to double precision each step
inverse *= 2 - denominator * inverse; // 8 bits
inverse *= 2 - denominator * inverse; // 16 bits
inverse *= 2 - denominator * inverse; // 32 bits
inverse *= 2 - denominator * inverse; // 64 bits
inverse *= 2 - denominator * inverse; // 128 bits
inverse *= 2 - denominator * inverse; // 256 bits
// Final result
result = prod0 * inverse;
Division by d becomes multiplication by d's modular inverse. 6 multiplications instead of an expensive division. Each iteration doubles the precision.
mulDiv — Complete Picture
1512-bit multiply
Use MULMOD to get high bits
2Fast path check
prod1 == 0 → normal div
3Subtract remainder
Make evenly divisible
4Factor out 2's
d & (-d) trick
5Modular inverse
Newton-Raphson × 6 iter
6Multiply
prod0 × inverse = result
Gas cost: ~140 gas for the full 512-bit path, ~40 gas for the fast path.
For comparison: a single SSTORE is 20,000 gas. mulDiv is cheap.
Part 10: exp() — Computing ex
The hardest function. No Taylor series — too expensive.
exp() Strategy: Change of Base
PRBMath does not compute ex directly. Instead:
ex = 2x × log₂(e)
❌ Taylor series
ex = 1 + x + x²/2! + x³/3! + ...
Needs ~20 terms → 20 mulDivs → ~3000 gas
✓ Base-2 decomposition
Convert to exp2() which uses bit tricks
Much fewer operations → ~1600 gas
function exp(UD60x18 x) pure returns (UD60x18 result) {
// x_192x64 = x * log2(e), scaled to 192.64 format
// then call exp2()
result = wrap(exp2(mulDiv18(unwrap(x), uLOG2_E)));
}
exp2() — The Bit Decomposition Trick
To compute 2y, split y into integer + fraction:
25.75 = 25 × 20.75
Integer part: 25
// Just a bit shift!
result = 1 << integerPart;
// 2^5 = 32 = 1 << 5
// Cost: 3 gas (SHL opcode)
Fractional part: 20.75
This is the hard part. PRBMath uses a product of precomputed factors.
Key insight: any binary fraction is a sum of negative powers of 2. So 2frac = product of precomputed 21/2ⁿ values for each set bit.
exp2() — Actual Code (Simplified)
// The fractional part in 192.64-bit fixed-point
uint256 x = xValue; // the fractional bits
// For each bit position, multiply by precomputed 2^(1/2^n)
if (x & 0x8000000000000000 > 0) // bit 63: 2^(1/2)
result = (result * 0x16A09E667F3BCC909) >> 64;
if (x & 0x4000000000000000 > 0) // bit 62: 2^(1/4)
result = (result * 0x1306FE0A31B7152DF) >> 64;
if (x & 0x2000000000000000 > 0) // bit 61: 2^(1/8)
result = (result * 0x1172B83C7D517ADCE) >> 64;
if (x & 0x1000000000000000 > 0) // bit 60: 2^(1/16)
result = (result * 0x10B5586CF9890F62A) >> 64;
// ... continues for all 64 bits ...
if (x & 0x2 > 0) // bit 1: 2^(1/2^63)
result = (result * 0x10000000000000001) >> 64;
if (x & 0x1 > 0) // bit 0: 2^(1/2^64)
result = (result * 0x10000000000000001) >> 64;
64 if checks, each with 1 multiply + 1 shift. No loops, no branches — pure branchless math. ~1500 gas total
Part 11: ln() — Binary Logarithm
Same trick in reverse: ln(x) = log₂(x) / log₂(e)
ln() → log2() → Bit Extraction
function ln(UD60x18 x) pure returns (UD60x18 result) {
// ln(x) = log2(x) / log2(e)
// But we compute: log2(x) * (1/log2(e)) = log2(x) * ln(2)
unchecked {
result = wrap(mulDiv(log2(unwrap(x)), uLOG2_E_INV, uUNIT));
}
}
Just like exp() delegates to exp2(), ln() delegates to log2().
log2() is where the real algorithm lives. It finds the binary logarithm by extracting bits one at a time — from integer part down to fractional part.
log2() — Integer Part
// n = integer part of log2(x)
// Find the highest set bit using binary search
uint256 n;
if (xUint >= 2**128) { xUint >>= 128; n += 128; }
if (xUint >= 2**64) { xUint >>= 64; n += 64; }
if (xUint >= 2**32) { xUint >>= 32; n += 32; }
if (xUint >= 2**16) { xUint >>= 16; n += 16; }
if (xUint >= 2**8) { xUint >>= 8; n += 8; }
if (xUint >= 2**4) { xUint >>= 4; n += 4; }
if (xUint >= 2**2) { xUint >>= 2; n += 2; }
if (xUint >= 2**1) { n += 1; }
Binary search for the highest set bit — finds floor(log₂(x)) in exactly 8 comparisons. This is the same algorithm the EVM itself uses internally. ~80 gas
log2() — Fractional Part
// y = x / 2^n (normalize to range [1, 2))
// Now compute fractional bits by repeated squaring
int256 resultInt;
// ...after integer part calculation...
// Compute 64 fractional bits, one at a time
y = (y * y) >> 127; // square and check
if (y >= 2 * UNIT) { // if ≥ 2.0...
resultInt |= (1 << 63); // ...set bit 63
y >>= 1; // normalize back
}
y = (y * y) >> 127;
if (y >= 2 * UNIT) {
resultInt |= (1 << 62); // set bit 62
y >>= 1;
}
// ... repeat for all 64 bits ...
Repeated squaring: if y² ≥ 2, then the next log₂ bit is 1 (divide by 2 and continue). Otherwise it's 0. One bit per iteration, 64 iterations for 64-bit precision.
Part 12: sqrt() — Newton's Method
The oldest algorithm in PRBMath — over 3,000 years old.
sqrt() — The Babylonian Method
To find √x, repeatedly improve a guess: g' = (g + x/g) / 2
function sqrt(uint256 x) pure returns (uint256 result) {
if (x == 0) return 0;
// Initial guess: closest power of 2 to √x
// (uses the same binary search as log2)
result = 1;
uint256 xAux = x;
if (xAux >= 2**128) { xAux >>= 128; result <<= 64; }
if (xAux >= 2**64) { xAux >>= 64; result <<= 32; }
if (xAux >= 2**32) { xAux >>= 32; result <<= 16; }
if (xAux >= 2**16) { xAux >>= 16; result <<= 8; }
if (xAux >= 2**8) { xAux >>= 8; result <<= 4; }
if (xAux >= 2**4) { xAux >>= 4; result <<= 2; }
if (xAux >= 2**2) { result <<= 1; }
// Newton-Raphson: 7 iterations (doubles bits each time)
result = (result + x / result) >> 1; // ~8 bits
result = (result + x / result) >> 1; // ~16 bits
result = (result + x / result) >> 1; // ~32 bits
result = (result + x / result) >> 1; // ~64 bits
result = (result + x / result) >> 1; // ~128 bits
result = (result + x / result) >> 1; // ~256 bits
result = (result + x / result) >> 1; // exact
// Round down
if (result > x / result) result = x / result;
}
sqrt() for UD60x18 — Scale Adjustment
/// @notice Calculates the square root of x using the
/// Babylonian method.
function sqrt(UD60x18 x) pure returns (UD60x18 result) {
uint256 xUint = unwrap(x);
if (xUint > uMAX_UD60x18 / uUNIT) revert ...;
// Multiply by UNIT (1e18) for scale adjustment,
// then take integer sqrt
result = wrap(prbSqrt(xUint * uUNIT));
}
Input x is already scaled by 1e18. Multiply by another 1e18 before sqrt, so the result comes out correctly scaled.
Part 13: pow() & Gas Costs
Putting it all together.
pow() — Just exp(y × ln(x))
/// @notice Raises x to the power of y.
function pow(UD60x18 x, UD60x18 y)
pure returns (UD60x18 result)
{
uint256 xUint = unwrap(x);
uint256 yUint = unwrap(y);
if (xUint == 0) return y == ZERO ? UNIT : ZERO;
if (yUint == uUNIT) return x;
// x^y = exp(y * ln(x))
// = exp2(y * log2(x)) ← base-2 version
result = exp2(mul(y, log2(x)));
}
pow() is the most expensive function because it calls both log2 and exp2. But it's still just ~3000 gas — 15% of a single SSTORE.
Gas Cost Summary
Function
Algorithm
Gas (approx)
vs. Naive
mul()
mulDiv18
~100
safe from phantom overflow
div()
mulDiv (scale first)
~150
18-digit precision preserved
sqrt()
Newton × 7 iterations
~400
impossible naively
exp()
exp2 + bit decomp
~1600
Taylor: ~3000+
ln()
log2 + repeated squaring
~1200
Taylor: ~4000+
pow()
exp2(y × log2(x))
~2800
loop: O(n) gas
For reference: SSTORE = 20,000 gas, SLOAD = 2,100 gas, LOG = 375+ gas.
All PRBMath operations are cheaper than a single storage read.
Techniques You've Learned
Technique
Used In
512-bit via MULMOD
mulDiv
Modular inverse
mulDiv
Newton-Raphson
mulDiv, sqrt
d & (-d) bit trick
mulDiv
Technique
Used In
Binary search (MSB)
log2, sqrt
Repeated squaring
log2
Precomputed constants
exp2
Change of base
exp, ln, pow
These 8 techniques appear throughout DeFi math — Uniswap v3's TickMath, Balancer's LogExpMath, Solmate's FixedPointMathLib. Learn them once, read any library.