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

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)