Aere Network public source. Everything here can be checked against the live chain (chain id 2800, https://rpc.aere.network). Scope note, stated up front rather than buried: consensus on chain 2800 is classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at the signature, precompile, account and transport layers. Nothing here makes the consensus post-quantum, and no document in it should be read as claiming so.
187 lines
9.9 KiB
Python
187 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
# -----------------------------------------------------------------------------
|
|
# spokepool_smt.py
|
|
#
|
|
# SMT proof (z3) of NO-THEFT-OF-PRINCIPAL for AereSpokePool (the corrected V2
|
|
# with the ROUND-3 `totalLocked` accounting fix), a NEGATIVE CONTROL showing the
|
|
# pre-V2 sweepResidual bug is a real theft vector, and one honestly-surfaced
|
|
# COMPOSITION FINDING (sweepResidual does not reserve solver bonds).
|
|
#
|
|
# Contract: contracts/contracts/intents/AereSpokePool.sol
|
|
#
|
|
# We model the per-input-token accounting as an inductive transition system and
|
|
# prove the safety invariant is INDUCTIVE (base case + every operation preserves
|
|
# it). Each Solidity `require`/revert is a guard; Solidity 0.8 checked
|
|
# arithmetic is modeled as "underflow reverts (no state change)".
|
|
#
|
|
# Per input token T:
|
|
# bal = IERC20(T).balanceOf(address(this)) -- real token balance
|
|
# lock = totalLocked[T] -- sum of unsettled solverPortions (USER PRINCIPAL)
|
|
#
|
|
# PRINCIPAL SAFETY INVARIANT (the task's target -- no theft of principal):
|
|
# INV_P := bal >= lock AND lock >= 0
|
|
#
|
|
# INV_P => every open user order's locked principal is ALWAYS fully backed by
|
|
# the contract balance: no settle / sweepResidual can drive balance below what
|
|
# users are collectively owed, so settle() can always pay out.
|
|
#
|
|
# Method: for each op OP, check INV_P(pre) AND guards AND post=OP(pre) AND
|
|
# NOT INV_P(post) is UNSAT. UNSAT => OP cannot break the invariant.
|
|
# Unbounded Ints = the accounting/design abstraction (matches solc SMTChecker's
|
|
# default int model). This checks the DESIGN math, NOT the EVM bytecode.
|
|
# -----------------------------------------------------------------------------
|
|
from z3 import Int, Solver, And, Or, Not, If, sat, unsat
|
|
|
|
FEE_BPS = 50 # PROTOCOL_FEE_BPS (immutable)
|
|
BPS_DEN = 10_000
|
|
|
|
def INV_P(bal, lock):
|
|
return And(bal >= lock, lock >= 0)
|
|
|
|
results = []
|
|
def check(name, s, expect_unsat=True, kind="PROOF"):
|
|
r = s.check()
|
|
if expect_unsat:
|
|
ok = (r == unsat); tag = "PROVED" if ok else "FAILED"
|
|
else:
|
|
ok = (r == sat); tag = "CEX-FOUND" if ok else "FAILED"
|
|
results.append((name, tag, ok, kind))
|
|
print(f"[{tag:9}] ({kind}) {name}: z3={r} (expected {'unsat' if expect_unsat else 'sat'})")
|
|
if r == sat:
|
|
m = s.model()
|
|
print(" witness:", {str(d): m[d].as_long() for d in m.decls()})
|
|
return ok
|
|
|
|
print("### AereSpokePool -- no-theft-of-PRINCIPAL INV_P: bal >= totalLocked\n")
|
|
|
|
# ---- BASE CASE: fresh contract ------------------------------------------------
|
|
s = Solver(); s.add(Not(INV_P(0, 0)))
|
|
check("base case (empty contract) satisfies INV_P", s)
|
|
|
|
# ---- open(inputAmount): pull inputAmount, route fee, lock solverPortion --------
|
|
# solverPortion sp = inputAmount - fee. Balance rises by AT LEAST sp (fee either
|
|
# leaves to sink OR stays as residual >= 0). lock rises by sp.
|
|
s = Solver()
|
|
bal, lock, inp, feeStays = Int('bal'), Int('lock'), Int('inputAmount'), Int('feeStays')
|
|
fee = (inp * FEE_BPS) / BPS_DEN
|
|
sp = inp - fee
|
|
s.add(INV_P(bal, lock), inp >= 1, Or(feeStays == 0, feeStays == fee))
|
|
s.add(Not(INV_P(bal + sp + feeStays, lock + sp)))
|
|
check("open() preserves INV_P", s)
|
|
|
|
# ---- settle(order a): pays a = o.inputAmount to solver, exactly once -----------
|
|
# The order's principal a was added to lock at open and is still counted (a<=lock).
|
|
# settle: lock -= a ; bal -= a (checked-sub => a<=lock).
|
|
s = Solver()
|
|
bal, lock, a = Int('bal'), Int('lock'), Int('orderAmount')
|
|
s.add(INV_P(bal, lock), a >= 1, a <= lock)
|
|
s.add(Not(INV_P(bal - a, lock - a)))
|
|
check("settle() preserves INV_P (single payout)", s)
|
|
|
|
# ---- double-settle blocked: over-withdraw beyond locked amt is guard-infeasible-
|
|
s = Solver()
|
|
lock2, a = Int('lock2'), Int('a')
|
|
s.add(lock2 >= 0, a >= 1, a > lock2, a <= lock2) # settled-flag/checked-sub guard
|
|
check("over-withdraw beyond locked amount is guard-infeasible", s)
|
|
|
|
# ---- sweepResidual(token) V2 (FIXED): reserved = max(lock, floor) -------------
|
|
s = Solver()
|
|
bal, lock, floor = Int('bal'), Int('lock'), Int('sweepFloor')
|
|
s.add(INV_P(bal, lock), floor >= 0)
|
|
reserved = If(lock > floor, lock, floor) # max(lock,floor) >= lock
|
|
s.add(bal > reserved) # else revert ZeroAmount (no state change)
|
|
s.add(Not(INV_P(bal - (bal - reserved), lock))) # bal2 == reserved
|
|
check("sweepResidual() V2 (max(lock,floor)) preserves INV_P", s)
|
|
|
|
# ---- NEGATIVE CONTROL: PRE-V2 sweepResidual reserved ONLY sweepFloor -----------
|
|
# Ignores totalLocked. If Foundation mis-sets floor < lock, an attacker sweeps
|
|
# bal-floor, draining locked user principal. z3 must find a THEFT state.
|
|
s = Solver()
|
|
bal, lock, floor = Int('bal'), Int('lock'), Int('sweepFloor')
|
|
s.add(INV_P(bal, lock), floor >= 0, bal > floor) # buggy reserve = floor only
|
|
s.add(Not(INV_P(bal - (bal - floor), lock))) # bal2 == floor, can be < lock
|
|
check("PRE-V2 sweepResidual (floor-only) CAN steal principal", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- NEGATIVE CONTROL: the OLD (pre-fix) composition WAS a real under-backing ---
|
|
# sweepResidual reserved totalLocked but NOT solver bonds. With WAERE as BOTH a
|
|
# bridge INPUT token and the bond token: bal_W = principal(lock) + bonds; the old
|
|
# sweep reserved only lock -> swept the bonds; a later slashSolverBond then drove
|
|
# bal_W = lock - amt < lock. z3 finds this theft state (proves the bug was real).
|
|
s = Solver()
|
|
lock, bonds, amt = Int('lock'), Int('bonds'), Int('slashAmt')
|
|
s.add(lock >= 1, bonds >= 1, amt >= 1, amt <= bonds)
|
|
bal_after_sweep_OLD = If(lock > 0, lock, 0) # OLD sweep: bonds gone
|
|
s.add(Not(INV_P(bal_after_sweep_OLD - amt, lock))) # principal under-backed
|
|
check("PRE-FIX composition (sweep ignores bonds)+slash CAN under-back principal", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- BOND-ACCOUNTING FIX: sweepResidual reserves totalBond ----------------------
|
|
# Fix: track totalBond[T], reserve max(lock+bond, floor) in sweepResidual, and
|
|
# keep it in lockstep (depositSolverBond +=, slashSolverBond -=). Prove the
|
|
# STRONGER invariant INV_PB := bal >= lock + bond is INDUCTIVE (subsumes INV_P and
|
|
# closes the finding). Each op: INV_PB(pre) AND guards AND post => INV_PB(post).
|
|
def INV_PB(bal, lock, bond):
|
|
return And(bal >= lock + bond, lock >= 0, bond >= 0)
|
|
|
|
s = Solver(); bal, lock, bond, amt = Int('bal'), Int('lock'), Int('bond'), Int('amt')
|
|
s.add(INV_PB(bal, lock, bond), amt >= 1); s.add(Not(INV_PB(bal + amt, lock, bond + amt)))
|
|
check("FIX depositSolverBond preserves INV_PB (bal>=lock+bond)", s)
|
|
|
|
s = Solver(); bal, lock, bond, amt = Int('bal'), Int('lock'), Int('bond'), Int('amt')
|
|
s.add(INV_PB(bal, lock, bond), amt >= 1, amt <= bond); s.add(Not(INV_PB(bal - amt, lock, bond - amt)))
|
|
check("FIX slashSolverBond (bond-=amt, bal-=amt) preserves INV_PB", s)
|
|
|
|
s = Solver(); bal, lock, bond, floor = Int('bal'), Int('lock'), Int('bond'), Int('floor')
|
|
s.add(INV_PB(bal, lock, bond), floor >= 0)
|
|
reservedF = If(lock + bond > floor, lock + bond, floor)
|
|
s.add(bal > reservedF); s.add(Not(INV_PB(bal - (bal - reservedF), lock, bond)))
|
|
check("FIX sweepResidual (reserve max(lock+bond,floor)) preserves INV_PB", s)
|
|
|
|
s = Solver(); bal, lock, bond, inp, feeStays = Int('bal'), Int('lock'), Int('bond'), Int('inputAmount'), Int('feeStays')
|
|
feeO = (inp * FEE_BPS) / BPS_DEN; spO = inp - feeO
|
|
s.add(INV_PB(bal, lock, bond), inp >= 1, Or(feeStays == 0, feeStays == feeO))
|
|
s.add(Not(INV_PB(bal + spO + feeStays, lock + spO, bond)))
|
|
check("FIX open preserves INV_PB (bond unchanged)", s)
|
|
|
|
s = Solver(); bal, lock, bond, a = Int('bal'), Int('lock'), Int('bond'), Int('a')
|
|
s.add(INV_PB(bal, lock, bond), a >= 1, a <= lock); s.add(Not(INV_PB(bal - a, lock - a, bond)))
|
|
check("FIX settle preserves INV_PB (bond unchanged)", s)
|
|
|
|
# ---- REFUND FIX (adversarial-review-found HIGH, 2026-07-12): cancel(order a) -----
|
|
# returns the original user's principal a after fillDeadline when the order is
|
|
# unclaimed. Same accounting delta as settle: lock -= a ; bal -= a (Solidity
|
|
# checked-sub => a <= lock; the o.settled flag blocks double-cancel exactly as it
|
|
# blocks double-settle). Prove it preserves the stronger invariant INV_PB, i.e. the
|
|
# refund path cannot under-back any other user's principal or a solver bond.
|
|
s = Solver(); bal, lock, bond, a = Int('bal'), Int('lock'), Int('bond'), Int('a')
|
|
s.add(INV_PB(bal, lock, bond), a >= 1, a <= lock); s.add(Not(INV_PB(bal - a, lock - a, bond)))
|
|
check("FIX cancel/refund preserves INV_PB (lock-=a, bal-=a, bond unchanged)", s)
|
|
|
|
# The exact WAERE-as-input composition is now SAFE: sweep reserves lock+bond (sweeps
|
|
# nothing), then slash bond-=amt, bal-=amt leaves bal = lock+bond-amt >= lock.
|
|
s = Solver(); lock, bonds, amt = Int('lock'), Int('bonds'), Int('slashAmt')
|
|
s.add(lock >= 1, bonds >= 1, amt >= 1, amt <= bonds)
|
|
bal_fixed = (lock + bonds) - amt # FIX: sweep took nothing, slash -amt
|
|
s.add(Not(INV_P(bal_fixed, lock))) # want bal>=lock -> negation UNSAT
|
|
check("FIXED: reserve-bond sweep + slash keeps WAERE-input principal backed", s)
|
|
|
|
# ------------------------------------------------------------------------------
|
|
print("\n=== SUMMARY ===")
|
|
allok = True
|
|
for name, tag, ok, kind in results:
|
|
print(f" {tag:9} [{kind}] {name}")
|
|
allok = allok and ok
|
|
print()
|
|
print("NO-THEFT-OF-PRINCIPAL (INV_P: bal >= totalLocked) for corrected V2:",
|
|
"\n PROVED inductive -- base case + open/settle/sweepResidual-V2 all preserve it;"
|
|
"\n pre-V2 floor-only sweep reproduces the theft (control)." if allok else
|
|
"\n NOT fully established (see FAILED).")
|
|
print("BOND-ACCOUNTING FIX PROVED: totalBond[T] is now reserved by sweepResidual"
|
|
"\n (reserve = max(totalLocked+totalBond, floor)) and kept in lockstep by"
|
|
"\n depositSolverBond/slashSolverBond. The stronger invariant bal>=lock+bond is"
|
|
"\n inductive, and the exact WAERE-as-input composition is now UNSAT (safe)."
|
|
"\n The pre-fix composition is retained above as a NEG-CTRL (it WAS a real bug).")
|
|
import sys
|
|
sys.exit(0 if allok else 1)
|