The second execution client for chain 2800: 23 files that let an independent implementation, in a different language on a different codebase, follow the chain and reach the same state. Published because the files derive from Nethermind under LGPL-3.0, and because a chain whose node software cannot be read cannot have independent operators. Nothing here is useful without upstream Nethermind. Contains no keys, no node addresses and no operational configuration. Two test files carried absolute paths and a host name from the machine they were written on; both were replaced with portable equivalents before this commit, which also makes the tests runnable by anyone. The README states the limits plainly: this client follows and validates, it does not produce mainnet blocks, and it has never been audited by a third party.
556 lines
25 KiB
C#
556 lines
25 KiB
C#
// 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<JsonElement> Blocks);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static List<Run> 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<Run> 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<JsonElement> 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<Run> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static (BlockHeader header, UInt256 baseFee) BuildGenuine(JsonElement v)
|
|
{
|
|
Hash256 reported = H(v.GetProperty("hash").GetString()!);
|
|
|
|
List<UInt256> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<Run> runs = LoadRuns();
|
|
AereQbftHeaderValidator validator = BuildValidator();
|
|
|
|
int accepted = 0, hashed = 0;
|
|
foreach (Run run in runs)
|
|
{
|
|
List<BlockHeader> headers = [];
|
|
List<UInt256> 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<long, (BlockHeader h, UInt256 fee)> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<byte[]> 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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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]);
|
|
}
|
|
}
|