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.
275 lines
16 KiB
Python
275 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# -----------------------------------------------------------------------------
|
|
# qbft_locking_smt.py
|
|
#
|
|
# MACHINE-CHECKED (z3) model of IBFT 2.0 ROUND-CHANGE LOCKING as it maps onto the
|
|
# AERE second-client QBFT engine (audit Findings D/E, 2026-07-14). The abstract
|
|
# locking property is already established in qbft_safety_smt.py L3 (locking=True
|
|
# safe, locking=False broken). This model is DIFFERENT: it makes the two engine
|
|
# roles explicit variables so each result maps 1:1 onto the C# code that must
|
|
# change --
|
|
# * PROPOSER policy (Finding E): AereQbftLiveEngine.ProposeBlockAtRound builds a
|
|
# FRESH block at round>0. The fix must RE-PROPOSE the value of the highest
|
|
# prepared round carried in the 2f+1 round-change certificate.
|
|
# * ACCEPTOR policy (Finding D): the engine adopts a higher-round proposal
|
|
# without checking that it is justified, and its RoundChange carries no
|
|
# prepared certificate. The fix must (a) carry the prepared certificate and
|
|
# (b) refuse to PREPARE an unjustified round>0 proposal.
|
|
#
|
|
# The model answers three engine-specific questions the abstract L3 does not:
|
|
# P2 full fix (proposer re-proposes justified + all acceptors lock) => agreement
|
|
# PROVED (no two rounds commit different values).
|
|
# P3 Besu majority saves SAFETY even if the second client proposes FRESH: when
|
|
# every ACCEPTOR locks, a fresh conflicting round>0 proposal simply cannot
|
|
# collect a prepare-quorum -> agreement still holds (PROVED). => Finding E is,
|
|
# given Besu's acceptor locking, primarily a LIVENESS cost (a wasted round),
|
|
# not a live-mainnet safety hole.
|
|
# P4 the load-bearing one: if ONE acceptor does NOT lock (the second client's
|
|
# Finding-D gap) and its vote is on the critical path, a conflicting value CAN
|
|
# reach quorum across rounds (CEX). => the second client MUST enforce
|
|
# acceptor-side locking BEFORE it can be a producing validator.
|
|
# P1 full no-lock (neither role) => CEX (non-vacuity; matches L3 NEG-CTRL).
|
|
#
|
|
# Besu quorum quorum(N)=ceil(2N/3), fault bound f(N)=floor((N-1)/3).
|
|
# HONEST BOUNDARY: bounded per-N/round model checks of the locking combinatorics,
|
|
# not the C# bytecode; complements the adversarial soak. Chain 2800 consensus is
|
|
# classical ECDSA QBFT; live mainnet runs Besu (which enforces locking) only, so
|
|
# these findings gate the SECOND client's producing-validator swap, not the live
|
|
# chain.
|
|
# -----------------------------------------------------------------------------
|
|
from z3 import Int, Bool, Solver, And, Or, Not, Implies, If, Sum, sat, unsat
|
|
|
|
def quorum(n): return (2 * n + 2) // 3 # ceil(2N/3)
|
|
def faultbound(n): return (n - 1) // 3 # floor((N-1)/3)
|
|
|
|
NONE, A, B = 0, 1, 2
|
|
results = []
|
|
|
|
def check(name, s, expect_unsat=True, kind="PROOF"):
|
|
r = s.check()
|
|
ok = (r == unsat) if expect_unsat else (r == sat)
|
|
tag = ("PROVED" if ok else "FAILED") if expect_unsat else ("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'})")
|
|
return ok
|
|
|
|
|
|
def locking_model(N, qv, fv, R, proposer_justified, locks_pattern, reveal=None):
|
|
"""Bounded per-validator, per-round IBFT 2.0 locking model.
|
|
proposed[r] : value the round-r proposer proposes (A or B).
|
|
prep[i][r] : value validator i broadcasts PREPARE for at r (NONE/A/B).
|
|
comm[i][r] : value validator i broadcasts COMMIT for at r (NONE/A/B).
|
|
locks_pattern(i) : True if validator i enforces ACCEPTOR-side locking.
|
|
proposer_justified: True if the round>0 proposer re-proposes the highest prepared value.
|
|
reveal[r] : True if a round-r acceptor can SEE the justification for locking. The COMPLETE
|
|
signed-RoundChange-certificate acceptor always can (each RoundChange is
|
|
individually signed, so a Byzantine cannot hide the highest prepared value):
|
|
reveal defaults to all-True. The INCOMPLETE acceptor that reads the proposal's
|
|
OWN prepare list cannot, when a Byzantine round>0 proposer omits it: reveal[r]=False.
|
|
Returns (solver, committedA_list, committedB_list, prepared_val_list)."""
|
|
if reveal is None:
|
|
reveal = [True] * R
|
|
s = Solver()
|
|
honest = [Bool(f"honest_{i}") for i in range(N)]
|
|
proposed = [Int(f"proposed_{r}") for r in range(R)]
|
|
prep = [[Int(f"prep_{i}_{r}") for r in range(R)] for i in range(N)]
|
|
comm = [[Int(f"comm_{i}_{r}") for r in range(R)] for i in range(N)]
|
|
|
|
for r in range(R):
|
|
s.add(Or(proposed[r] == A, proposed[r] == B)) # a proposer always proposes some block
|
|
for i in range(N):
|
|
for r in range(R):
|
|
s.add(prep[i][r] >= NONE, prep[i][r] <= B)
|
|
s.add(comm[i][r] >= NONE, comm[i][r] <= B)
|
|
|
|
s.add(Sum([If(Not(honest[i]), 1, 0) for i in range(N)]) <= fv) # <= f Byzantine
|
|
|
|
def cnt(mat, r, v): return Sum([If(mat[i][r] == v, 1, 0) for i in range(N)])
|
|
preparedA = [cnt(prep, r, A) >= qv for r in range(R)]
|
|
preparedB = [cnt(prep, r, B) >= qv for r in range(R)]
|
|
prepared_val = [If(preparedA[r], A, If(preparedB[r], B, NONE)) for r in range(R)]
|
|
|
|
def highest_prior(r):
|
|
hp = NONE
|
|
for rp in range(r - 1, -1, -1):
|
|
hp = If(prepared_val[rp] != NONE, prepared_val[rp], hp)
|
|
return hp
|
|
|
|
# PROPOSER policy (Finding E): a justified proposer re-proposes the highest prepared value.
|
|
if proposer_justified:
|
|
for r in range(1, R):
|
|
hp = highest_prior(r)
|
|
s.add(Implies(hp != NONE, proposed[r] == hp))
|
|
|
|
for i in range(N):
|
|
for r in range(R):
|
|
# An HONEST validator only ever PREPAREs the value that was PROPOSED this round.
|
|
s.add(Implies(honest[i], Or(prep[i][r] == NONE, prep[i][r] == proposed[r])))
|
|
# ACCEPTOR-side locking (Finding D): an honest LOCKING validator refuses to prepare a
|
|
# round>0 proposal that is not the highest previously-prepared value.
|
|
if locks_pattern(i) and r > 0 and reveal[r]:
|
|
# The acceptor can only enforce locking when it can SEE the justification. The complete
|
|
# signed-certificate acceptor always can (reveal[r]=True); the incomplete proposal's-own-
|
|
# prepare-list acceptor cannot when a Byzantine proposer omits it (reveal[r]=False), and
|
|
# then this constraint is simply absent -> that honest node is free to prepare a conflict.
|
|
hp = highest_prior(r)
|
|
s.add(Implies(And(honest[i], hp != NONE, proposed[r] != hp),
|
|
prep[i][r] == NONE))
|
|
# (H-commit) honest commits v => it prepared v this round AND a prepare-quorum formed.
|
|
s.add(Implies(And(honest[i], comm[i][r] == A), And(prep[i][r] == A, preparedA[r])))
|
|
s.add(Implies(And(honest[i], comm[i][r] == B), And(prep[i][r] == B, preparedB[r])))
|
|
|
|
committedA = [cnt(comm, r, A) >= qv for r in range(R)]
|
|
committedB = [cnt(comm, r, B) >= qv for r in range(R)]
|
|
return s, committedA, committedB, prepared_val
|
|
|
|
|
|
print("### AERE QBFT IBFT 2.0 LOCKING -- engine-role model (Findings D/E)\n")
|
|
print(" quorum(N)=ceil(2N/3), f(N)=floor((N-1)/3): "
|
|
+ ", ".join(f"N={n}->q={quorum(n)},f={faultbound(n)}" for n in (4, 7)) + "\n")
|
|
|
|
ALL = lambda i: True # every validator locks
|
|
NONE_LOCKS = lambda i: False
|
|
|
|
print("=" * 78)
|
|
print("P1 NO LOCKING anywhere (proposer builds fresh + no acceptor locks) => CEX")
|
|
print("=" * 78)
|
|
for N in (4, 7):
|
|
R = 2
|
|
s, cA, cB, _ = locking_model(N, quorum(N), faultbound(N), R,
|
|
proposer_justified=False, locks_pattern=NONE_LOCKS)
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} R={R}: without any locking two rounds commit different values", s,
|
|
expect_unsat=False, kind="NEG-CONTROL")
|
|
|
|
print()
|
|
print("=" * 78)
|
|
print("P2 FULL FIX (justified proposer + all acceptors lock) => agreement PROVED")
|
|
print("=" * 78)
|
|
for N in (4, 7, 10):
|
|
R = 3
|
|
s, cA, cB, _ = locking_model(N, quorum(N), faultbound(N), R,
|
|
proposer_justified=True, locks_pattern=ALL)
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} R={R}: justified proposer + locking acceptors => no cross-round disagreement", s,
|
|
expect_unsat=True, kind="PROOF")
|
|
|
|
print()
|
|
print("=" * 78)
|
|
print("P3 Besu ACCEPTOR locking saves SAFETY even if the 2nd client proposes FRESH")
|
|
print("=" * 78)
|
|
# proposer may build a fresh (conflicting) block at round>0 (Finding E gap), but EVERY acceptor
|
|
# locks (Besu's real behavior). Prove agreement still holds: an unjustified fresh proposal cannot
|
|
# gather a prepare-quorum, so it can never commit. => Finding E is a LIVENESS cost, not a
|
|
# live-mainnet safety hole, as long as the accepting quorum locks.
|
|
for N in (4, 7, 10):
|
|
R = 3
|
|
s, cA, cB, _ = locking_model(N, quorum(N), faultbound(N), R,
|
|
proposer_justified=False, locks_pattern=ALL)
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} R={R}: fresh proposer but ALL acceptors lock => agreement still holds", s,
|
|
expect_unsat=True, kind="PROOF")
|
|
|
|
print()
|
|
print("=" * 78)
|
|
print("P4 the NON-LOCKING THRESHOLD: k second-client nodes without acceptor locking")
|
|
print("=" * 78)
|
|
# The load-bearing engine result, stated precisely. Let k validators (the second client) NOT
|
|
# enforce acceptor-side locking; the rest (Besu) do. A conflicting round>0 value still needs a
|
|
# full prepare-quorum q, and locking honest nodes refuse it, so only the k non-locking nodes plus
|
|
# <= f Byzantine can prepare it. Hence:
|
|
# * k < q - f (below threshold): the conflicting value can NEVER reach quorum -> SAFE (PROVED).
|
|
# => a small number of non-locking second-client nodes among locking Besu cannot fork the chain.
|
|
# * k >= q - f (at/above threshold): the non-locking nodes + f Byzantine CAN form a quorum for
|
|
# an unjustified value across rounds -> agreement breaks (CEX).
|
|
# So the second client MUST enforce locking to be deployed as anything approaching q-f of the set
|
|
# (the whole point of a producing second client). A single non-locking node is not enough; the
|
|
# fix removes the ceiling entirely.
|
|
def first_k_unlocked(k):
|
|
return (lambda i: i >= k) # validators 0..k-1 do NOT lock (the second client), rest lock
|
|
for N in (7, 10, 13):
|
|
q, f = quorum(N), faultbound(N)
|
|
R = 2
|
|
k_safe = q - f - 1
|
|
if k_safe >= 0:
|
|
s, cA, cB, _ = locking_model(N, q, f, R, proposer_justified=False,
|
|
locks_pattern=first_k_unlocked(k_safe))
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} q={q} f={f}: k={k_safe} non-locking (< q-f) => still SAFE", s,
|
|
expect_unsat=True, kind="PROOF")
|
|
k_break = q - f
|
|
s, cA, cB, _ = locking_model(N, q, f, R, proposer_justified=False,
|
|
locks_pattern=first_k_unlocked(k_break))
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} q={q} f={f}: k={k_break} non-locking (>= q-f) => agreement BREAKS", s,
|
|
expect_unsat=False, kind="NEG-CONTROL")
|
|
|
|
print("\n" + "=" * 78)
|
|
print("P5 the COMPLETE signed-certificate acceptor removes the k<q-f ceiling (Findings D/E)")
|
|
print("=" * 78)
|
|
# P4 leaves the second client with a hard ceiling: it can only be < q-f of the set unless it locks.
|
|
# The COMPLETE acceptor (validate the piggy-backed RoundChange CERTIFICATE, whose entries are each
|
|
# individually secp256k1-signed, and re-propose the highest prepared value found ACROSS the quorum)
|
|
# is what lets every second-client node lock soundly. Because the certificate is unforgeable, a
|
|
# Byzantine cannot induce an honest node to skip a locked value; so the number of honest non-locking
|
|
# nodes collapses to 0, and 0 < q-f for every valid QBFT N. P5a proves the consequence: with every
|
|
# honest node locking, an ALL-second-client set (no justified proposer) still keeps agreement.
|
|
print("P5a complete acceptor: every honest node locks => SAFE at ANY deployment fraction")
|
|
for N in (4, 7, 10):
|
|
q, f = quorum(N), faultbound(N)
|
|
R = 2
|
|
s, cA, cB, _ = locking_model(N, q, f, R, proposer_justified=False, locks_pattern=ALL)
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} q={q} f={f}: complete acceptor (all honest lock, fresh proposer) => agreement", s,
|
|
expect_unsat=True, kind="PROOF")
|
|
|
|
# P5b pins down WHY the discretionary version was rejected. The INCOMPLETE acceptor reads the round>0
|
|
# proposal's OWN prepare list, which that proposer authors; a Byzantine proposer simply omits the
|
|
# prepares for the locked value (reveal[1]=False). Then, even with EVERY node running an acceptor,
|
|
# no honest node can tell it must lock, and agreement breaks exactly as in the no-acceptor case P1.
|
|
print("\nP5b incomplete acceptor + Byzantine proposer omits the justification => BREAKS (NEG-CONTROL)")
|
|
for N in (7, 10):
|
|
q, f = quorum(N), faultbound(N)
|
|
R = 2
|
|
reveal = [True, False] # the round-1 proposer is Byzantine and hides the prepared-value justification
|
|
s, cA, cB, _ = locking_model(N, q, f, R, proposer_justified=False, locks_pattern=ALL, reveal=reveal)
|
|
s.add(Or(*cA)); s.add(Or(*cB))
|
|
check(f"N={N} q={q} f={f}: incomplete acceptor cannot see hidden justification => agreement breaks", s,
|
|
expect_unsat=False, kind="NEG-CONTROL")
|
|
|
|
print("\n=== SUMMARY (IBFT 2.0 locking, engine-role model: Findings D/E) ===")
|
|
allok = True
|
|
for name, tag, ok, kind in results:
|
|
print(f" {tag:9} [{kind}] {name}")
|
|
allok = allok and ok
|
|
print()
|
|
if allok:
|
|
print(" ESTABLISHED, mapped to the engine roles:")
|
|
print(" * P2 PROVED: the FULL fix -- a round>0 proposer that re-proposes the highest")
|
|
print(" prepared value (Finding E) plus acceptors that refuse an unjustified proposal")
|
|
print(" (Finding D) -- keeps agreement across round changes.")
|
|
print(" * P3 PROVED: because live mainnet's accepting quorum is Besu (which locks), a")
|
|
print(" fresh conflicting proposal can never gather a prepare-quorum; so the second")
|
|
print(" client's fresh-proposer gap is a LIVENESS cost (a wasted round), not a live-")
|
|
print(" chain safety hole. Live mainnet (Besu-only) is unaffected.")
|
|
print(" * P4 gives the exact threshold: k non-locking second-client nodes are SAFE while")
|
|
print(" k < q-f (PROVED) but BREAK agreement at k >= q-f (CEX). A single non-locking node")
|
|
print(" among locking Besu cannot fork the chain, but the second client cannot approach")
|
|
print(" q-f of the set without locking -- so locking is a hard prerequisite for scaling")
|
|
print(" it as a producing validator. P1 CEX confirms non-vacuity.")
|
|
print(" * P5a PROVED: the COMPLETE signed-RoundChange-certificate acceptor (each entry secp256k1-")
|
|
print(" signed, highest prepared value taken across the quorum) makes every honest node lock,")
|
|
print(" which removes P4's k<q-f ceiling entirely -- the second client stays safe at ANY")
|
|
print(" deployment fraction, up to an all-second-client set.")
|
|
print(" * P5b NEG-CONTROL: the INCOMPLETE acceptor (reading the proposal's own prepare list, which")
|
|
print(" a Byzantine round>0 proposer controls) does NOT close P4 -- when that proposer omits the")
|
|
print(" justification, agreement breaks even with every node running it. This is the formal")
|
|
print(" reason the discretionary-list acceptor was rejected for the signed certificate.")
|
|
print(" BOUNDARY: bounded per-N/round model checks of the locking combinatorics, not the")
|
|
print(" C# bytecode; complements the adversarial soak. Chain 2800 consensus is classical")
|
|
print(" ECDSA QBFT.")
|
|
else:
|
|
print(" NOT fully established (see FAILED above).")
|
|
import sys
|
|
sys.exit(0 if allok else 1)
|