Security · formal verification

Don't trust us. Check the math.

QuietOdds holds your money behind zero-knowledge proofs, so the rules inside those proofs have to be right. We proved the money rules in Lean, a proof assistant where a computer checks every step. Then we tested everything else. Every number on this page comes from the tools' own output.

Every check passes

Run 7 Nov 2026, 00:18 UTC on commit 7968b77 ↗

Why a proof, not just tests

Tests check examples

A test tries one input, or a few thousand random ones. If a bug needs one exact number out of 2²⁵⁴, testing will almost never find it.

ZK circuits wrap around

Circuit math works modulo a huge prime p, like a clock. On a clock, 11 + 3 = 2. In a circuit, a "negative" number is just a very big one. That's how money gets created from nothing.

A proof covers every input

Lean checks each step of the argument down to its axioms, for all possible values at once. If it builds, the claim holds. No sampling, no luck.

What is proved

Four theorems, one per rule that would lose people money if it were wrong. Each one names the exact circuit or contract lines it models.

Theorem 1 proved

A private spend can't create money

Whatever goes into a spend comes out exactly: the note you spend equals the change you keep, plus what you bet, swap or withdraw, plus the relayer fee.

Rules out
A forged proof that spends a 1 USDC note but bets 1,000,000 USDC, by making the numbers wrap around the field.
How
The circuit's equation holds "modulo p", like a clock that wraps at 12. On its own, 1 = change + bet can be satisfied by a gigantic "negative" change. The four 128-bit range checks stop that, and the theorem proves they are enough: since 3 × 2¹²⁸ < p, the sum can never wrap, so the field equation is the ordinary equation.
spend circuit · spend/src/main.nr L50–54source ↗
50    assert_u128(amount);51    assert_u128(change);52    assert_u128(public_amount);53    assert_u128(fee);54    assert(amount == change + public_amount + fee, "value not conserved");
Lean · Spend.lean L29source ↗
29theorem spend_conserves_value (amount change pub fee : ZMod p)30    /- main.nr line 50: `assert_u128(amount)` -/31    (hA : amount.val < 2 ^ 128)32    /- main.nr line 51: `assert_u128(change)` -/33    (hC : change.val < 2 ^ 128)34    /- main.nr line 52: `assert_u128(public_amount)` -/35    (hP : pub.val < 2 ^ 128)36    /- main.nr line 53: `assert_u128(fee)` -/37    (hF : fee.val < 2 ^ 128)38    /- main.nr line 54: `assert(amount == change + public_amount + fee)` (field equality) -/39    (hCircuit : amount = change + pub + fee) :40    amount.val = change.val + pub.val + fee.val
spend_conserves_valuepropextQuot.sound
Theorem 2 proved

Payouts are exact, to the unit

When you claim a win or redeem a batch ticket, the payout is exactly ⌊stake × pot ÷ winning side⌋. Not a unit more. And the relayer fee can never exceed the payout.

Rules out
Claiming a payout slightly larger than your share, or a fee bigger than the payout so the leftover wraps into a huge note.
How
Circuits can't divide, so the prover supplies the answer and the circuit checks x·num = payout·den + remainder with 0 ≤ remainder < den. The theorem proves that with the 96-bit checks, this pins the payout to the one true floor division. The contract supplies num and den, never the user.
redeem circuit · redeem/src/main.nr L66–79source ↗
66    // 4. payout = floor(x * num / den), i.e. 0 <= x*num - payout*den < den.67    assert_u96(x);68    assert_u96(num);69    assert_u96(den);70    assert_u96(payout);71    assert(den != 0, "zero denominator");72    let remainder = x * num - payout * den;73    assert_u96(remainder); // >= 074    assert_u96(den - remainder - 1); // < den75 76    // 5. The fee comes out of the payout; the rest becomes a hidden balance note.77    let out_amount = payout - fee;78    assert_u128(fee);79    assert_u128(out_amount);
Lean · Redeem.lean L61source ↗
61theorem redeem_payout_exact (x num den payout : ZMod p)62    /- main.nr line 67: `assert_u96(x)` -/63    (hx : x.val < 2 ^ 96)64    /- main.nr line 68: `assert_u96(num)` -/65    (hn : num.val < 2 ^ 96)66    /- main.nr line 69: `assert_u96(den)` -/67    (hd : den.val < 2 ^ 96)68    /- main.nr line 70: `assert_u96(payout)` -/69    (hpay : payout.val < 2 ^ 96)70    /- main.nr line 71: `assert(den != 0)` -/71    (hd0 : den ≠ 0)72    /- main.nr lines 72-73: `remainder = x * num - payout * den; assert_u96(remainder)` -/73    (hr : (x * num - payout * den).val < 2 ^ 96)74    /- main.nr line 74: `assert_u96(den - remainder - 1)` -/75    (hLess : (den - (x * num - payout * den) - 1).val < 2 ^ 96) :76    payout.val = x.val * num.val / den.val ∧77      (x * num - payout * den).val = x.val * num.val % den.val
1 more theorem in this group
Lean · Redeem.lean L112source ↗
112theorem redeem_fee_sound (payout fee : ZMod p)113    /- main.nr line 70: `assert_u96(payout)` -/114    (hpay : payout.val < 2 ^ 96)115    /- main.nr line 78: `assert_u128(fee)` -/116    (hF : fee.val < 2 ^ 128)117    /- main.nr lines 77, 79: `out_amount = payout - fee; assert_u128(out_amount)` -/118    (hOut : (payout - fee).val < 2 ^ 128) :119    fee.val ≤ payout.val ∧ (payout - fee).val = payout.val - fee.val
redeem_payout_exactpropextQuot.soundredeem_fee_soundpropextClassical.choiceQuot.sound
Theorem 3 proved

Winners can never take more than the pot

If every winner is paid their rounded-down share, the total paid out is at most the pot. Rounding dust is less than one unit per person, and it stays in the pool.

Rules out
Rounding errors that add up across many claims until the last winner, or the pool, comes up short.
How
Each payout is floored, and a sum of floors is at most the floor of the sum. Here it is with the real numbers from our Sepolia batch #1, where three people put in 1.2, 1.0 and 1.0 USDC and the batch got back 1,392,866,552,093,773 wei: payouts add up to …772. That is 1 wei of dust, kept by the pool. Lean checks that example too.

Models the pro-rata terms that Markets and BatchSwapper hand to ShieldedPool.redeem.

Lean · ProRata.lean L38source ↗
38theorem pro_rata_conserves {ι : Type*} (s : Finset ι) (x : ι → ℕ) (N D : ℕ)39    (hD : ∑ i ∈ s, x i = D) :40    ∑ i ∈ s, x i * N / D ≤ N
1 more theorem in this group
Lean · ProRata.lean L50source ↗
50theorem pro_rata_dust_lt_card {ι : Type*} (s : Finset ι) (x : ι → ℕ) (N D : ℕ)51    (hD : ∑ i ∈ s, x i = D) (hDpos : 0 < D) :52    N < ∑ i ∈ s, x i * N / D + s.card
pro_rata_conservespropextClassical.choiceQuot.soundpro_rata_dust_lt_cardpropextClassical.choiceQuot.sound
Theorem 4 proved

A sandwich attack on our pool doesn't pay

Our Uniswap v4 hook charges the back half of a sandwich a fee as large as the price move it reverses. With that fee, buying first and selling right after in the same block can't make a profit.

Rules out
A bot buys just before your swap and sells just after, pocketing the price move your swap caused.
How
Cost of the front-run is at least q·P₀. Revenue from the back-run is at most q·(1−f)·P₂. With f ≥ (P₂−P₀)/P₀, revenue never beats cost. We also proved where it stops: the contract caps the fee at 50%, so if one block more than doubles the price, the guarantee fails. We proved that too, instead of hiding it.
QuietOddsHook.directionalFee · contracts/src/QuietOddsHook.sol L205–222source ↗
205    function directionalFee(PoolKey calldata key, bool zeroForOne) public view returns (uint24) {206        Checkpoint storage cp = _checkpoints[key.toId()];207        if (cp.blockNumber != uint48(block.number)) return BASE_FEE; // first swap of the block208        uint256 start = cp.startSqrtPriceX96;209        (uint160 sqrtNow,,,) = poolManager.getSlot0(key.toId());210        uint256 cur = sqrtNow;211 212        // zero-for-one pushes the price down, so it reverses an upward move, and vice versa.213        bool reverses = zeroForOne ? cur > start : cur < start;214        if (!reverses) return BASE_FEE;215 216        // The move, relative to the lower of the two prices (the larger ratio).217        uint256 ratio = FullMath.mulDiv(FullMath.mulDiv(cur, 1e6, start), cur, start); // (cur/start)^2 in pips218        if (ratio == 0) return MAX_REVERSAL_FEE; // the price fell more than 1e6-fold219        uint256 move = ratio > 1e6 ? ratio - 1e6 : FullMath.mulDiv(1e6 - ratio, 1e6, ratio);220        uint256 fee = BASE_FEE + move;221        return fee > MAX_REVERSAL_FEE ? MAX_REVERSAL_FEE : uint24(fee);222    }
Lean · Sandwich.lean L46source ↗
46theorem sandwich_unprofitable (q P₀ P₂ cost revenue f : ℝ)47    (hq : 0 ≤ q) (hP₀ : 0 < P₀) (hup : P₀ ≤ P₂)48    /- every unit of the front-run is bought at a price `≥ P₀` -/49    (hCost : q * P₀ ≤ cost)50    /- every unit of the back-run sells at a price `≤ P₂`, after the fee -/51    (hRev : revenue ≤ q * (1 - f) * P₂)52    (hFee : (P₂ - P₀) / P₀ ≤ f)53    /- `f ≤ 1` (kept to match the paper statement; the proof does not need it) -/54    (_hf1 : f ≤ 1) :55    revenue - cost ≤ 0
2 more theorems in this group
Lean · Sandwich.lean L71source ↗
71theorem sandwich_unprofitable_fee_rule (q P₀ P₂ cost revenue base : ℝ)72    (hq : 0 ≤ q) (hP₀ : 0 < P₀) (hup : P₀ ≤ P₂) (hbase : 0 ≤ base)73    (hCost : q * P₀ ≤ cost)74    (hRev : revenue ≤ q * (1 - (base + (P₂ - P₀) / P₀)) * P₂)75    (hf1 : base + (P₂ - P₀) / P₀ ≤ 1) :76    revenue - cost ≤ 0
Lean · Sandwich.lean L83source ↗
83theorem capped_fee_counterexample (q P₀ P₂ base : ℝ)84    (hq : 0 < q) (hP₀ : 0 < P₀) (hbig : 2 * P₀ < P₂) (hbase : 0 ≤ base) :85    let f := min (base + (P₂ - P₀) / P₀) (1 / 2)86    0 < q * (1 - f) * P₂ - q * P₀

Where it stops. capped_fee_counterexample proves that with the 50% fee cap, a block that more than doubles the price lets a sandwich profit. Sandwiches spread over several blocks are also outside the model. We state these limits as theorems, not footnotes.

sandwich_unprofitablepropextClassical.choiceQuot.soundsandwich_unprofitable_fee_rulepropextClassical.choiceQuot.soundcapped_fee_counterexamplepropextClassical.choiceQuot.sound

The axiom audit

A proof is only as strong as what it assumes. Lean can list every axiom a theorem rests on, so an unfinished proof (sorry) or a made-up assumption can't hide. This is the real output of lake build:

lean/ · lake build
$ lake build
'QuietOdds.spend_conserves_value' depends on axioms: [propext, Quot.sound]
'QuietOdds.redeem_payout_exact' depends on axioms: [propext, Quot.sound]
'QuietOdds.redeem_fee_sound' depends on axioms: [propext, Classical.choice, Quot.sound]
'QuietOdds.pro_rata_conserves' depends on axioms: [propext, Classical.choice, Quot.sound]
'QuietOdds.pro_rata_dust_lt_card' depends on axioms: [propext, Classical.choice, Quot.sound]
'QuietOdds.sandwich_unprofitable' depends on axioms: [propext, Classical.choice, Quot.sound]
'QuietOdds.sandwich_unprofitable_fee_rule' depends on axioms: [propext, Classical.choice, Quot.sound]
'QuietOdds.capped_fee_counterexample' depends on axioms: [propext, Classical.choice, Quot.sound]
Build completed successfully (9054 jobs).

propext Two statements that imply each other are equal.

Quot.sound Lean's rule for working with equivalence classes.

Classical.choice Ordinary classical logic, used by Mathlib.

These are Lean's three standard axioms, used by almost all of mathematics in Lean. No sorryAx, no custom axioms. Lean 4.35.0 with Mathlib.

What these proofs don't cover

A proof you can trust says exactly where it ends.

Constraints copied by hand

The circuit lines were transcribed into Lean by hand. Each hypothesis names the main.nr line it models, so you can check them side by side above. Nothing checks this mechanically.

Trusted toolchain

We trust the Noir compiler, Barretenberg, the generated Solidity verifier, and Poseidon's collision resistance. The proofs are about the arithmetic, not these tools.

Model, not bytecode

Theorems 3 and 4 model contract logic. Theorem 3 relies on three tested facts: stakes really sum to the total, each note redeems once, and the pot is really in the pool. Theorem 4 works over real numbers and leaves out integer rounding and price encoding.

Not audited

This is a hackathon build on Sepolia. Formally verified where it matters most, tested everywhere else, but not audited.

Tests, all of them

Proofs cover the arithmetic. Tests cover the rest: Merkle paths, nullifiers, the hook's deltas, batches, the resolver. The contract tests use real proofs generated by the circuits, plus fuzzing and invariants.

Noir circuits

36/36 passing

Valid spends and claims pass. Wrong side, bad Merkle path, inflated payout, wraparound and tampered notes all fail.

$ nargo test --workspace · 1.3s

Show all 36 tests

quietodds

  • assert member fails for wrong leaf
  • assert member fails for wrong root
  • assert member passes for valid path
  • rejects non boolean index
  • single level left child
  • single level right child
  • two levels mixed directions
  • works at production depth
  • balance leaf binds every field
  • leaves are domain separated
  • nullifier hash ignores secret
  • nullifier hash is not the precommitment
  • position leaf binds every field
  • u128 accepts max
  • u128 rejects overflow
  • hash 2 matches js and solidity
  • hash 4 matches js and solidity
  • hash 5 matches js and solidity

redeem

  • batch ticket redeems pro rata
  • loser cannot claim
  • refund any side one to one
  • rejects fee above payout
  • rejects inflated payout
  • rejects understated payout
  • rejects unknown kind
  • rejects zero denominator
  • ticket cannot pose as position
  • winner claims exact floor payout

spend

  • full withdraw zero change
  • rejects inflated note amount
  • rejects mismatched change commitment
  • rejects negative change wraparound
  • rejects note not in tree
  • rejects value creation
  • rejects wrong nullifier hash
  • valid bet spend
circuits/ ↗

Solidity contracts

118/118 passing

Real UltraHonk proofs, 8 fuzz tests (up to 1,000 runs each) and 3 invariants, including "the pool always holds every deposit".

$ forge test · 5.2s

Show all 118 tests

BatchSwapperTest

  • admin setters are one time and bounded
  • batch never fills worse than oracle bound
  • cancel stale refunds in input asset
  • cancelled batch refunds ticket1to1
  • donate fees leaves winners whole
  • donate fees pays in range lps
  • execute checks timing
  • execute usdc to weth batch
  • execute weth to usdc batch
  • front run beyond oracle bound reverts
  • join after execution reverts
  • join mints ticket and adds to batch
  • join rejects wrong asset dust and closed batch
  • only batches releases fees
  • only pool joins only batches releases
  • oracle checks
  • redeem terms revert until final
  • second batch in ablock pays the base fee
  • set pool opens one batch per direction
  • ticket cannot redeem before execution
  • ticket must use the batch ratio
  • ticket redeems only once
  • tickets redeem their pro rata share
  • unlock callback only from pool manager
  • weth join goes to the weth batch

DirectionalFeeTest

  • sandwich never profits either direction
  • control sandwich profits on static pool
  • fee is capped
  • ordinary swaps pay the base fee
  • reversing swap pays the move
  • same sandwich loses on dynamic pool
  • static fee pool is unchanged

ImportsTest

  • dependencies resolve

MarketsTest

  • cancel stale after grace
  • cannot resolve twice
  • create and bet
  • empty winning side refunds
  • feed price from before close rejected
  • no bet after close
  • no bet on bad side
  • only owner creates
  • only pool bets
  • only resolver resolves
  • pool set once
  • resolve from feed no
  • resolve from feed yes
  • resolve win and terms
  • resolver cancel
  • sports market cannot use feed
  • stale feed rejected

PayoutFuzzTest

  • payouts never exceed pot

PoolInvariantTest

  • no junk proof accepted
  • one leaf per deposit
  • pool holds every deposit

PoseidonMerkleTreeTest

  • empty root is zero subtree
  • insert gas
  • old roots expire
  • rejects leaf outside field
  • root history
  • root matches js tree
  • root matches proof fixture
  • zeros chain

PoseidonParityTest

  • hash2
  • hash4
  • hash5

QuietOddsHookTest

  • plain swap is untouched
  • sandwich never pays in one block
  • shield on every swap shape
  • first swap on an eth usdc priced pool is cheap
  • guard only covers one block
  • hook address has exactly its flags
  • note leaf is built from the public amount
  • reverts on pool without usdc
  • reverts on zero shield
  • reverts when shield eats the whole exact input
  • reverts when shield exceeds output
  • sandwich pays without the hook
  • shield still exact when the guard skims
  • skim goes to lps

ResolverReceiverTest

  • batch skips failed actions
  • cancels through forwarder
  • executes batches and donates in one report
  • only owner configures
  • rejects foreign workflow
  • rejects non forwarder
  • resolve before close is logged not applied
  • resolves through forwarder
  • supports interface

ShieldedPoolTest

  • bet after close rejected
  • both sides bet
  • cannot claim twice
  • deposit builds leaf on chain
  • deposit rejects unknown asset and zero
  • full story and solvency
  • nullifier cannot be reused
  • only named relayer can submit
  • only pool can add bets
  • private bet
  • redeem before resolution rejected
  • refund proof fails on resolved market
  • refund when cancelled
  • spend gas
  • tampered action to withdraw rejected
  • tampered fee rejected
  • tampered recipient rejected
  • tampered side rejected
  • unknown redeem kind rejected
  • unknown root rejected
  • win proof fails when other side won
  • winner claims hidden payout

TamperFuzzTest

  • any tampered spend input rejected
  • flipped proof byte rejected
  • untampered fixture still passes

VerifiersTest

  • redeem proof rejected by spend verifier
  • redeem proof verifies
  • redeem rejects tampered num
  • spend proof verifies
  • spend rejects tampered relayer
contracts/test/ ↗

Chainlink CRE workflow

15/15 passing

ESPN and F1 parsing, price rules after close, and report encoding for ResolverReceiver.

$ bun test · 0.2s

Show all 15 tests
  • builds the single-event scoreboard url
  • rejects anything else
  • home | draw | away for 3-sided markets
  • a draw refunds a 2-sided market
  • pending until completed; void statuses cancel
  • throws on a malformed response instead of guessing
  • yes only strictly above the threshold, and only after close
  • maps codes to receiver actions
  • encodes as resolver receiver.action[]
  • executes every ready batch
  • donates when fees are accrued or a resolve in this report accrues them
  • one cron handler on the configured schedule
  • the named winner, or the field
  • pending until the race is final; void statuses cancel
  • espn outcome routes racing refs
cre/resolver/main.test.ts ↗

Property tests: random inputs, hundreds to thousands of runs each

  • batch never fills worse than oracle boundfuzz · 256 runs
  • sandwich never profits either directionfuzz · 1,000 runs
  • payouts never exceed potfuzz · 256 runs
  • plain swap is untouchedfuzz · 256 runs
  • sandwich never pays in one blockfuzz · 256 runs
  • shield on every swap shapefuzz · 1,000 runs
  • any tampered spend input rejectedfuzz · 256 runs
  • flipped proof byte rejectedfuzz · 256 runs
  • no junk proof acceptedinvariant · 32 runs
  • one leaf per depositinvariant · 32 runs
  • pool holds every depositinvariant · 32 runs

Check it yourself

Everything here is open source. One command reruns every suite and the Lean build, and rewrites the data this page shows.

terminal
# clone
git clone https://github.com/AshiqAhamed17/quietodds && cd quietodds && npm install

# the proofs (downloads prebuilt Mathlib, then checks every step)
cd lean && lake exe cache get && lake build && cd ..

# every suite + this page's data
npm run verify-report -w scripts

nargo 1.0.0-beta.25 · forge 1.7.1 · lean4:v4.35.0 · bun 1.3.14