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.
216 lines
13 KiB
Python
216 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
# -----------------------------------------------------------------------------
|
|
# destinationsettler_smt.py
|
|
#
|
|
# SMT proof (z3) of NO-ARBITRARY-RECIPIENT + AT-MOST-ONCE (no duplicate fill) +
|
|
# OUTPUT-MATCH for AereDestinationSettler, the ERC-7683 filling-side settler, with
|
|
# load-bearing NEGATIVE CONTROLS for each guard.
|
|
#
|
|
# Contract: contracts/contracts/intents/AereDestinationSettler.sol
|
|
#
|
|
# The settler's fill() delivers an order's promised output on AERE and records it
|
|
# so the origin chain can later repay the solver. The safety-critical guarantees
|
|
# (fail-closed) are: a fill can only deliver to the order's DECLARED recipient
|
|
# (never an attacker address), a duplicate fill is impossible (a solver is repaid
|
|
# at most once per intent), and an output that does not satisfy the declared leg
|
|
# (wrong token / wrong recipient / short amount) is rejected.
|
|
#
|
|
# We model fill()'s guards as first-order constraints over the decoded fields and
|
|
# prove the properties by asserting each property's NEGATION under the guards and
|
|
# showing z3 returns UNSAT (no counterexample). Each NEG-CTRL removes exactly one
|
|
# guard and shows the corresponding attack becomes satisfiable (SAT).
|
|
#
|
|
# Addresses / token ids are modelled as Ints (identity only; the proof needs
|
|
# equality/disequality, not byte layout). This checks the DESIGN-level guard
|
|
# logic, NOT the EVM bytecode.
|
|
#
|
|
# Decoded fields (from fill()):
|
|
# originData -> (declOrderId, declOutputToken, declOutputAmount, declRecipient)
|
|
# fillerData -> (deliveredToken, deliveredAmount, deliveredRecipient, repayAddr)
|
|
# orderId = the standard's canonical id argument
|
|
#
|
|
# Guards enforced by fill() (all must hold or it reverts, no state change):
|
|
# G1 declOrderId == orderId (OrderIdMismatch)
|
|
# G2 declRecipient != 0 (ZeroRecipient)
|
|
# G3 declOutputToken != 0 (ZeroAddress)
|
|
# G4 declOutputAmount != 0 (ZeroAmount)
|
|
# G5 fills[orderId].filler == 0 (unfilled) (AlreadyFilled)
|
|
# G6 deliveredToken == declOutputToken AND
|
|
# deliveredRecipient == declRecipient AND
|
|
# deliveredAmount >= declOutputAmount (OutputMismatch)
|
|
# On success: record fills[orderId], then safeTransferFrom(solver -> deliveredRecipient).
|
|
# -----------------------------------------------------------------------------
|
|
from z3 import Int, Bool, Solver, And, Or, Not, Implies, 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
|
|
|
|
def guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller,
|
|
include_g1=True, include_g2=True, include_g6=True):
|
|
"""Assert fill()'s guards. Flags let a NEG-CTRL drop exactly one guard."""
|
|
if include_g1:
|
|
s.add(declOrderId == orderId) # G1
|
|
if include_g2:
|
|
s.add(declRecipient != 0) # G2 (ZeroRecipient)
|
|
s.add(declOutputToken != 0) # G3
|
|
s.add(declOutputAmount >= 1) # G4
|
|
s.add(alreadyFiller == 0) # G5 (unfilled)
|
|
if include_g6:
|
|
s.add(deliveredToken == declOutputToken) # G6a
|
|
s.add(deliveredRecipient == declRecipient) # G6b
|
|
s.add(deliveredAmount >= declOutputAmount) # G6c
|
|
|
|
print("### AereDestinationSettler -- NO-ARBITRARY-RECIPIENT + AT-MOST-ONCE + OUTPUT-MATCH\n")
|
|
|
|
# ---- P1 NO-ARBITRARY-RECIPIENT: a successful fill delivers ONLY to the order's
|
|
# declared recipient. Negation: fill succeeds yet the funds go to some other
|
|
# address `attacker` (attacker != declRecipient). Under the guards, UNSAT. --
|
|
s = Solver()
|
|
orderId, declOrderId = Int('orderId'), Int('declOrderId')
|
|
declRecipient, declOutputToken, declOutputAmount = Int('declRecipient'), Int('declOutputToken'), Int('declOutputAmount')
|
|
deliveredToken, deliveredRecipient, deliveredAmount = Int('deliveredToken'), Int('deliveredRecipient'), Int('deliveredAmount')
|
|
alreadyFiller, attacker = Int('alreadyFiller'), Int('attacker')
|
|
guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller)
|
|
# The tokens are transferred to `deliveredRecipient` (the safeTransferFrom `to`).
|
|
# NEGATION: that recipient is an attacker distinct from the order's declared one.
|
|
s.add(attacker != declRecipient, deliveredRecipient == attacker)
|
|
check("P1 fill can only deliver to the DECLARED recipient (no arbitrary recipient)", s)
|
|
|
|
# ---- P1b the transfer destination is provably the declared recipient AND non-zero
|
|
s = Solver()
|
|
declRecipient, deliveredRecipient = Int('declRecipient'), Int('deliveredRecipient')
|
|
declOrderId, orderId = Int('declOrderId'), Int('orderId')
|
|
declOutputToken, declOutputAmount = Int('declOutputToken'), Int('declOutputAmount')
|
|
deliveredToken, deliveredAmount, alreadyFiller = Int('deliveredToken'), Int('deliveredAmount'), Int('alreadyFiller')
|
|
guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller)
|
|
# NEGATION: the actual transfer target is zero OR differs from the declared recipient.
|
|
s.add(Or(deliveredRecipient == 0, deliveredRecipient != declRecipient))
|
|
check("P1b transfer target == declared recipient AND is non-zero", s)
|
|
|
|
# ---- P2 AT-MOST-ONCE: an order already filled cannot be filled again. The G5
|
|
# guard rejects fills[orderId].filler != 0. Model the double-fill directly:
|
|
# a second fill requires filler==0 but the slot is already set (filler!=0). -
|
|
s = Solver()
|
|
firstFiller = Int('firstFiller')
|
|
s.add(firstFiller != 0) # order already filled by someone (slot occupied)
|
|
s.add(firstFiller == 0) # ... yet the AlreadyFilled guard demands "unfilled"
|
|
check("P2 second fill of an already-filled order is guard-infeasible (AlreadyFilled)", s)
|
|
|
|
# ---- P2b the recorded slot is EXACTLY the arg orderId (G1 binds declOrderId to
|
|
# the id argument), so a fill can never be recorded under a different order's
|
|
# slot to dodge the duplicate check. Negation: recorded under a slot whose
|
|
# declared id differs from the arg. UNSAT under G1. -----------------------
|
|
s = Solver()
|
|
orderId, declOrderId = Int('orderId'), Int('declOrderId')
|
|
s.add(declOrderId == orderId) # G1
|
|
s.add(declOrderId != orderId) # NEGATION
|
|
check("P2b fill is bound to the arg orderId (no cross-slot record, G1)", s)
|
|
|
|
# ---- P3 OUTPUT-MATCH: a recorded fill satisfies the declared output exactly on
|
|
# token+recipient and is >= on amount. Negation: a fill succeeds yet delivers
|
|
# the wrong token OR short amount. Under G6, UNSAT. -----------------------
|
|
s = Solver()
|
|
orderId, declOrderId = Int('orderId'), Int('declOrderId')
|
|
declRecipient, declOutputToken, declOutputAmount = Int('declRecipient'), Int('declOutputToken'), Int('declOutputAmount')
|
|
deliveredToken, deliveredRecipient, deliveredAmount = Int('deliveredToken'), Int('deliveredRecipient'), Int('deliveredAmount')
|
|
alreadyFiller = Int('alreadyFiller')
|
|
guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller)
|
|
# NEGATION: wrong token OR under-delivered amount slipped through.
|
|
s.add(Or(deliveredToken != declOutputToken, deliveredAmount < declOutputAmount))
|
|
check("P3 output-mismatch (wrong token OR short amount) is rejected", s)
|
|
|
|
# ============================ NEGATIVE CONTROLS ==============================
|
|
|
|
# ---- NEG-CTRL 1 (recipient guard G6b removed): without deliveredRecipient ==
|
|
# declRecipient, a solver can deliver the output to an arbitrary attacker.
|
|
s = Solver()
|
|
orderId, declOrderId = Int('orderId'), Int('declOrderId')
|
|
declRecipient, declOutputToken, declOutputAmount = Int('declRecipient'), Int('declOutputToken'), Int('declOutputAmount')
|
|
deliveredToken, deliveredRecipient, deliveredAmount = Int('deliveredToken'), Int('deliveredRecipient'), Int('deliveredAmount')
|
|
alreadyFiller, attacker = Int('alreadyFiller'), Int('attacker')
|
|
guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller,
|
|
include_g6=False) # drop the whole output-match block...
|
|
s.add(deliveredToken == declOutputToken, deliveredAmount >= declOutputAmount) # ...keep token+amount
|
|
s.add(attacker != declRecipient, deliveredRecipient == attacker) # but NOT recipient
|
|
check("no-recipient-check settler CAN deliver to an arbitrary attacker", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- NEG-CTRL 2 (duplicate guard G5 removed): without AlreadyFilled, a second
|
|
# fill of the same order succeeds -> the solver is repaid twice per intent. -
|
|
s = Solver()
|
|
firstFiller, secondFiller = Int('firstFiller'), Int('secondFiller')
|
|
s.add(firstFiller != 0, secondFiller != 0) # order already filled; a 2nd fill also runs
|
|
# No G5 guard tying the 2nd fill to "unfilled" -> both fills are recorded.
|
|
check("no-AlreadyFilled settler CAN double-fill one order (double repayment)", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- NEG-CTRL 3 (zero-recipient guard G2 removed): without ZeroRecipient, an
|
|
# order can be "delivered" to address(0) (funds burned / undeliverable). ----
|
|
s = Solver()
|
|
orderId, declOrderId = Int('orderId'), Int('declOrderId')
|
|
declRecipient, declOutputToken, declOutputAmount = Int('declRecipient'), Int('declOutputToken'), Int('declOutputAmount')
|
|
deliveredToken, deliveredRecipient, deliveredAmount = Int('deliveredToken'), Int('deliveredRecipient'), Int('deliveredAmount')
|
|
alreadyFiller = Int('alreadyFiller')
|
|
guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller,
|
|
include_g2=False) # drop ZeroRecipient
|
|
s.add(declRecipient == 0, deliveredRecipient == 0)
|
|
check("no-ZeroRecipient settler CAN record a delivery to address(0)", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- NEG-CTRL 4 (output-match guard G6 removed): without OutputMismatch, a solver
|
|
# delivers a WORTHLESS/wrong token yet records a valid fill (still repaid). --
|
|
s = Solver()
|
|
orderId, declOrderId = Int('orderId'), Int('declOrderId')
|
|
declRecipient, declOutputToken, declOutputAmount = Int('declRecipient'), Int('declOutputToken'), Int('declOutputAmount')
|
|
deliveredToken, deliveredRecipient, deliveredAmount = Int('deliveredToken'), Int('deliveredRecipient'), Int('deliveredAmount')
|
|
alreadyFiller = Int('alreadyFiller')
|
|
guards(s, orderId, declOrderId, declRecipient, declOutputToken, declOutputAmount,
|
|
deliveredToken, deliveredRecipient, deliveredAmount, alreadyFiller,
|
|
include_g6=False)
|
|
s.add(deliveredToken != declOutputToken) # wrong token delivered, recorded anyway
|
|
check("no-OutputMismatch settler CAN record a wrong-token delivery", 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("AereDestinationSettler fill() safety:")
|
|
print(" PROVED -- under the four fail-closed guards, a fill can deliver ONLY to the order's")
|
|
print(" declared (non-zero) recipient (P1/P1b), is bound to the arg orderId and can be")
|
|
print(" recorded at most once (P2/P2b), and an output that mismatches the declared token or")
|
|
print(" is short is rejected (P3). Four NEG-CTRLs fire: dropping the recipient-match, the")
|
|
print(" AlreadyFilled, the ZeroRecipient, or the output-match guard each opens a real attack")
|
|
print(" (arbitrary recipient / double repayment / burn-to-zero / wrong-token repayment).")
|
|
print(" [VERIFY] ATOMICITY of the delivery (effects-before-interaction record, then a single")
|
|
print(" SafeERC20 pull solver->recipient that reverts the WHOLE tx on failure, under")
|
|
print(" nonReentrant) is an EVM-revert/whole-tx property, asserted structurally not by this")
|
|
print(" arithmetic model. Repayment on the ORIGIN chain is gated by the validator-set-anchored")
|
|
print(" finality+inclusion proof and is out of scope here [MEASURE].")
|
|
print(" BOUNDARY: this checks the DESIGN-level guard 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)
|