aere-research/formal-consensus/qbft_locking_smt.py
Aere Network 6cb0140fae Republished from a clean root: the compiled artifact is gone from history, and the local line of work joins the sanitized public line
The public history carried kat/__pycache__/mlkem768_reference.cpython-314.pyc,
a compiled Python artifact embedding the operator's absolute local path. Text
secret scanners do not read compiled binaries, which is exactly how it slipped
through, and removing it from the tip would have left it reachable through the
old root commits. So this repository is republished from a single clean root.

This root also carries, from the previously unpublished line of work:
- corrected LICENSE year, LICENSING.md, VERIFY-POLICY.md, and
  CITATIONS-UNRESOLVED.md remeasured 2026-08-11 (101 paths, README aligned)
- O-018: run_consensus_verification.py ran 19 of 29 models and reported PASS;
  it now runs all 29, and computemarket_smt.py gains resolveByTimeout /
  reclaimUnsettled cases plus a negative control
- O-006: the word 'audited' removed from next to Bouncy Castle, twice, after a
  concurrent edit resurrected it
- O-014: prior art named and dated - Algorand's native falcon_verify shipped
  about ten months before AERE's precompiles; the primacy claim is withdrawn
  where it was implied
- bench/ scripts parametrized so they actually run for an outsider (the
  earlier textual sanitization left $STAGING unexpanded inside Python strings)
- AIP-2/AIP-3 errata with measured figures, spec remeasurements at 2026-08-01,
  and the spec-zk-stack retractions (owner is an operational key, not the
  Foundation; 'maximally sound' withdrawn; aggregator V1 deprecated)
The redacted bench-host environment files from the sanitized line are kept
exactly as published; the unredacted local variants are not carried.
2026-08-15 13:52:14 +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)