aere-research/formal-consensus/migrator_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

206 lines
11 KiB
Python

#!/usr/bin/env python3
# -----------------------------------------------------------------------------
# migrator_smt.py
#
# SMT proof (z3) of ONLY-DESTINATION (no third-party leakage, no custody) +
# ATOMICITY (all-or-nothing, no partial move, no stranded native) for
# AereAccountMigrator, the no-custody classical-EOA -> post-quantum-account sweep.
#
# Contract: contracts/contracts/pqc/AereAccountMigrator.sol
#
# migrate() moves a set of ERC-20 balances (and optionally native AERE) from the
# caller straight to a caller-specified `destination`, in one hop, holding no
# custody. The safety guarantees are:
# ONLY-DESTINATION : every asset moved lands at `destination` and nowhere else;
# the migrator is never the `to`, so it never skims or holds.
# ATOMICITY : a single failing transfer reverts the WHOLE call (SafeERC20
# reverts), so a migration is all-or-nothing and can never
# leave a holder with a partially drained old account.
# NO STRANDED VALUE: moveNative=false with msg.value!=0 reverts (StrayNative), so
# native can never be stuck in the migrator (there is no
# withdraw/sweep function).
#
# We model the conservation / partial-move / native-strand cores in decidable
# integer + boolean arithmetic and prove each property by asserting its NEGATION
# and showing z3 returns UNSAT. Each NEG-CTRL removes exactly one guard and shows
# the corresponding leak/partial/strand becomes satisfiable (SAT). Amounts are
# Ints; addresses are Int identities. This checks the DESIGN-level logic, NOT the
# EVM bytecode.
#
# Guards enforced by _migrate() (all hold or it reverts, no state change):
# H1 destination != 0 (ZeroDestination)
# H2 tokens.length == amounts.length (LengthMismatch)
# H3 each token != 0, each amount != 0 (ZeroToken / ZeroAmount)
# H4 moveNative => msg.value != 0 (ZeroNative)
# H5 !moveNative => msg.value == 0 (StrayNative) <-- no-strand
# H6 every ERC-20 leg: safeTransferFrom(caller, destination, amount) (to==destination)
# H7 native leg: call{value: msg.value}(destination) (to==destination), reverts on !ok
# -----------------------------------------------------------------------------
from z3 import Int, Bool, Solver, And, Or, Not, If, Sum, sat, unsat
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] for d in m.decls()})
return ok
print("### AereAccountMigrator -- ONLY-DESTINATION (no leak / no custody) + ATOMICITY\n")
N = 3 # model a migration of N ERC-20 legs (the argument is quantified structurally)
# ---- P1 ONLY-DESTINATION: every leg's transfer target is `destination`, which is
# non-zero (H1). Negation: some leg lands at an address != destination (a third
# party). In the contract the `to` is literally `destination` for every leg, so
# model to_i as a free var and let H6 bind it; UNSAT under the binding. -------
s = Solver()
destination = Int('destination')
to = [Int(f'to_{i}') for i in range(N)]
s.add(destination != 0) # H1
for i in range(N):
s.add(to[i] == destination) # H6: safeTransferFrom(..., destination, ...)
thirdParty = Int('thirdParty')
s.add(thirdParty != destination)
# NEGATION: some leg lands at the third party.
s.add(Or([to[i] == thirdParty for i in range(N)]))
check("P1 every ERC-20 leg delivers ONLY to `destination` (no third-party target)", s)
# ---- P2 NO CUSTODY / CONSERVATION: for each token the migrator is never `to`, so
# amount pulled from caller == amount delivered to destination, and the
# migrator's balance delta is exactly 0. Negation: migrator retains > 0. -----
s = Solver()
amount = [Int(f'amount_{i}') for i in range(N)]
pulledFromCaller = [Int(f'pull_{i}') for i in range(N)]
deliveredToDest = [Int(f'deliv_{i}') for i in range(N)]
for i in range(N):
s.add(amount[i] >= 1) # H3 (each amount != 0)
s.add(pulledFromCaller[i] == amount[i]) # transferFrom moves `amount`
s.add(deliveredToDest[i] == amount[i]) # ...straight to destination
migratorDelta = Sum([pulledFromCaller[i] - deliveredToDest[i] for i in range(N)])
s.add(migratorDelta != 0) # NEGATION: migrator holds custody
check("P2 no custody: sum(pulled) == sum(delivered), migrator ERC-20 delta == 0", s)
# ---- P3 ATOMICITY (all-or-nothing): a single failing leg reverts the WHOLE call,
# so either every leg's move is committed or none is. Model whole-tx revert:
# overallOk = AND(leg_ok_i); committed_i = amount_i iff overallOk else 0.
# Negation: a PARTIAL move -- some token committed while another (with a real
# amount) is not. Under whole-tx semantics, UNSAT. ---------------------------
s = Solver()
leg_ok = [Bool(f'leg_ok_{i}') for i in range(N)]
amount = [Int(f'amount_{i}') for i in range(N)]
overallOk = And([leg_ok[i] for i in range(N)]) # SafeERC20 reverts whole tx on any fail
committed = [If(And(overallOk, amount[i] >= 1), amount[i], 0) for i in range(N)]
for i in range(N):
s.add(amount[i] >= 1)
# NEGATION: partial move -- some leg moved (>0) while some other leg did NOT (==0).
s.add(Or([committed[i] > 0 for i in range(N)]))
s.add(Or([committed[j] == 0 for j in range(N)]))
check("P3 atomicity: no partial move -- all legs commit or none (whole-tx revert)", s)
# ---- P4 NO STRANDED NATIVE: with moveNative=false, H5 forces msg.value==0, so the
# migrator's native balance delta is 0; with moveNative=true the full msg.value
# is forwarded to destination (delta 0). Either way migrator native delta == 0.
# Negation: the migrator retains native. UNSAT under H4/H5/H7. --------------
s = Solver()
moveNative = Bool('moveNative')
msgValue = Int('msgValue')
s.add(msgValue >= 0)
# H4/H5: moveNative => value!=0 ; !moveNative => value==0.
s.add(If(moveNative, msgValue >= 1, msgValue == 0))
# H7: when moveNative, forward FULL msgValue to destination (retained = 0);
# when !moveNative, msgValue is 0 so retained = 0 as well.
migratorNativeRetained = If(moveNative, msgValue - msgValue, msgValue)
s.add(migratorNativeRetained != 0) # NEGATION
check("P4 no stranded native: migrator native delta == 0 (StrayNative + full forward)", s)
# ---- P5 DERIVED-DESTINATION binding (migrateToPqcAccount): destination is the
# factory's predictAddress(falconPubKey, salt); if expectedDestination != 0 the
# derived address MUST equal it. Negation: a caller who pinned an expected
# address gets a DIFFERENT destination. UNSAT under the AddressMismatch guard.
s = Solver()
derived, expected = Int('derived'), Int('expected')
s.add(expected != 0) # caller pinned an address
s.add(Or(expected == 0, expected == derived)) # AddressMismatch guard
s.add(expected != derived) # NEGATION
check("P5 migrateToPqcAccount honors a pinned expectedDestination (AddressMismatch)", s)
# ============================ NEGATIVE CONTROLS ==============================
# ---- NEG-CTRL 1 (skim to a third party): a BUGGY conduit that diverts a fee f>0 of
# one leg to a feeCollector (destination gets amount-f). Then delivered != pulled
# and the third party receives funds. z3 finds the leak. -------------------
s = Solver()
amount0, fee = Int('amount0'), Int('fee')
destGets, feeCollectorGets = Int('destGets'), Int('feeCollectorGets')
s.add(amount0 >= 2, fee >= 1, fee < amount0)
s.add(destGets == amount0 - fee) # BUG: skim a fee...
s.add(feeCollectorGets == fee) # ...to a third party
s.add(destGets != amount0) # destination is short-changed
check("BUGGY skim (fee to a third party) CAN leak funds off `destination`", s,
expect_unsat=False, kind="NEG-CTRL")
# ---- NEG-CTRL 2 (per-leg try/catch instead of whole-tx revert): a BUGGY migrator
# that CONTINUES past a failed leg commits a PARTIAL move (leg 0 moves, leg 1
# fails and is skipped). z3 finds the partial state. -----------------------
s = Solver()
leg_ok = [Bool(f'leg_ok_{i}') for i in range(N)]
amount = [Int(f'amount_{i}') for i in range(N)]
committed = [If(And(leg_ok[i], amount[i] >= 1), amount[i], 0) for i in range(N)] # BUG: per-leg commit
for i in range(N):
s.add(amount[i] >= 1)
s.add(leg_ok[0] == True, leg_ok[1] == False) # leg 1 fails but tx does NOT revert
s.add(committed[0] > 0, committed[1] == 0) # partial move realized
check("BUGGY per-leg-catch migrator CAN leave a PARTIAL move (old account half-drained)", s,
expect_unsat=False, kind="NEG-CTRL")
# ---- NEG-CTRL 3 (no StrayNative guard): a BUGGY migrator that accepts msg.value with
# moveNative=false leaves that value stuck in the contract forever (no sweep). --
s = Solver()
msgValue = Int('msgValue')
s.add(msgValue >= 1) # value sent...
# BUG: no H5 guard, moveNative is false, native is NOT forwarded -> retained in migrator.
migratorNativeRetained = msgValue
s.add(migratorNativeRetained > 0) # stranded, unrecoverable
check("BUGGY no-StrayNative migrator CAN strand native value (unrecoverable, no sweep)", s,
expect_unsat=False, kind="NEG-CTRL")
# ---- NEG-CTRL 4 (zero destination allowed): without H1 a migration could sweep to
# address(0), burning the holder's assets. z3 finds it. -------------------
s = Solver()
destination = Int('destination')
# BUG: no ZeroDestination guard.
s.add(destination == 0)
check("BUGGY no-ZeroDestination migrator CAN sweep to address(0) (burn)", 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()
if allok:
print("AereAccountMigrator safety:")
print(" PROVED -- every ERC-20 leg delivers ONLY to the caller's non-zero `destination`")
print(" (P1), the migrator takes no custody (pulled == delivered, delta 0, P2), a migration")
print(" is all-or-nothing with no partial move under whole-tx revert (P3), no native can be")
print(" stranded (P4), and a pinned expectedDestination is honored (P5). Four NEG-CTRLs fire:")
print(" a fee-skim, a per-leg try/catch, a missing StrayNative guard, and a missing")
print(" ZeroDestination guard each reproduce a real leak / partial-move / strand / burn.")
print(" [VERIFY] Whole-tx atomicity rests on SafeERC20 reverting on a false/failing ERC-20")
print(" and on nonReentrant; that is an EVM-revert property asserted structurally, and P3")
print(" models its consequence (all-or-nothing) rather than the opcode semantics.")
print(" BOUNDARY: this checks the DESIGN-level logic, not the compiled EVM bytecode.")
else:
print(" NOT fully established (see FAILED / unexpected CEX above).")
import sys
sys.exit(0 if allok else 1)