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.
172 lines
8.4 KiB
Python
172 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
# -----------------------------------------------------------------------------
|
|
# settlementhub_smt.py
|
|
#
|
|
# SMT proof (z3) of the SOLVENCY / NO-THEFT invariant for AereSettlementHubV2,
|
|
# the corrected settlement venue that fixes the V1 F-SWEEP griefing bug by
|
|
# tracking a per-asset `committedLiabilities` accumulator.
|
|
#
|
|
# Contract: contracts/contracts/settlement/AereSettlementHubV2.sol
|
|
#
|
|
# We model the per-asset accounting as an inductive transition system and prove
|
|
# the safety invariant is INDUCTIVE (base case + every op preserves it). Each
|
|
# Solidity `require`/revert is a guard; Solidity 0.8 checked arithmetic is
|
|
# modelled as "underflow reverts (no state change)".
|
|
#
|
|
# Per asset T:
|
|
# bal = IERC20(T).balanceOf(address(this)) -- real token balance
|
|
# committed = committedLiabilities[T] -- sum of every OPEN intent
|
|
# remainder + every posted
|
|
# solver bond (what the hub OWES)
|
|
#
|
|
# SOLVENCY INVARIANT (task target -- residual sweep can never touch owed funds,
|
|
# and settle()/cancelIntent() can always pay out):
|
|
# INV := bal >= committed AND committed >= 0
|
|
#
|
|
# INV => residualOf(T) = bal - committed is exactly the un-owed surplus, so a
|
|
# permissionless sweepResidual can only ever move genuine surplus (fees / un-
|
|
# flushable slashed bonds), NEVER a live intent remainder or a posted bond.
|
|
#
|
|
# Method: for each op OP, check INV(pre) AND guards AND post=OP(pre) AND
|
|
# NOT INV(post) is UNSAT. UNSAT => OP cannot break the invariant.
|
|
# Unbounded Ints = the accounting/design abstraction. Checks the DESIGN math,
|
|
# NOT the EVM bytecode.
|
|
#
|
|
# ASSUMPTIONS (bound every PROVED below):
|
|
# A1. Standard ERC20: transferFrom credits exactly `amount`, transfer debits
|
|
# exactly `amount`. Fee-on-transfer / rebasing tokens are OUT OF SCOPE
|
|
# (they would break bal>=committed for ANY escrow and are excluded by the
|
|
# asset-listing policy).
|
|
# A2. SINK is the trusted immutable AereSink; it can pull at most the approved
|
|
# amount. Its `flush` either succeeds (pulls `amount`) or reverts (pulls 0).
|
|
# A3. committed == (sum of open-intent remainders) + (sum of posted bonds) is
|
|
# the intended reading; the per-op guards below (amt<=committed portions)
|
|
# are exactly the Solidity checked-sub guards, which hold BECAUSE the item
|
|
# being removed is itself a summand of committed.
|
|
# -----------------------------------------------------------------------------
|
|
from z3 import Int, Solver, And, Or, Not, If, sat, unsat
|
|
|
|
FEE_BPS = 50 # PROTOCOL_FEE_BPS (immutable)
|
|
BPS_DEN = 10_000
|
|
|
|
def INV(bal, committed):
|
|
return And(bal >= committed, committed >= 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("### AereSettlementHubV2 -- SOLVENCY INV: balanceOf(hub) >= committedLiabilities\n")
|
|
|
|
# ---- BASE CASE: fresh hub -----------------------------------------------------
|
|
s = Solver(); s.add(Not(INV(0, 0)))
|
|
check("base case (empty hub) satisfies INV", s)
|
|
|
|
# ---- deposit(amount): pull full amount, route fee (may stay if sink rejects),
|
|
# commit amountAfterFee. --------------------------------------------------
|
|
s = Solver()
|
|
bal, com, amount, feeStays = Int('bal'), Int('com'), Int('amount'), Int('feeStays')
|
|
fee = (amount * FEE_BPS) / BPS_DEN
|
|
aaf = amount - fee # amountAfterFee
|
|
# bal2 = bal + amount - (fee actually flushed). feeStays in {0, fee}: if the
|
|
# fee left to sink feeStays==0 (bal drops by fee); if sink rejected feeStays==fee.
|
|
s.add(INV(bal, com), amount >= 1, Or(feeStays == 0, feeStays == fee))
|
|
bal2 = bal + amount - fee + feeStays
|
|
com2 = com + aaf
|
|
s.add(Not(INV(bal2, com2)))
|
|
check("deposit() preserves INV (commit amountAfterFee)", s)
|
|
|
|
# ---- depositSolverBond(amount): pull amount, commit amount --------------------
|
|
s = Solver()
|
|
bal, com, amount = Int('bal'), Int('com'), Int('amount')
|
|
s.add(INV(bal, com), amount >= 1)
|
|
s.add(Not(INV(bal + amount, com + amount)))
|
|
check("depositSolverBond() preserves INV", s)
|
|
|
|
# ---- slashSolverBond(amount): committed-=amount; flush amount (may stay) ------
|
|
# Guard: bond>=amount and bond is a summand of committed => committed>=amount.
|
|
# sinkOk in {0(reject),1(accept)}: accept => bal-=amount; reject => bal unchanged.
|
|
s = Solver()
|
|
bal, com, bond, amount, sinkOk = Int('bal'), Int('com'), Int('bond'), Int('amount'), Int('sinkOk')
|
|
s.add(INV(bal, com), amount >= 1, bond >= amount, bond <= com, # bond is part of committed
|
|
Or(sinkOk == 0, sinkOk == 1))
|
|
bal2 = If(sinkOk == 1, bal - amount, bal)
|
|
com2 = com - amount
|
|
s.add(Not(INV(bal2, com2)))
|
|
check("slashSolverBond() preserves INV (sink accept OR reject)", s)
|
|
|
|
# ---- settle(intent): pay parked remainder amt to solver; uncommit it ---------
|
|
# Guard: amt is an open intent's remainder, a summand of committed => amt<=committed.
|
|
s = Solver()
|
|
bal, com, amt = Int('bal'), Int('com'), Int('amt')
|
|
s.add(INV(bal, com), amt >= 1, amt <= com)
|
|
s.add(Not(INV(bal - amt, com - amt)))
|
|
check("settle() preserves INV (pay solver, uncommit)", s)
|
|
|
|
# ---- cancelIntent(intent): refund parked remainder to depositor; uncommit ----
|
|
s = Solver()
|
|
bal, com, amt = Int('bal'), Int('com'), Int('amt')
|
|
s.add(INV(bal, com), amt >= 1, amt <= com)
|
|
s.add(Not(INV(bal - amt, com - amt)))
|
|
check("cancelIntent() preserves INV (refund depositor, uncommit)", s)
|
|
|
|
# ---- sweepResidual(amount): amount <= residualOf = max(bal-committed,0) -------
|
|
s = Solver()
|
|
bal, com, amount = Int('bal'), Int('com'), Int('amount')
|
|
residual = If(bal > com, bal - com, 0)
|
|
s.add(INV(bal, com), amount >= 1, amount <= residual) # else ExceedsResidual revert
|
|
s.add(Not(INV(bal - amount, com))) # committed unchanged
|
|
check("sweepResidual() V2 (bounded by residual) preserves INV", s)
|
|
|
|
# ---- NEG-CTRL 1: V1 permissionless sweep took an arbitrary amount<=bal, ignoring
|
|
# committed. This is the F-SWEEP griefing bug: an EOA sweeps a live intent's
|
|
# parked funds + posted bonds into the sink, bricking settle(). z3 finds it.
|
|
s = Solver()
|
|
bal, com, amount = Int('bal'), Int('com'), Int('amount')
|
|
s.add(INV(bal, com), com >= 1, amount >= 1, amount <= bal) # buggy bound: only bal
|
|
s.add(Not(INV(bal - amount, com))) # bal2 can drop below committed
|
|
check("PRE-V2 sweep (bound=balance, ignores committed) CAN steal owed funds", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- NEG-CTRL 2: a BUGGY deposit that commits the FULL amount (not amountAfterFee)
|
|
# while the fee is flushed to the sink. Then committed grows by `amount` but
|
|
# balance only by amountAfterFee -> hub is over-committed, cannot pay. Shows
|
|
# the accounting MUST use amountAfterFee. z3 finds the under-backing.
|
|
s = Solver()
|
|
bal, com, amount = Int('bal'), Int('com'), Int('amount')
|
|
fee = (amount * FEE_BPS) / BPS_DEN
|
|
s.add(INV(bal, com), amount >= 1, fee >= 1) # fee>0 needs amount>=200
|
|
bal2 = bal + amount - fee # fee flushed
|
|
com2 = com + amount # BUG: full amount committed
|
|
s.add(Not(INV(bal2, com2)))
|
|
check("BUGGY deposit (commit full amount, flush fee) CAN over-commit (under-back)", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ------------------------------------------------------------------------------
|
|
print("\n=== SUMMARY ===")
|
|
allok = True
|
|
for name, tag, ok, kind in results:
|
|
print(f" {tag:9} [{kind}] {name}")
|
|
allok = allok and ok
|
|
print()
|
|
print("SOLVENCY (INV: balanceOf(hub) >= committedLiabilities) for AereSettlementHubV2:")
|
|
if allok:
|
|
print(" PROVED inductive -- base case + deposit / depositSolverBond / slashSolverBond /")
|
|
print(" settle / cancelIntent / sweepResidual all preserve it. Two NEG-CTRLs confirm the")
|
|
print(" checks are load-bearing: the V1 unbounded sweep AND a full-amount-commit deposit")
|
|
print(" each reproduce a real under-backing counterexample.")
|
|
else:
|
|
print(" NOT fully established (see FAILED / unexpected CEX above).")
|
|
import sys
|
|
sys.exit(0 if allok else 1)
|