// SPDX-FileCopyrightText: 2026 AERE Network // SPDX-License-Identifier: LGPL-3.0-only // // PROOF: a second, independent client (Nethermind 1.39.0, C#/.NET) VALIDATES and // would IMPORT real AERE chain-2800 QBFT blocks through Nethermind's REAL // header-import validation path. // // This drives real chain-2800 headers through Nethermind's production // HeaderValidator base class (subclassed as AereQbftHeaderValidator) with the // AERE QBFT ISealValidator wired in and the QBFT-aware canonical-hash patch // active. It is NOT a test of the standalone AERE validator: every accept/reject // decision here flows through Nethermind's own HeaderValidator.Validate(), // exercising parent linkage, EIP-1559 base fee, blob-gas, requests, gas limits, // timestamps, block number, the (patched) canonical block-hash check, and the // QBFT seal check - the same code the node runs when importing headers. // // Honest boundary: this is the VALIDATING/FOLLOWING half of client diversity. // It is not a producing validator (no proposing/sealing, not in the QBFT set), // and full stateful block execution (state-root re-derivation) is out of scope // here because it needs AERE's world state. Consensus stays classical ECDSA QBFT. // // 2026-08-01, second pass. Three defects of this fixture were found by RUNNING it // (it had never been run before) and are fixed here: // // 1. It could not load its own vectors. LoadVectors read root.GetProperty // ("vectors") while the vector file was a bare JSON array, so all five tests // died in the loader with "requires an element of type 'Object'". The loader // now accepts the runs format, the {"vectors":[...]} format and a bare array. // // 2. It built its validator on SingleReleaseSpecProvider(Prague), a spec that // does not carry the AERE base-fee floor, so the fixture rejected the genuine // fork block 10,141,734 with "InvalidBaseFeePerGas: Expected 7, got // 1000000000" - a defect of the test, not of the client, since the live node // imported that block correctly. The validator is now built on the REAL // chainspec of chain 2800 through ChainSpecBasedSpecProvider, which installs // AereBaseFeeCalculator, so what is asserted here is what the node runs. // // 3. Its message claimed "consecutive genuine chain-2800 blocks" while only 6 of // the 17 vectors were parent-linked and one block appeared twice. Vectors are // now explicit contiguous RUNS (tools/make-follower-vectors.py) and linkage is // re-checked here at load time. // // Added in the same pass: the genesis-parent round-robin (block 1 of chain 2800 // committed at ROUND 1) and a tampered-round rejection, plus a planted-failure // control, because a check that has never caught anything cannot be trusted. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using Nethermind.AerePqc.Consensus; using Nethermind.AerePqc.Qbft; using Nethermind.Consensus; using Nethermind.Consensus.Validators; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; using Nethermind.Core.Test.Builders; using Nethermind.Crypto; using Nethermind.Int256; using Nethermind.Logging; using Nethermind.Serialization.Json; using Nethermind.Specs.ChainSpecStyle; using NUnit.Framework; namespace Nethermind.Blockchain.Test.Validators; [TestFixture] public class AereQbftFollowerProofTests { private const ulong AereChainId = 2800; private const long BaseFeeFloorForkBlock = 10_141_734; private static string TestDataPath(string name) => Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", name); private static string ResolvePath(string envVar, string testDataName, string fallback) { string? fromEnv = Environment.GetEnvironmentVariable(envVar); if (!string.IsNullOrWhiteSpace(fromEnv) && File.Exists(fromEnv)) return fromEnv; string inTestData = TestDataPath(testDataName); if (File.Exists(inTestData)) return inTestData; return fallback; } private static string VectorsPath => ResolvePath("AERE_QBFT_VECTORS", "qbft-follower-vectors.json", "./qbft-follower-vectors.json"); private static string ChainSpecPath => ResolvePath("AERE_CHAINSPEC", "aere-live-chainspec.json", "./aere-live-chainspec.json"); private sealed record Run(string Name, IReadOnlyList Blocks); /// /// Loads the vectors as contiguous parent-linked RUNS. Accepts three shapes: /// the current {"runs":[{"name","blocks":[...]}]}, the older {"vectors":[...]} /// and an older bare array; for the two flat shapes the blocks are split into /// runs here by actual parentHash linkage, so no caller can claim a linkage the /// file does not have. /// private static List LoadRuns() { string path = VectorsPath; Assert.That(File.Exists(path), $"vector file not found: {path}"); using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path)); JsonElement root = doc.RootElement.Clone(); List runs = []; if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("runs", out JsonElement runsEl)) { foreach (JsonElement r in runsEl.EnumerateArray()) { runs.Add(new Run(r.GetProperty("name").GetString()!, r.GetProperty("blocks").EnumerateArray().ToList())); } } else { JsonElement flat = root.ValueKind == JsonValueKind.Array ? root : root.GetProperty("vectors"); List current = []; int seq = 0; foreach (JsonElement v in flat.EnumerateArray()) { if (current.Count > 0 && !string.Equals(v.GetProperty("parentHash").GetString(), current[^1].GetProperty("hash").GetString(), StringComparison.OrdinalIgnoreCase)) { runs.Add(new Run($"flat-{seq++}", current)); current = []; } current.Add(v); } if (current.Count > 0) runs.Add(new Run($"flat-{seq}", current)); } // Linkage is re-verified here, at load time, for every shape. foreach (Run run in runs) { for (int i = 1; i < run.Blocks.Count; i++) { Assert.That(run.Blocks[i].GetProperty("parentHash").GetString(), Is.EqualTo(run.Blocks[i - 1].GetProperty("hash").GetString()).IgnoreCase, $"run {run.Name}: block {run.Blocks[i].GetProperty("number").GetString()} is not linked to its predecessor"); } } Assert.That(runs.Count, Is.GreaterThan(0), "no vectors loaded"); return runs; } private static Run RunNamed(string name) { List runs = LoadRuns(); Run? r = runs.FirstOrDefault(x => x.Name == name); // Fall back to the longest run when loading an older flat vector file. return r ?? runs.OrderByDescending(x => x.Blocks.Count).First(); } private static Hash256 H(string hex) => new(hex); private static byte[] B(string hex) => Bytes.FromHexString(hex); private static ulong U(string? hex) => hex is null ? 0UL : (ulong)Convert.ToInt64(hex, 16); private static UInt256 Uint(string hex) => new(Bytes.FromHexString(hex), true); private static BlockHeader BuildHeader(JsonElement v, UInt256 baseFee) { string? S(string k) => v.TryGetProperty(k, out JsonElement e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null; BlockHeader header = new( parentHash: H(S("parentHash")!), unclesHash: H(S("sha3Uncles")!), beneficiary: new Address(B(S("miner")!)), difficulty: Uint(S("difficulty")!), number: (long)U(S("number")), gasLimit: (long)U(S("gasLimit")), timestamp: U(S("timestamp")), extraData: B(S("extraData")!), blobGasUsed: S("blobGasUsed") is string bg ? U(bg) : null, excessBlobGas: S("excessBlobGas") is string eg ? U(eg) : null, parentBeaconBlockRoot: S("parentBeaconBlockRoot") is string pb ? H(pb) : null, requestsHash: S("requestsHash") is string rq ? H(rq) : null) { StateRoot = H(S("stateRoot")!), TxRoot = H(S("transactionsRoot")!), ReceiptsRoot = H(S("receiptsRoot")!), Bloom = new Bloom(B(S("logsBloom")!)), GasUsed = (long)U(S("gasUsed")), MixHash = H(S("mixHash")!), Nonce = U(S("nonce")), BaseFeePerGas = baseFee, WithdrawalsRoot = S("withdrawalsRoot") is string wr ? H(wr) : null, }; return header; } /// /// Build the header and prove the base fee we used is the CONSENSUS one: the /// chain-reported baseFeePerGas is tried first (the RPC base-fee shim of chain /// 2800 was retired, so the RPC now reports the real number), then a small /// candidate list for older captures. The proof is that Nethermind's own /// (QBFT-patched) CalculateHash reproduces the chain-reported block hash. /// private static (BlockHeader header, UInt256 baseFee) BuildGenuine(JsonElement v) { Hash256 reported = H(v.GetProperty("hash").GetString()!); List candidates = []; if (v.TryGetProperty("baseFeePerGas", out JsonElement bf) && bf.ValueKind == JsonValueKind.String) candidates.Add(Uint(bf.GetString()!)); foreach (ulong c in new ulong[] { 1000000000UL, 7, 6, 8, 5, 9, 10, 11, 12, 13, 14, 15, 16, 17, 1 }) candidates.Add(c); foreach (UInt256 cand in candidates) { BlockHeader h = BuildHeader(v, cand); if (h.CalculateHash() == reported) { h.Hash = reported; return (h, cand); } } Assert.Fail($"No candidate base fee reproduced hash for block {v.GetProperty("number").GetString()}"); return default; } /// /// The validator under test, built on the REAL chainspec of chain 2800 (the same /// file the live second client node runs), so the AERE base-fee floor calculator is /// installed exactly as it is in production. Building it on a stock spec provider /// is what made this fixture reject the genuine fork block. /// private static AereQbftHeaderValidator BuildValidator() { string specPath = ChainSpecPath; Assert.That(File.Exists(specPath), $"chainspec not found: {specPath}"); ChainSpec chainSpec = new ChainSpecFileLoader(new EthereumJsonSerializer(), LimboLogs.Instance) .LoadEmbeddedOrFromFile(specPath); Assert.That(chainSpec.ChainId, Is.EqualTo(AereChainId), "chainspec is not chain 2800"); ChainSpecBasedSpecProvider specProvider = new(chainSpec); Nethermind.Blockchain.IBlockTree blockTree = Build.A.BlockTree().WithoutSettingHead.TestObject; AereQbftSealValidator seal = new(LimboLogs.Instance); return new AereQbftHeaderValidator(blockTree, seal, specProvider, LimboLogs.Instance); } private static byte[] FlipSeals(byte[] extraData, int howMany) { RlpList top = (RlpList)Rlp.Decode(extraData); RlpList sealList = (RlpList)top.Items[4]; for (int i = 0; i < howMany && i < sealList.Items.Count; i++) { byte[] s = ((RlpBytes)sealList.Items[i]).Value; s[64] ^= 0x01; } return top.Encode(); } private static byte[] DropSeals(byte[] extraData, int keep) { RlpList top = (RlpList)Rlp.Decode(extraData); RlpList sealList = (RlpList)top.Items[4]; while (sealList.Items.Count > keep) sealList.Items.RemoveAt(sealList.Items.Count - 1); return top.Encode(); } private static byte[] WithRound(byte[] extraData, long round) { RlpList top = (RlpList)Rlp.Decode(extraData); top.Items[3] = RlpBytes.Scalar(round); return top.Encode(); } // ---------------------------------------------------------------- accept [Test] public void Second_client_accepts_every_genuine_chain2800_block_and_reproduces_its_hash() { List runs = LoadRuns(); AereQbftHeaderValidator validator = BuildValidator(); int accepted = 0, hashed = 0; foreach (Run run in runs) { List headers = []; List fees = []; foreach (JsonElement v in run.Blocks) { (BlockHeader h, UInt256 bf) = BuildGenuine(v); Assert.That(h.Hash, Is.EqualTo(H(v.GetProperty("hash").GetString()!)), $"Nethermind must reproduce chain hash of block {h.Number}"); headers.Add(h); fees.Add(bf); hashed++; } for (int i = 1; i < headers.Count; i++) { bool ok = validator.Validate(headers[i], headers[i - 1], false, out string? error); Assert.That(ok, Is.True, $"run {run.Name}: genuine block {headers[i].Number} must pass Nethermind HeaderValidator; error={error}"); accepted++; } TestContext.Out.WriteLine( $"ACCEPT run {run.Name}: blocks {headers[0].Number}..{headers[^1].Number}, " + $"{headers.Count} hashes reproduced, {headers.Count - 1} parent-linked pairs validated, " + $"baseFee {fees[0]}..{fees[^1]} wei."); } Assert.That(hashed, Is.GreaterThanOrEqualTo(20)); Assert.That(accepted, Is.GreaterThanOrEqualTo(15)); TestContext.Out.WriteLine($"TOTAL: {hashed} genuine headers hash-reproduced, {accepted} accepted by HeaderValidator."); } [Test] public void Second_client_accepts_the_base_fee_floor_fork_block_that_stalled_it() { Run run = RunNamed("basefee-floor-fork"); Dictionary byNumber = []; foreach (JsonElement v in run.Blocks) { (BlockHeader h, UInt256 fee) = BuildGenuine(v); byNumber[h.Number] = (h, fee); } Assert.That(byNumber.ContainsKey(BaseFeeFloorForkBlock), "vectors must contain the fork block"); Assert.That(byNumber[BaseFeeFloorForkBlock - 1].fee, Is.EqualTo((UInt256)7), "last pre-fork block carries 7 wei"); Assert.That(byNumber[BaseFeeFloorForkBlock].fee, Is.EqualTo((UInt256)1_000_000_000), "the fork block carries 1 Gwei"); AereQbftHeaderValidator validator = BuildValidator(); bool ok = validator.Validate(byNumber[BaseFeeFloorForkBlock].h, byNumber[BaseFeeFloorForkBlock - 1].h, false, out string? error); Assert.That(ok, Is.True, $"block {BaseFeeFloorForkBlock} is the block that stalled the second client; it must now pass. error={error}"); TestContext.Out.WriteLine( $"ACCEPT fork block {BaseFeeFloorForkBlock} (7 wei -> 1 Gwei) through the live chainspec's AereBaseFeeCalculator."); } /// /// The genesis-parent round-robin, on a real mainnet block. Block 1 of chain 2800 /// committed at ROUND 1 with N=3; its parent is genesis, whose coinbase 0x0 is not /// a validator. Besu's handleMissingProposer seed gives sorted[(0 + round) mod N] = /// sorted[1], which is the real proposer. The naive "seed from the parent" formula /// adds a spurious +1 and gives sorted[2]: this test measures that the two formulas /// really do disagree here, so accepting block 1 is evidence and not a coincidence. /// [Test] public void Second_client_validates_block_1_committed_at_round_1_from_a_genesis_parent() { Run run = RunNamed("genesis-round1"); (BlockHeader genesis, _) = BuildGenuine(run.Blocks[0]); (BlockHeader block1, _) = BuildGenuine(run.Blocks[1]); Assert.That(genesis.Number, Is.EqualTo(0)); Assert.That(block1.Number, Is.EqualTo(1)); QbftExtraData extra = QbftExtraData.Decode(block1.ExtraData); int n = extra.Validators.Count; long round = extra.Round; List sorted = extra.Validators.ToList(); sorted.Sort((a, b) => { for (int i = 0; i < a.Length; i++) if (a[i] != b[i]) return a[i] < b[i] ? -1 : 1; return 0; }); Address besuSeed = new(sorted[(int)((round % n + n) % n)]); // handleMissingProposer Address naiveSeed = new(sorted[(int)(((round + 1) % n + n) % n)]); // the wrong "+1" formula TestContext.Out.WriteLine($"block 1: N={n}, round={round}, proposer={block1.Beneficiary}, " + $"Besu missing-proposer seed={besuSeed}, naive +1 seed={naiveSeed}"); Assert.That(besuSeed, Is.Not.EqualTo(naiveSeed), "the two formulas must disagree here, otherwise this block proves nothing"); Assert.That(block1.Beneficiary, Is.EqualTo(besuSeed), "the real chain agrees with the Besu missing-proposer seed"); AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(block1, genesis, false, out string? error), Is.True, $"genuine round-{round} block 1 must be accepted; error={error}"); // The check is load-bearing: put the naive formula's validator in the coinbase, // recompute the canonical hash so only the round-robin can object, expect reject. BlockHeader bad = block1.Clone(); bad.Beneficiary = naiveSeed; bad.Hash = bad.CalculateHash(); Assert.That(validator.Validate(bad, genesis, false, out string? error2), Is.False, "the proposer the naive formula would expect must be REJECTED at block 1"); TestContext.Out.WriteLine($"ACCEPT genuine block 1 at round {round}; REJECT naive-formula proposer {naiveSeed}: {error2}"); } // ---------------------------------------------------------------- reject [Test] public void Second_client_rejects_a_tampered_base_fee() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader genuine, UInt256 trueFee) = BuildGenuine(run.Blocks[1]); BlockHeader bad = BuildHeader(run.Blocks[1], trueFee + 1); bad.Hash = genuine.Hash; // claim the real hash with a fee that does not produce it AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, "a base fee that does not reproduce the block hash must be rejected"); TestContext.Out.WriteLine($"REJECT tampered baseFee ({trueFee} -> {trueFee + 1}) block {bad.Number}: {error}"); } [Test] public void Second_client_rejects_flipped_committed_seals() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); int quorum = QbftHeaderValidator.Quorum(extra.Validators.Count); int seals = extra.CommittedSeals.Count; int toBreak = seals - quorum + 1; BlockHeader bad = child.Clone(); bad.ExtraData = FlipSeals(child.ExtraData.ToArray(), toBreak); bad.Hash = child.Hash; // canonical hash is seal-stripped, unaffected AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, "flipped committed seals must drop below quorum and be rejected"); Assert.That(validator.Validate(child, parent, false, out _), Is.True, "the genuine header must still pass the same validator"); TestContext.Out.WriteLine($"REJECT flipped seals ({toBreak}/{seals} broken, quorum {quorum}): block {child.Number}: {error}"); } [Test] public void Second_client_rejects_dropped_seal_below_quorum() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); int quorum = QbftHeaderValidator.Quorum(extra.Validators.Count); int seals = extra.CommittedSeals.Count; int target = quorum - 1; BlockHeader bad = child.Clone(); bad.ExtraData = DropSeals(child.ExtraData.ToArray(), target); bad.Hash = child.Hash; AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, $"only {target} seals (< quorum {quorum}) must be rejected"); TestContext.Out.WriteLine($"REJECT dropped seals ({seals}->{target}, quorum {quorum}): block {child.Number}: {error}"); } [Test] public void Second_client_rejects_non_validator_proposer() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); BlockHeader bad = child.Clone(); bad.Beneficiary = new Address(B("0x000000000000000000000000000000000000dead")); bad.Hash = bad.CalculateHash(); // recompute so only the proposer rule can object AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, "a non-validator proposer must be rejected"); TestContext.Out.WriteLine($"REJECT non-validator proposer: block {bad.Number}: {error}"); } [Test] public void Second_client_rejects_an_in_set_proposer_that_is_wrong_for_the_round() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); Address? other = null; foreach (byte[] vb in extra.Validators) { Address a = new(vb); if (a != child.Beneficiary && a != parent.Beneficiary) { other = a; break; } } Assert.That(other, Is.Not.Null, "need an alternate in-set validator"); BlockHeader bad = child.Clone(); bad.Beneficiary = other!; bad.Hash = bad.CalculateHash(); AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, "an in-set validator that is not the round-robin proposer for this height must be rejected"); TestContext.Out.WriteLine($"REJECT wrong in-set proposer {other} for block {child.Number}: {error}"); } /// /// A tampered ROUND. The round is stripped from the canonical block hash, so the /// hash check still passes and the rejection is isolated to the QBFT layer: the /// round is part of the committed-seal digest and of the round-robin expectation, /// so changing it must invalidate the block. /// [Test] public void Second_client_rejects_a_tampered_round() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); long tamperedRound = extra.Round + 1; BlockHeader bad = child.Clone(); bad.ExtraData = WithRound(child.ExtraData.ToArray(), tamperedRound); bad.Hash = child.Hash; // round is stripped from the canonical hash: unchanged Assert.That(bad.CalculateHash(), Is.EqualTo(child.Hash), "the canonical hash must be unaffected by the round, otherwise this test measures the hash check"); AereQbftHeaderValidator validator = BuildValidator(); Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, $"round {extra.Round} -> {tamperedRound} must be rejected"); TestContext.Out.WriteLine($"REJECT tampered round ({extra.Round} -> {tamperedRound}) block {child.Number}: {error}"); } // ---------------------------------------------------------------- control /// /// Planted failure. A fixture that has never produced a failure cannot be trusted /// to be able to. This asserts something FALSE about a genuine header and requires /// the assertion machinery to raise, proving every PASS above is load-bearing. /// [Test] public void Planted_control_this_fixture_can_actually_fail() { Run run = RunNamed("tip"); (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); AereQbftHeaderValidator validator = BuildValidator(); bool genuineAccepted = validator.Validate(child, parent, false, out _); Assert.That(genuineAccepted, Is.True, "precondition: the genuine block is accepted"); Exception? raised = Assert.Catch(() => Assert.That(genuineAccepted, Is.False, "PLANTED: genuine header asserted invalid")); Assert.That(raised, Is.Not.Null, "the planted false assertion produced NO failure: this fixture is blind"); TestContext.Out.WriteLine("PLANTED CONTROL raised as required: " + raised!.Message.Split('\n')[0]); } }