aere-research/formal-consensus/settlementhub_smt.py
Aere Network 4a0b48588c Initial public release
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.
2026-07-20 01:02:30 +03:00

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)