blockulator

BL127 is a number format and a math library for Solidity. A value is stored as value × 2^128 in an int256, so it keeps 38 correct digits at 1 where WAD keeps 18, and the same Yul source ships two ways: a library you inline, and a deployed contract you call. The two are fuzzed equal on every int256, reverts included.

Install

The import path is the same however you install it:

# Foundry, from git
forge install securedataresearch/blockulator
echo "@blockulator/contracts/=lib/blockulator/contracts/src/" >> remappings.txt

# npm, yarn or pnpm (Hardhat needs no remapping; Foundry reading node_modules needs this one)
npm install @blockulator/contracts
echo "@blockulator/contracts/=node_modules/@blockulator/contracts/" >> remappings.txt

# Soldeer
forge soldeer install blockulator~<version>
echo "@blockulator/contracts/=dependencies/blockulator-<version>/" >> remappings.txt
import {BL127Lib} from "@blockulator/contracts/bl127/BL127Lib.sol";

contract CompoundExample {
    function accrue(int256 principalWad, int256 rateWad, uint256 steps)
        external pure returns (int256 balanceWad)
    {
        int256 b = BL127Lib.fromFixed18(principalWad);
        int256 growth = BL127Lib.add(BL127Lib.ONE, BL127Lib.fromFixed18(rateWad));
        for (uint256 i = 0; i < steps; i++) {
            b = BL127Lib.mul(b, growth);
        }
        return BL127Lib.toFixed18(b);
    }
}

This example is compiled and tested in test/examples/ against the ladder's compound scenario. Compile with via_ir = true in foundry.toml: through the IR the inline ops come within a few percent of the deployed contract's gas, and on the legacy pipeline the heavy ops cost 5–14% more (profile.md).

Typed API

BL127Type.sol is opt-in: type BL127 is int256, with + - * /, unary - and < > <= >= == != bound to the library, and every other op attached (x.exp(), a.mulUp(b)). bl127FromWad and toWadDown / toWadUp convert unsigned WADs, bl127FromInt integers, and ONE and PI are typed constants. Each function only re-types its arguments, so the words and reverts are BL127Lib's, checked under fuzzing in test/typed/. Through the IR every op costs the same gas as the raw call; under the legacy pipeline the wrapper is one more internal call, 37 to 114 gas.

import {BL127, ONE, PI, bl127FromWad} from "bl127/BL127Type.sol";

BL127 b = bl127FromWad(1.5e18) * ONE.exp();  // 1.5e; * is BL127Lib.mul
BL127 r = (b - ONE).ln() / PI + -ONE;        // operators and attached ops
bool inside = r >= -ONE && r <= ONE;
uint256 paid = b.toWadDown();                // 4077422742688567853, floored

The format

The range is ±1.7 × 1038. With 128 bits after the point, BL127 keeps 38 digits at 1, 32 at 10−6 and 8 at 10−30. ONE is 2^128, every op takes and returns this one type, and fromFixed18 and toFixed18 convert to and from WAD.

Errors are Overflow(), DivisionByZero() and OutOfRange(). Nothing wraps.

The deployed contracts

Calling the deployed contract costs one staticcall, about 600 gas more than inlining on simple ops like add and mul. On the heavy ops (exp, exp2, exp10, sinh, cosh and both Lambert W branches) it is cheaper than inlining, by 50 to 620 gas (gas.md). It is also the way to use BL127 from an eth_call, where gas is free.

network
sepolia (chain id 11155111)
address
0x0DF738d125677550a80A46BaF254386C66f96E0d
verified
Sourcify, Blockscout. Etherscan has no Yul path.
Deployment record
deployer
0x4e59b44847b379578588920cA78FbF26c0B4956C (the CREATE2 proxy)
salt
"FP127 v9" = 0x4650313237207639000000000000000000000000000000000000000000000000
init code hash
0x3c663aca27b46991b0b81c2e1056ec9b270da4b37b5ae1944b5f40a79dbb5316
runtime
3728 bytes, keccak 0x2841130456eac80bb73cf869aded1898e3acaaaa2c36d385f076145a7695f6ee
compiler
Yul, solc 0.8.38+commit.1835d247, optimizer 200 runs, osaka
deployed
block 12017693, source commit 80d0656
record
BL127.md, bl127.json

BL127 v9 (Blockulator 3.0) is five contracts from one source, one idea each. The record above is the BL127 contract's; BL127.md has all five.

contractwhat it holdsaddressops
BL127the number0x0DF738d125677550a80A46BaF254386C66f96E0dadd sub mul div inv sqrt cbrt hypot gavg; neg abs sign; min max clamp; floor ceil round frac; avg dist zeroFloorSub lerp; factorial; mulDown mulUp divDown divUp; fromFixed18 toFixed18 toFixed18Down toFixed18Up
BL127Ethe elementary functions, on one exp/log kernel0xc16F4C45B78D90A4D4C030cABF4f2A34c1Fb0808exp exp2 exp10 expm1 ln log2 log10 log2Up log1p pow powu compound
BL127Hthe hyperbolic functions0xf2F99947aD1cC370bD5388a2997A5E4963a0B27Bsinh cosh tanh atanh
BL127Nthe normal distribution0xAA7Cd9635aE8630379636F533f23DBf2C193d296erf erfc normCdf
BL127SLambert W0xB9C2583a7Ad2251444DC7752658B6C1107C25036lambertW0 lambertWm1

Each object has its own interface (IBL127.sol, IBL127E.sol, IBL127H.sol, IBL127N.sol, IBL127S.sol) and ABI; ops.json names each op's object. The inline library holds all 53 ops.

They have no storage, no owner and no upgrade path: a new version gets a new salt and a new address, and v1 to v8 (one contract each) stay deployed at the addresses in the record. It is built for evmVersion osaka and needs the CLZ opcode (EIP-7939), so it runs on Ethereum, Sepolia and the other chains with CLZ, but not on zkSync Era, Avalanche C-Chain or Polygon zkEVM (the list). What changed in each version is in the changelog.

sqrt(2) from the command line:

cast call 0x0DF738d125677550a80A46BaF254386C66f96E0d "sqrt(int256)(int256)" \
  680564733841876926926749214863536422912 \
  --rpc-url https://ethereum-sepolia-rpc.publicnode.com
# 481231938336009023090067544955250113854
python3 -c "print(481231938336009023090067544955250113854 / 2**128)"
# 1.4142135623730951

From Solidity:

import {IBL127} from "bl127/IBL127.sol";

contract StaticcallExample {
    IBL127 public constant BL127 = IBL127(0x0DF738d125677550a80A46BaF254386C66f96E0d);
    int256 internal constant ONE = int256(1) << 128;

    function accrue(int256 principalWad, int256 rateWad, uint256 steps)
        external view returns (int256 balanceWad)
    {
        int256 b = BL127.fromFixed18(principalWad);
        int256 growth = BL127.add(ONE, BL127.fromFixed18(rateWad));
        for (uint256 i = 0; i < steps; i++) {
            b = BL127.mul(b, growth);
        }
        return BL127.toFixed18(b);
    }
}

Integrating

Two rules, each from the replay of a real exploit. The integration page has the replays, the code in both formats and the edge-op tables.

  1. Store the state in the format. Keep a running balance, index or accumulator as an BL127 word, and round to token units only on the way out. In Bunni v2's replay this alone turns a balance drained from 28 wei to 4 into 35.6 wei, within 10−37 of exact.
  2. Round toward the protocol at the token edge. Floor what you pay out and ceil what you take in: toFixed18Down and toFixed18Up, mulDown and mulUp, divDown and divUp. Under this rule Resupply's borrow against 1 wei of collateral reverts, and Balancer V2's charge matches the exact one on every call.

The ops

Each op's worst-case precision against its gas. Hover a dot for the op.

opwhatdomainrevertsbits (min/mean)gas, objectgas, inlinebytes inline
fromFixed18(int256)proven
0x9aea0a13
Convert an 18-decimal fixed-point value to 127.128. |x| < 2^127
OutOfRangeOutOfRange() if |x| >= 2^127
131/191 858 14086
toFixed18(int256)proven
0xa2745c74
Convert a 127.128 value to 18-decimal fixed-point. any x never 181/199 877 12154
toFixed18Down(int256)proven
0x32154abe
Convert a 127.128 value to 18-decimal fixed-point, rounding toward negative infinityConvert a 127.128 value to 18-decimal fixed-point, rounding toward negative infinity: toFixed18 under the name that says its direction. For an amount a protocol pays out.
any x never 181/199 765 16077
toFixed18Up(int256)proven
0x27066ea4
Convert a 127.128 value to 18-decimal fixed-point, rounding toward positive infinityConvert a 127.128 value to 18-decimal fixed-point, rounding toward positive infinity: ceil(x * 1e18 / 2^128). For an amount a protocol charges.
any x never 182/201 874 18592
add(int256,int256)proven
0xa5f3c23b
a + b. Reverts with Overflow on signed overflow. any a, b
OverflowOverflow() on signed overflow
exact 682 12875
sub(int256,int256)proven
0xadefc37b
a - b. Reverts with Overflow on signed overflow. any a, b
OverflowOverflow() on signed overflow
exact 741 14075
mul(int256,int256)proven
0xbbe93d91
floor(a * b / 2^128), the 127.128 product. any a, b
OverflowOverflow() when a·b / 2^128 does not fit in int256
132/232 862 267119
div(int256,int256)proven
0x43509138
a / b in 127.128, truncated toward zero. b != 0
DivisionByZero, OverflowDivisionByZero() when b == 0; Overflow() when the quotient does not fit
129/193 1,047 413480
mulDown(int256,int256)proven
0x4b8d10e0
floor(a * b / 2^128), toward negative infinity: mul under the name that says its direction. any a, b
OverflowOverflow() when a·b / 2^128 does not fit
131/229 803 315128
mulUp(int256,int256)proven
0x1aabb2f7
ceil(a * b / 2^128), toward positive infinity. any a, b
OverflowOverflow() when the rounded product does not fit
129/226 913 337243
divDown(int256,int256)proven
0xddb29e92
a / b in 127.128, rounded toward negative infinitya / b in 127.128, rounded toward negative infinity. div truncates toward zero, which rounds a negative quotient up; this does not.
b != 0
DivisionByZero, OverflowDivisionByZero() when b == 0; Overflow() when the quotient does not fit
128/188 1,030 476498
divUp(int256,int256)proven
0xe2bec1fe
a / b in 127.128, rounded toward positive infinity. b != 0
DivisionByZero, OverflowDivisionByZero() when b == 0; Overflow() when the quotient does not fit
129/190 1,115 479499
exp2(int256)
0xdbbb06d2
2^x, rounded to nearest. x < 127; returns 0 below -129
OverflowOverflow() for x >= 127
130/217 2,906 2,7125,637
exp(int256)
0xe46751e3
e^x, rounded to nearest. x <= 88; returns 0 below -90
OverflowOverflow() for x > 88
130/207 3,058 2,8355,794
exp10(int256)
0x4c0dafa2
10^x, rounded to nearest. 10^x < 2^127 (x below about 38.23)
OverflowOverflow() above that
131/217 3,171 3,0815,825
log2(int256)
0xe0d68737
log2(x) for x > 0, rounded to nearest. x > 0
OutOfRangeOutOfRange() for x <= 0
130/233 2,178 1,7238,337
ln(int256)
0x8e6f2353
ln(x) = log2(x) * ln(2) for x > 0, rounded to nearest. Reverts with OutOfRange otherwise. x > 0
OutOfRangeOutOfRange() for x <= 0
130/135 2,321 1,9448,605
log10(int256)
0xb47ca3c7
log10(x) = log2(x) / log2(10) for x > 0, rounded to nearest. Reverts with OutOfRange otherwise. x > 0
OutOfRangeOutOfRange() for x <= 0
130/135 2,360 1,9348,605
log2Up(int256)
0xa1985bb6
ceil(log2(x)) for x > 0: msb(x) - 128, plus one unless x is a power of two. Exact. x > 0
OutOfRangeOutOfRange() for x <= 0
exact 815 209106
sqrt(int256)proven
0x665df460
sqrt(x) for x >= 0. x >= 0
OutOfRangeOutOfRange() for x < 0
129/198 1,221 469358
pow(int256,int256)
0x92b0c5b2
x^y = 2^(y * log2(x)) for x >= 0, rounded to nearest. x >= 0
OutOfRange, OverflowOutOfRange() for x < 0; Overflow() when the result does not fit
130/181 4,185 3,31715,227
powu(int256,int256)
0xb8846d69
x^floor(n) for an integer exponent, rounded to nearestx^floor(n) for an integer exponent, rounded to nearest: binary exponentiation on a 256-bit mantissa with a separate binary exponent, rounded once; when that mantissa's error bound (k + 5) 2^-255, k = |n|, straddles a rounding midpoint, the same product on a 512-bit mantissa decides (within (3k + 2) 2^-510 relative, under 2^-237 ULP below k = 2^16; exact at every tie). From k = 2^16, pow(|x|, n) with the sign restored. Correctly rounded, the magnitude's ties away from zero. Exact whenever the true value is representable. Negative n inverts the mantissa, so x^-n is as accurate as x^n.
any x, any integer n = floor(n); the result must fit
Overflow, DivisionByZeroOverflow() when x^n does not fit; DivisionByZero() for 0^n with n < 0
130/204 6,125 5,25216,406
compound(int256,int256)
0xa31e7db8
(1 + rate)^floor(n)(1 + rate)^floor(n): n periods of compounding at rate, rounded to nearest, so a per-second rate over years keeps every bit the format holds. 1 + rate is exact in 127.128 (the sum of two grid values), so nothing is lost before the exponentiation; on an 18-decimal grid the rate itself would have been the first loss.
rate > -1 and 1 + rate fits; any integer n = floor(n)
Overflow, DivisionByZeroOverflow() when 1 + rate or the result does not fit; DivisionByZero() for rate == -1 with n < 0
130/208 4,006 3,07816,932
cbrt(int256)
0xd4b6188c
Cube root, defined for all x: cbrt(-x) = -cbrt(x)Cube root, defined for all x: cbrt(-x) = -cbrt(x). Rounded to nearest (decided exactly); exact for perfect cubes.
any x
OverflowOverflow() only at -2^255
130/164 2,452 1,936600
gavg(int256,int256)
0xa13ed853
The geometric mean sqrt(a b), rounded once to nearest from the exact 512-bit pro…The geometric mean sqrt(a b), rounded once to nearest from the exact 512-bit product; two negatives give the positive root.
a·b >= 0
OutOfRange, OverflowOutOfRange() if a and b have opposite signs (both nonzero); Overflow() only for a = b = -2^255
131/210 1,625 1,003566
hypot(int256,int256)
0xc479210c
sqrt(a^2 + b^2), rounded once to nearest from the exact sum of squares (two words); no intermediate overflows. any a, b; the result must fit
OverflowOverflow() when sqrt(a^2 + b^2) rounds past the largest word
131/183 1,689 1,164549
inv(int256)proven
0x3a02c983
1 / x. Reverts with DivisionByZero for x == 0. x != 0
DivisionByZero, OverflowDivisionByZero() for x == 0; Overflow() when 1/x does not fit
exact 954 242135
abs(int256)
0x1b5ac4b5
|x|. Reverts with Overflow for x == -2^255, which has no positive. any x
OverflowOverflow() at -2^255
exact 681 14074
neg(int256)
0x54a53dc1
-x. Reverts with Overflow for x == -2^255. any x
OverflowOverflow() at -2^255
exact 712 11670
sign(int256)
0xae610b48
-1, 0 or 1 in 127.128. any x never exact 737 758
min(int256,int256)
0x29aa9cbe
Signed minimum. any a, b never exact 935 12651
max(int256,int256)
0x81fe5786
Signed maximum. any a, b never exact 1,037 12551
clamp(int256,int256,int256)
0x7b8d0f0c
min(hi, max(lo, x)). If lo > hi the result is hi, as in the Huff. any x, lo, hi never exact 760 24176
avg(int256,int256)
0xebf274be
floor((a + b) / 2) without intermediate overflow. any a, b never 129/234 857 11446
zeroFloorSub(int256,int256)
0xff78cf2d
max(0, a - b), with the subtraction checked. any a, b
OverflowOverflow() if a - b does not fit
exact 1,032 212101
dist(int256,int256)
0x2110b689
|a - b|, checked. any a, b
OverflowOverflow() if a - b does not fit
exact 992 289122
lerp(int256,int256,int256)
0x84e13b60
a + t * (b - a), checked. t is not clamped to [0, 1]. any a, b, t
OverflowOverflow() on any intermediate that does not fit
exact 986 477219
floor(int256)proven
0xda3ff648
Largest integer <= x. Clearing the fraction bits of a two's complement word is a floor for both signs. any x never exact 744 7217
ceil(int256)proven
0x42fba7ae
Smallest integer >= x, as -floor(-x). Reverts with Overflow when that integer is 2^127, which does not fit. any x
OverflowOverflow() when the result is 2^127
exact 834 14790
frac(int256)
0xae2785ac
x - floor(x), always in [0, 1). any x never exact 767 6916
round(int256)proven
0x58b4370b
Nearest integer, halves rounded up: floor(x + 1/2). Checked. any x
OverflowOverflow() when the result is 2^127
exact 884 212103
factorial(int256)
0x5ad78e8a
floor(n)! for 0 <= n < 34. 33! is the largest that fits. 0 <= n < 34
OutOfRangeOutOfRange() outside that
exact 880 400864
lambertW0(int256)
0x6de23d34
Principal branch of the Lambert W function, W(x) e^W(x) = x, for x >= -1/e, rounded to nearest. x >= -1/e
OutOfRangeOutOfRange() below -1/e
130/134 7,947 8,30412,374
lambertWm1(int256)
0xae94abc8
Secondary real branch of the Lambert W function, W(x) e^W(x) = x with W(x) <= -1…Secondary real branch of the Lambert W function, W(x) e^W(x) = x with W(x) <= -1, for -1/e <= x < 0, rounded to nearest.
-1/e <= x < 0
OutOfRangeOutOfRange() outside that
130/131 7,882 8,15612,050
log1p(int256)
0x8552a5b0
ln(1 + x), rounded to nearestln(1 + x), rounded to nearest. The sum 1 + x is exact in 127.128, so this is ln of an exact argument, rounded once; it exists because on an 18-decimal grid the sum is where a small x loses its digits.
x > -1
OutOfRange, OverflowOutOfRange() at or below -1; Overflow() if 1 + x does not fit
130/132 2,526 2,0648,692
expm1(int256)
0x973fe005
e^x - 1, rounded to neareste^x - 1, rounded to nearest: on the 2^-128 grid the one is exact, so this is exp(x) - 1 word for word, a small x by its own series for gas: 27 terms at 2^-192 for |x| <= 1/16.
x <= 88; returns -1 below -90
OverflowOverflow() for x > 88
130/153 3,252 2,9966,207
sinh(int256)
0x17cdc976
sinh(x), correctly rounded (to nearest, ties away from zero)sinh(x), correctly rounded (to nearest, ties away from zero): (u + u/(1+u)) / 2 with u = e^|x| - 1 from one double-word exp at 2^-192, u/(1+u) = 1 - e^-|x| by one division, the difference taken before the one rounding; when that value's bound reaches the rounding boundary, e^|x| and e^-|x| in two words.
|x| <= 88
OverflowOverflow() beyond that
130/149 4,196 4,1096,392
cosh(int256)
0xaa13e96a
cosh(x), correctly rounded (to nearest, ties up)cosh(x), correctly rounded (to nearest, ties up): 1 + u (u/(1+u)) / 2 with u = e^|x| - 1 from one double-word exp at 2^-192 and u/(1+u) = 1 - e^-|x| by one division, which is (e^|x| + e^-|x|) / 2 summed before the one rounding; when that value's bound reaches the rounding boundary, e^|x| and e^-|x| in two words. cosh(0) is exactly one.
|x| <= 88
OverflowOverflow() beyond that
129/175 4,218 4,0736,382
tanh(int256)
0x5e1f05c7
tanh(x), correctly rounded (to nearest, ties away from zero)tanh(x), correctly rounded (to nearest, ties away from zero). For |x| < 1/4, u / (u + 2) with u = e^(2|x|) - 1 at 2^-192; up to |x| < 46, (1 - w) / (1 + w) with w = e^(-2|x|) at 2^-192; one division and one rounding, or, when the value's bound reaches the rounding boundary, (1 - w) / (1 + w) in two words. Beyond, exactly +-1, the nearest word.
any x never 130/216 4,205 3,9756,715
atanh(int256)
0xc6285a68
atanh(x) = log1p(2|x| / (1 - |x|)) / 2, correctly rounded (to nearest, ties away…atanh(x) = log1p(2|x| / (1 - |x|)) / 2, correctly rounded (to nearest, ties away from zero): the quotient at 2^-192 by one division, one unrounded logarithm of 1 + it, scaled by ln2/2; when that value's bound reaches the rounding boundary, the double-word l2p of 1 + |x| and 1 - |x|, and when that one's does, their logarithms in two words.
|x| < 1
OutOfRangeOutOfRange() at or beyond +-1
130/131 3,268 2,94411,158
erf(int256)
0x7a959e36
The error functionThe error function. |x| <= 4 by the series, |x| > 4 as 1 - erfc(x) by the tail polynomial, both at 2^-192; one rounding when the value's bound (2^45.6 and 2^20 units of 2^-192, erf_model.py) clears the rounding boundary, else BL19's wide second stage (erfw127): correctly rounded. +-1 exactly from |x| >= 9.375, where 1 - erf is below 2^-130.8 and the subtraction rounds to one.
any x never 132/186 7,676 7,5598,849
erfc(int256)
0xa79456da
The complementary error function 1 - erf(x), computed directly for x > 4 so the …The complementary error function 1 - erf(x), computed directly for x > 4 so the tail keeps its digits down to the format's floor (erfc(9.3) is one ULP; zero from 9.375, where it is below 2^-130.8); 2 - erfc(-x) for x < 0. One rounding, or erf's second stage when the bound reaches the boundary: correctly rounded.
any x never 130/132 7,330 7,1868,880
normCdf(int256)
0x7b9745a0
The standard normal distribution function Phi(x) = erfc(-x / sqrt2) / 2, the arg…The standard normal distribution function Phi(x) = erfc(-x / sqrt2) / 2, the argument scaled at 2^-192. The tail keeps its digits to the format's floor: Phi(-13) is about two ULPs, Phi(-14) rounds to zero, and Phi(x) is exactly one from x = 14. One rounding, or erf's second stage at y = |x| / sqrt 2 (exact, as BL19's) when the bound reaches the boundary: correctly rounded. This is the N(d) of Black-Scholes; on an 18-decimal grid it is zero from about d = -8.3.
any x never 147/228 6,155 6,0378,926

Bits are relative correct bits against mpmath at 200 decimal places over the committed vectors; "exact" is bit-identical. proven: every input against a reference spec, on the proofs page. Open an op for its method; hover a reverts cell for when. Gas is the median over random inputs that take no shortcut for the 22 ops that have them (the transcendentals, roots and powers), else over the op's input ladder. "Object" includes the staticcall to the deployed object that holds the op (BL127, BL127E, BL127H, BL127N or BL127S); "inline" is BL127Lib. "Bytes inline" is the runtime code one op adds to a contract that calls it through BL127Lib, helpers included (default profile, legacy pipeline; hover for via_ir). Ops that share a kernel add less together than their rows (size.json's bundles). Inlining saves about 554 gas a call; staticcall the deployed object instead when inlining would take a contract near the 24,576-byte limit, or for a large op called rarely. Sources: ops.json (generated from the Yul source), precision.json, gas.json, size.json.

For agents

/.well-known/agent.json, /llms.txt, ABI at /data/abi.json. Reads are eth_call: no gas, no wallet.