#!/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)