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

144 lines
7.1 KiB
Python

#!/usr/bin/env python3
# -----------------------------------------------------------------------------
# qbft_digest_keyed_smt.py
#
# MACHINE-CHECKED (z3) model of the DIGEST-KEYED VOTE-TALLY fix applied to the AERE
# second-client QBFT engine on 2026-07-14 (audit Finding A). This is the fix that
# actually SHIPPED after two proposal-adoption patches were soak-rejected: instead
# of gating adoption, keep the prepare/commit tallies keyed BY DIGEST and count a
# quorum only over the CURRENT digest.
#
# THE BUG (Finding A): the tallies were flat sets keyed by voter address only. On a
# conflicting proposal that changed the current digest from D0 to D1 at the same
# (height,round), the prepares already cast for D0 stayed in the flat set and were
# then counted toward D1 -> an UNJUSTIFIED commit quorum for a block nobody prepared
# -> cross-client double-commit / fork.
#
# THE FIX: tallies are maps `digest -> voters`; `PrepareCount()`/`CommitCount()`
# count only `tally[current_digest]`. A vote cast for D0 lives under D0 and can never
# be counted toward D1.
#
# This model proves the fix removes the mis-attribution, and its NEGATIVE CONTROL
# shows the flat tally genuinely had the bug (non-vacuous). Besu quorum
# quorum(N)=ceil(2N/3), fault bound f(N)=floor((N-1)/3).
# HONEST BOUNDARY: bounded per-N checks of the counting rule (message combinatorics),
# not the C# bytecode; complements the shipped fix's adversarial-soak verification
# (0 forks, Besu parity). Chain 2800 consensus is classical ECDSA QBFT.
# -----------------------------------------------------------------------------
from z3 import Int, Bool, Solver, And, Or, Not, Implies, If, Sum, sat, unsat
def quorum(n): return (2 * n + 2) // 3
def faultbound(n): return (n - 1) // 3
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
OPT = [4, 7, 10, 13] # N = 3f+1 optimal sizes
print("=" * 78)
print("P1 FLAT TALLY (the BUG) => a conflicting-digest mis-count can fake a quorum")
print("=" * 78)
# Per-validator model at one (height,round). Each validator i either voted for D0, for
# D1, or not at all. A FLAT tally (the pre-fix bug) that adopts D1 counts EVERY voter
# (D0-voters + D1-voters) toward D1. Show: even when FEWER than quorum validators
# actually voted D1, the flat count can reach quorum -> a fake quorum for D1. (SAT.)
for N in OPT:
f = faultbound(N); q = quorum(N)
s = Solver()
v0 = [Bool(f"v0_{i}") for i in range(N)] # voted for D0
v1 = [Bool(f"v1_{i}") for i in range(N)] # voted for D1
for i in range(N):
s.add(Not(And(v0[i], v1[i]))) # an honest voter votes at most one digest
real_d1 = Sum([If(v1[i], 1, 0) for i in range(N)])
flat_d1 = Sum([If(Or(v0[i], v1[i]), 1, 0) for i in range(N)]) # BUG: pool D0+D1 voters
s.add(real_d1 < q) # D1 did NOT truly reach quorum
s.add(flat_d1 >= q) # ...but the flat (buggy) count says it did
check(f"N={N} q={q}: flat tally reports quorum for D1 while real D1 votes < q "
f"(fake quorum from mis-attribution)", s, expect_unsat=False, kind="NEG-CONTROL")
print()
print("=" * 78)
print("P2 DIGEST-KEYED TALLY (the FIX) => the count equals the REAL per-digest votes")
print("=" * 78)
# The fix counts tally[D1] = only D1-voters. Prove: it is IMPOSSIBLE for the digest-keyed
# count of D1 to reach quorum while the real number of D1 voters is below quorum. (UNSAT.)
for N in OPT:
f = faultbound(N); q = quorum(N)
s = Solver()
v0 = [Bool(f"v0_{i}") for i in range(N)]
v1 = [Bool(f"v1_{i}") for i in range(N)]
for i in range(N):
s.add(Not(And(v0[i], v1[i])))
keyed_d1 = Sum([If(v1[i], 1, 0) for i in range(N)]) # FIX: count only D1's own voters
real_d1 = keyed_d1
s.add(real_d1 < q)
s.add(keyed_d1 >= q) # can the fixed count over-report? -> UNSAT
check(f"N={N} q={q}: digest-keyed count for D1 reaches quorum while real D1 votes < q "
f"(over-count impossible)", s, expect_unsat=True, kind="PROOF")
print()
print("=" * 78)
print("P3 SAFETY under the fix => two distinct digests cannot BOTH reach quorum")
print("=" * 78)
# With per-digest counts, D0 and D1 both reaching quorum needs q honest-or-byzantine
# voters each. Honest validators vote at most one digest; up to f Byzantine may vote
# BOTH. Prove both-quorum is impossible (the classical intersection, now expressed on
# the digest-keyed tally so the fix is shown to preserve agreement). UNSAT.
for N in OPT:
f = faultbound(N); q = quorum(N)
s = Solver()
byz = [Bool(f"byz_{i}") for i in range(N)]
v0 = [Bool(f"v0_{i}") for i in range(N)]
v1 = [Bool(f"v1_{i}") for i in range(N)]
s.add(Sum([If(byz[i], 1, 0) for i in range(N)]) <= f)
for i in range(N):
s.add(Implies(Not(byz[i]), Not(And(v0[i], v1[i])))) # honest: one digest only
c0 = Sum([If(v0[i], 1, 0) for i in range(N)])
c1 = Sum([If(v1[i], 1, 0) for i in range(N)])
s.add(c0 >= q); s.add(c1 >= q) # both reach the (correct) digest-keyed quorum
check(f"N={N} f={f} q={q}: digest-keyed tallies let two distinct digests BOTH reach "
f"quorum (safety break)", s, expect_unsat=True, kind="PROOF")
print()
print("=" * 78)
print("P3-NEG lowering quorum to ceil(N/2) DOES let both reach quorum (threshold load-bearing)")
print("=" * 78)
for N in [4, 7, 10]:
f = faultbound(N); qbad = (N + 1) // 2
s = Solver()
byz = [Bool(f"byz_{i}") for i in range(N)]
v0 = [Bool(f"v0_{i}") for i in range(N)]
v1 = [Bool(f"v1_{i}") for i in range(N)]
s.add(Sum([If(byz[i], 1, 0) for i in range(N)]) <= f)
for i in range(N):
s.add(Implies(Not(byz[i]), Not(And(v0[i], v1[i]))))
c0 = Sum([If(v0[i], 1, 0) for i in range(N)]); c1 = Sum([If(v1[i], 1, 0) for i in range(N)])
s.add(c0 >= qbad); s.add(c1 >= qbad)
check(f"N={N} qbad=ceil(N/2)={qbad}: two digests both reach the LOWERED quorum", s,
expect_unsat=False, kind="NEG-CONTROL")
print("\n=== SUMMARY (digest-keyed vote tally: the shipped Finding-A fix) ===")
allok = True
for name, tag, ok, kind in results:
print(f" {tag:9} [{kind}] {name}")
allok = allok and ok
print()
if allok:
print(" ESTABLISHED: the FLAT tally could report a quorum for a digest from votes cast")
print(" for a DIFFERENT digest (P1, the Finding-A bug, CEX-FOUND); the DIGEST-KEYED tally")
print(" cannot over-count (P2, PROVED) and preserves agreement - two distinct digests")
print(" can never both reach the ceil(2N/3) quorum (P3, PROVED). The threshold is")
print(" load-bearing (P3-NEG fires at ceil(N/2)). This is the counting-rule proof behind")
print(" the shipped fix, which additionally passed adversarial soaks (0 forks, Besu parity).")
print(" HONEST: bounded per-N checks of the tally combinatorics, not the C# bytecode.")
else:
print(" NOT fully established (see FAILED above).")
import sys
sys.exit(0 if allok else 1)