wad.nr
February 26, 2026 · View on GitHub
A WAD (Wei-As-Decimal) fixed-point arithmetic library for Noir, targeting the Aztec Network. Provides safe 18-decimal fixed-point math with overflow-resistant mul_div operations.
Why WAD?
Token amounts in Ethereum-based systems carry 18 decimal places of precision. A balance of 1.5 USDC is stored as 1_500_000_000_000_000_000 on-chain. This is fine for storage and transfer, but it creates serious problems the moment you try to do arithmetic.
Problem 1 — Multiplication overflows
If you multiply two token amounts directly, you get a number that is 1$0^{1}$8 times too large. With u128 arithmetic this almost always overflows:
// User has 1000 tokens, price is 1000 tokens per unit
1000 * $10^{18}$ * 1000 * $10^{18}$ = $10^{42}$
u128::MAX ≈ 3.4 * $10^{38}$ —— OVERFLOW
And if you move the multiplication to Field to avoid overflow, you now have a 36-decimal number. You cannot simply divide by 1$0^{1}$8 in Field arithmetic to bring it back — Field division is modular inversion (a * b^-1 mod p), not integer floor division. The integer floor you need simply does not exist as a native Field operation.
Problem 2 — Division truncates precision
If you divide two token amounts using plain integer division, you lose all fractional precision immediately:
// What fraction of 3 tokens is 1 token?
(1 * $10^{18}$) / (3 * $10^{18}$) = 0 // integer floor division, result truncated to zero
The mathematically correct answer is 0.333..., which in 18-decimal representation should be 333_333_333_333_333_333. To get there, you need to scale the numerator up by 1$0^{1}$8 before dividing: (a * $10^{18}$) / b. But now you are back to the overflow problem — a * $10^{1}$8 may overflow u128 before the division can rescue it.
Problem 3 — You need multiplication and division together, atomically
Both problems above collapse into the same root issue: you need to compute floor(a * b / d) as a single atomic operation, where the intermediate product a * b may exceed any fixed-width integer type, but the final result fits comfortably. You cannot split this into a multiply-then-divide without an intermediate overflow, and you cannot do it in Field arithmetic without losing floor semantics.
This comes up everywhere in DeFi:
wad_mul(price, amount)— rescale a product back to token decimalswad_div(a, b)— compute a ratio without truncating to zero- Swap output amounts, fee calculations, interest accrual, price impact — all of these are
floor(a * b / d)under the hood
The WAD solution
WAD fixes this by:
-
Representing all values pre-scaled —
1.5is stored as1_500_000_000_000_000_000. Addition and subtraction work directly with no changes. -
Providing
wad_mul_div(a, b, d)— computesfloor(a * b / d)using a 256-bit intermediate representation in unconstrained code, then verifies the result in the ZK circuit with the constrainta * b == q * d + r. No overflow, no precision loss, fully ZK-verifiable. -
Deriving
wad_mulandwad_divfromwad_mul_div:wad_mul(a, b)=wad_mul_div(a, b, SCALE)— multiplies and rescales in one stepwad_div(a, b)=wad_mul_div(a, SCALE, b)— scales up before dividing, preserving all decimals
// Correct WAD multiplication
wad_mul(1000 * $10^{18}$, 1000 * $10^{18}$)
= floor(1000 * $10^{18}$ * 1000 * $10^{18}$ / $10^{18}$)
= 1_000_000 * $10^{18}$ ✓ (no overflow, correct decimals)
// Correct WAD division
wad_div(1 * $10^{18}$, 3 * $10^{18}$)
= floor(1 * $10^{18}$ * $10^{18}$ / 3 * $10^{18}$)
= floor($10^{18}$ / 3)
= 333_333_333_333_333_333 ✓ (0.333... WAD, no truncation to zero)
Installation
Add the library to your Nargo.toml:
[dependencies]
wad = { git = "https://github.com/merkle-groot/wad.nr", tag = "main"}
Then import in your Noir source:
use wad::{to_wad, wad_mul, wad_div, wad_mul_div, truncate, wad_add, wad_sub, from_u128};
API Reference
Type
pub type Wad = Field;
global SCALE: Field = 1_000_000_000_000_000_000; // $10^{18}$
Conversions
to_wad(value: u128) -> Wad
Converts a plain integer to WAD format by multiplying by SCALE.
let w = to_wad(5u128); // w == 5 * $10^{18}$
from_u128(value: u128) -> Wad
Raw cast — no scaling. Use when the value is already in WAD units or is a raw sub-unit amount.
let w = from_u128(500_000_000_000_000_000u128); // w == 0.5 WAD
truncate(value: Wad) -> u128
Extracts the integer part of a WAD value, discarding the fractional portion (floor).
let w = to_wad(7u128) + SCALE / 2; // 7.5 WAD
truncate(w); // == 7
Arithmetic
wad_add(a: Wad, b: Wad) -> Wad
Adds two WAD values.
wad_add(to_wad(3u128), to_wad(4u128)); // == to_wad(7)
wad_sub(a: Wad, b: Wad) -> Wad
Subtracts b from a. Panics on underflow.
wad_sub(to_wad(10u128), to_wad(3u128)); // == to_wad(7)
wad_mul(a: Wad, b: Wad) -> Wad
Multiplies two WAD values and rescales: floor(a * b / SCALE).
wad_mul(to_wad(3u128), to_wad(4u128)); // == to_wad(12)
wad_mul(to_wad(3u128), SCALE / 2); // == to_wad(1) + SCALE/2 (1.5 WAD)
wad_div(a: Wad, b: Wad) -> Wad
Divides two WAD values: floor(a * SCALE / b). Preserves fractional precision.
wad_div(to_wad(7u128), to_wad(2u128)); // == to_wad(3) + SCALE/2 (3.5 WAD)
wad_mul_div(a: Field, b: Field, d: Field) -> Field
The core primitive. Computes floor(a * b / d) without intermediate overflow, using a 256-bit long-division algorithm in an unconstrained hint verified by a circuit constraint.
// Apply a ratio: amount * numerator / denominator, in one atomic step
wad_mul_div(to_wad(1000u128), to_wad(3u128), to_wad(7u128)); // == to_wad(428) + ...
All higher-level functions delegate to this:
wad_mul(a, b)=wad_mul_div(a, b, SCALE)wad_div(a, b)=wad_mul_div(a, SCALE, b)
Safety & Constraints
Input range
All inputs to wad_mul_div must satisfy value < $2^{12}$7. This is enforced by constrained range checks inside the function — proof generation fails if any input violates this bound.
The limit exists because wad_mul_div verifies its result in BN254 Field arithmetic using the constraint a * b == q * d + r. The BN254 field modulus is p ≈ $2^{25}$4. If a * b >= p, the product wraps mod p and the constraint can be satisfied by a forged quotient. By bounding all inputs to < $2^{12}$7, we guarantee a * b < $2^{254}$ < p, making the constraint sound — Field equality implies integer equality.
Safe input ceilings
| Constant | Value | Meaning |
|---|---|---|
MAX_WAD_CONVERTIBLE | 340_282_366_920_938_463_463 | Largest x where to_wad(x) fits in u128 |
MAX_WAD_MUL_DIV_INT | 170_141_183_460_469_231_731 | Largest x where to_wad(x) passes the < $2^{12}$7 range check in wad_mul_div |
MAX_WAD_MUL_INT | 13_043_817_825 | Largest x where wad_mul(to_wad(x), to_wad(x)) result is still < $2^{12}$7 |
MAX_WAD_MUL_DIV_INT ≈ 1.7 × $10^{2}$0 means any token amount below ~170 quintillion units is safe as input to wad_mul_div. This covers all realistic DeFi amounts.
Nested floor identity
floor(floor(N / a) / b) == floor(N / (a * b)) holds unconditionally — no exact division required. Chained wad_mul_div calls can always be collapsed into a single call with a combined denominator, with identical results and no accumulated rounding error.
wad_mul is not associative
Due to flooring at each step, wad_mul(wad_mul(a, b), c) can differ from wad_mul(a, wad_mul(b, c)) when values do not divide exactly. When precision matters, prefer a single wad_mul_div call with the combined denominator over chained wad_mul calls.
How wad_mul_div works
- Constrained range checks —
a,b,dare cast tou128in constrained context (panics if>= $2^{12}$8) then asserted< $2^{12}$7. - Unconstrained hint — a 256-bit long division computes
(quotient, remainder)using four 64-bit limbs inu128arithmetic, with no modular reduction. - Circuit verification — the constrained context checks
a * b == quotient * d + remainderinField, andremainder < d. Because inputs are< $2^{12}$7,a * b < pandFieldequality implies integer equality. - Quotient range check —
quotientis range-checked to prevent a malicious prover from supplyingquotient + k*pas a forged witness.
Examples
Token swap output
// Uniswap-style: output = reserve_out * amount_in / (reserve_in + amount_in)
let reserve_in = to_wad(100_000u128);
let reserve_out = to_wad(200_000u128);
let amount_in = to_wad(1_000u128);
let amount_out = wad_mul_div(reserve_out, amount_in, wad_add(reserve_in, amount_in));
truncate(amount_out); // == 1980 (with slippage)
Fee calculation
// 0.3% fee on 10,000 tokens
let amount = to_wad(10_000u128);
let fee_rate = SCALE * 3 / 1000; // 0.003 WAD
let fee = wad_mul(amount, fee_rate);
truncate(fee); // == 30
Interest accrual
// Apply a 5% APR over a fraction of a year
// interest = principal * rate * time_fraction
let principal = to_wad(1_000u128);
let annual_rate = SCALE * 5 / 100; // 0.05 WAD
let time_fraction = SCALE / 12; // 1/12 of a year (1 month)
let interest = wad_mul(wad_mul(principal, annual_rate), time_fraction);
truncate(interest); // == 4 (floor of 4.166...)
Price from reserves
// price = reserve_b / reserve_a (preserves all decimal precision)
let reserve_a = to_wad(100_000u128);
let reserve_b = to_wad(350_000u128);
let price = wad_div(reserve_b, reserve_a); // 3.5 WAD
truncate(price); // == 3
Comparison: wad_mul_div vs noir-bignum
An alternative implementation using noir-bignum's U256 type was evaluated. noir-bignum is a general-purpose arbitrary-precision library designed for operations like RSA, ECC, and cross-curve arithmetic where inputs can reach thousands of bits. This comparison exists to document why that approach was not used.
Opcode counts
Measured with nargo compile --print-acir on identical wad_mul_div(a, b, d) inputs:
| Function | wad.nr | noir-bignum |
|---|---|---|
main | 81 | 163 |
| multiply (unconstrained) | 271 | 925 |
| divide (unconstrained) | — | 1616 |
| quadratic constraint | — | 1823 |
| range / borrow checks | — | 98 + 191 |
| Total | ~360 | ~4816 |
wad.nr is ~13x fewer opcodes for the same operation.
Why the difference
noir-bignum represents every number as three 120-bit limbs to support up to 257-bit moduli. Every multiplication and comparison runs Barrett reduction across all three limbs, even when the values easily fit in a single limb.
wad.nr exploits the fact that WAD inputs are always < $2^{12}$7:
noir-bignum verification:
__mul(a_bn, b_bn) → 3-limb product (925 opcodes)
__udiv_mod(product, d_bn) → 3-limb quotient (1616 opcodes)
evaluate_quadratic_expression → Barrett reduction (1823 opcodes)
wad.nr verification:
unconstrained_mul_div(a, b, d) → (quotient, remainder) (271 opcodes)
assert(a * b == q * d + r) → 1 native Field gate (part of main: 81)
The single Field constraint a * b == q * d + r is sound because inputs are bounded to < $2^{12}$7, so a * b < $2^{254}$ < p (BN254 field modulus). Field equality implies integer equality — no multi-limb arithmetic needed.
Compiler safety
Noir's Brillig safety checker also rejects the bignum approach for this pattern. Because __mul and __udiv_mod are chained unconstrained calls, the intermediate product value is never directly referenced in a circuit constraint:
// Brillig checker error:
// "This Brillig call's inputs and its return values haven't been
// sufficiently constrained"
let product = unsafe { a_bn.__mul(b_bn) };
let (q, r) = unsafe { product.__udiv_mod(d_bn) };
wad.nr's single unconstrained call returns (quotient, remainder) which are immediately and visibly consumed by the Field constraint — the checker passes with no warnings.
When to use each
| wad.nr | noir-bignum | |
|---|---|---|
| Input size | < $2^{12}$7 | up to 2^8192 |
| Use case | DeFi token arithmetic | RSA, ECC, cross-curve |
| Verification cost | 1 Field gate | Barrett reduction |
| Brillig safety | ✓ passes | ✗ fails for muldiv pattern |
| Soundness | proven for < $2^{12}$7 | proven for arbitrary size |
For WAD arithmetic where inputs are always 18-decimal token amounts, wad.nr's hand-rolled approach is the correct choice. noir-bignum is the correct choice when inputs genuinely exceed 2^127.
Running Tests
nargo test
Tests in src/wad.nr cover:
- Basic arithmetic and identity properties
- Commutativity, distributivity
- Fractional floor behaviour and repeating decimals
- Roundtrip conversions
u128 → WAD → u128 - Zero and boundary inputs
- Overflow rejection for
u128ceiling and2^127range bound - Field modulus boundary soundness
- Nested floor identity with exact and non-exact divisors
- Non-associativity of
wad_mulcounterexample
Limitations
- Unsigned only — no negative numbers.
wad_subpanics on underflow. - Floor division only — no rounding modes.
truncate(wad_div(7, 2)) == 3, not4. wad_mulis not associative — use a singlewad_mul_divcall where exact composition matters.- Max safe input
< $2^{12}$7— see constants above for the precise integer ceilings per operation. wad_adddoes not guard against overflow — caller is responsible for ensuring the sum does not exceedu128::MAXif the result will be passed totruncateor further arithmetic.
License
MIT