#!/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 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 k0 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)