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 decimals
  • wad_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:

  1. Representing all values pre-scaled1.5 is stored as 1_500_000_000_000_000_000. Addition and subtraction work directly with no changes.

  2. Providing wad_mul_div(a, b, d) — computes floor(a * b / d) using a 256-bit intermediate representation in unconstrained code, then verifies the result in the ZK circuit with the constraint a * b == q * d + r. No overflow, no precision loss, fully ZK-verifiable.

  3. Deriving wad_mul and wad_div from wad_mul_div:

    • wad_mul(a, b) = wad_mul_div(a, b, SCALE) — multiplies and rescales in one step
    • wad_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

ConstantValueMeaning
MAX_WAD_CONVERTIBLE340_282_366_920_938_463_463Largest x where to_wad(x) fits in u128
MAX_WAD_MUL_DIV_INT170_141_183_460_469_231_731Largest x where to_wad(x) passes the < $2^{12}$7 range check in wad_mul_div
MAX_WAD_MUL_INT13_043_817_825Largest 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

  1. Constrained range checksa, b, d are cast to u128 in constrained context (panics if >= $2^{12}$8) then asserted < $2^{12}$7.
  2. Unconstrained hint — a 256-bit long division computes (quotient, remainder) using four 64-bit limbs in u128 arithmetic, with no modular reduction.
  3. Circuit verification — the constrained context checks a * b == quotient * d + remainder in Field, and remainder < d. Because inputs are < $2^{12}$7, a * b < p and Field equality implies integer equality.
  4. Quotient range checkquotient is range-checked to prevent a malicious prover from supplying quotient + k*p as 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:

Functionwad.nrnoir-bignum
main81163
multiply (unconstrained)271925
divide (unconstrained)1616
quadratic constraint1823
range / borrow checks98 + 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.nrnoir-bignum
Input size< $2^{12}$7up to 2^8192
Use caseDeFi token arithmeticRSA, ECC, cross-curve
Verification cost1 Field gateBarrett reduction
Brillig safety✓ passes✗ fails for muldiv pattern
Soundnessproven for < $2^{12}$7proven 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 u128 ceiling and 2^127 range bound
  • Field modulus boundary soundness
  • Nested floor identity with exact and non-exact divisors
  • Non-associativity of wad_mul counterexample

Limitations

  • Unsigned only — no negative numbers. wad_sub panics on underflow.
  • Floor division only — no rounding modes. truncate(wad_div(7, 2)) == 3, not 4.
  • wad_mul is not associative — use a single wad_mul_div call where exact composition matters.
  • Max safe input < $2^{12}$7 — see constants above for the precise integer ceilings per operation.
  • wad_add does not guard against overflow — caller is responsible for ensuring the sum does not exceed u128::MAX if the result will be passed to truncate or further arithmetic.

License

MIT