diff --git a/README.md b/README.md
index 5a44fad..5bb4eec 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,10 @@ nine produce. Nine of the ten seats still run the first client, so the second ad
check, not yet fault tolerance.
**What this repository holds, and what it does not yet hold.** The files here are the validation
-side as published in August 2026: the precompiles, the QBFT header decoding and hashing, header and
-seal validation, and the base-fee floor. The block-producing consensus engine and the post-quantum
+side, first published in August 2026 and brought to the production tree on 2026-10-14 (the same
+bytes the tenth validator runs for these files: the strict extraData decoder, header hashing and
+validation, the second window of the base-fee floor): the precompiles, the QBFT header decoding and
+hashing, header and seal validation, and the base-fee floor. The block-producing consensus engine and the post-quantum
message layers that run on the tenth validator today (AIP-20, AIP-21 and the active stages of
AIP-22) are not in this repository yet. Until they are, this repository is not the complete source
of what runs on mainnet.
diff --git a/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs
index 8fd4457..981c777 100644
--- a/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: LGPL-3.0-only
//
// AERE QBFT-aware HeaderValidator. It is a real Nethermind HeaderValidator (same
-// class the node uses to validate imported headers) with two QBFT adaptations:
+// class the node uses to validate imported headers) with three QBFT adaptations:
//
// 1. ValidateExtraData: QBFT carries a large RLP extraData
// [vanity, validators, vote, round, committedSeals] (hundreds of bytes),
@@ -13,11 +13,28 @@
// ISealValidator = AereQbftSealValidator, which does committed-seal recovery,
// quorum, and the round-robin proposer check.
//
+// 3. AERE PQ ANCHOR (G7, 2026-08-28): at an anchor height the header must carry a valid
+// post-quantum certificate. Until today this client could only TOLERATE certificates, so
+// the chain's headline property had exactly one implementation checking it. The check runs
+// AFTER every stock check, on a header the base class already accepted, so it can only ever
+// REMOVE headers - it can never make this validator accept something stock Nethermind
+// refuses.
+//
+// ARMED ONLY WHEN FULLY CONFIGURED, and the difference is loud: without a registry and a
+// schedule the rule stays unarmed, the node says so once at startup, and headers pass
+// exactly as before. A verifier that silently degrades to a pass is worse than none,
+// because it also removes the reason to look for one.
+//
// The canonical (seal-stripped) block-hash rule is enforced by the base class's
// ValidateHash together with the QBFT-aware BlockHeaderExtensions.CalculateHash
// patch. Every other check (parent linkage, number, gas, EIP-1559 base fee,
// blob-gas, requests, timestamp, ...) runs unchanged from stock Nethermind.
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Nethermind.AerePqc.Consensus.Anchor;
+using Nethermind.AerePqc.Qbft;
using Nethermind.Blockchain;
using Nethermind.Consensus;
using Nethermind.Consensus.Validators;
@@ -30,6 +47,13 @@ namespace Nethermind.AerePqc.Consensus;
public sealed class AereQbftHeaderValidator : HeaderValidator
{
+ private readonly ILogger _log;
+ private readonly PqAnchorRule _anchorRule;
+
+ /// AERE 1(b), PROBE SEAM (review of step 6): the anchor rule this validator built, so a probe can read the configuration it
+ /// judges with (the emergency ceiling, H_r). Never read by the validator itself.
+ public PqAnchorRule AnchorRuleForProbe => _anchorRule;
+
public AereQbftHeaderValidator(
IBlockTree? blockTree,
ISealValidator? sealValidator,
@@ -37,11 +61,128 @@ public sealed class AereQbftHeaderValidator : HeaderValidator
ILogManager? logManager)
: base(blockTree, sealValidator, specProvider, logManager)
{
- ILogger log = logManager?.GetClassLogger() ?? NullLogger.Instance;
- if (log.IsInfo)
- log.Info("AERE-QBFT HeaderValidator active: validates AERE chain-2800 QBFT headers via the " +
- "seal-stripped canonical block hash + delegated committed-seal/round-robin seal check. " +
- "Following/validating client - full QBFT block production is a separate track.");
+ _log = logManager?.GetClassLogger() ?? NullLogger.Instance;
+ if (_log.IsInfo)
+ _log.Info("AERE-QBFT HeaderValidator active: validates AERE chain-2800 QBFT headers via the " +
+ "seal-stripped canonical block hash + delegated committed-seal/round-robin seal check. " +
+ "Following/validating client - full QBFT block production is a separate track.");
+
+ // AERE 1(b), D-470 (2026-10-02): the commit-round configuration through the ONE reader (AERE_PQ_CHAIN_ID, AERE_PQ_ANCHOR_BLOCK,
+ // AERE_PQ_COMMIT_ROUND_BLOCK), handed to the anchor rule; and the guards of section 2.3 at STARTUP, outside the rule's own
+ // fail-closed catch (lesson D-334: a configuration refused at the first use stops the chain, refused at startup it stops a node)
+ AereCommitRoundConfig roundCfg = AereCommitRoundConfig.FromEnvironment();
+ long genesisChainId = (long)(specProvider?.ChainId ?? 0);
+ // pas 11 (nm-consens-04): an armed anchor without an explicit chain id refuses (CONF-01), and ROUND-03 below judges the chain id
+ // on every armed anchor, with or without H_r
+ if (roundCfg.ChainIdRefusal() is { } conf01) throw new AereCommitRoundException(conf01);
+ _anchorRule = BuildAnchorRule(specProvider?.ChainId ?? 0, _log, roundCfg);
+ if (roundCfg.Round01Refusal(_anchorRule.Config) is { } round01) throw new AereCommitRoundException(round01);
+ if (roundCfg.Round02Refusal(ConfiguredFinalityBlock()) is { } round02) throw new AereCommitRoundException(round02);
+ if (roundCfg.Round03Refusal(genesisChainId) is { } round03) throw new AereCommitRoundException(round03);
+ if (_log.IsInfo) _log.Info(roundCfg.StartupLine());
+ if (roundCfg.BrakeLine() is { } brake && _log.IsWarn) _log.Warn(brake); // pas 11: the brake, H_r ignored, the guards inert
+ if (roundCfg.Round02ConfirmationLine(ConfiguredFinalityBlock()) is { } confirmed && _log.IsWarn) _log.Warn(confirmed);
+ // AIP-22 stage 4: the checkpoint (AERE_PQ_CHECKPOINT); a malformed value throws here, at load, not on the import path
+ _checkpoint = AereCheckpoint.FromEnvironment();
+ if (_checkpoint.Count > 0 && _log.IsInfo)
+ _log.Info($"AERE PQ CHECKPOINT: {_checkpoint.Count} checkpoint(s) configured; a header at such a height with another hash is REFUSED.");
+ }
+
+ private readonly AereCheckpoint _checkpoint;
+
+ /// AERE 1(b): H_fin as configured (installed by the engine, or AERE_PQ_FINALITY_BLOCK while it is not); a value that is not a
+ /// height is left to AERE-PQC-FIN-01, which refuses it where H_fin is installed.
+ private static long ConfiguredFinalityBlock()
+ {
+ if (AerePqFinality.FinalityBlock != AerePqIdentity.Never) return AerePqFinality.FinalityBlock;
+ string? v = Environment.GetEnvironmentVariable(AerePqFinality.EnvVar);
+ return long.TryParse(v?.Trim(), out long h) && h >= 0 ? h : AerePqIdentity.Never;
+ }
+
+ ///
+ /// Build the anchor rule from this node's environment, in the same shape the fleet's own
+ /// options take. Anything missing leaves the rule unarmed AND SAYS SO.
+ ///
+ /// AERE_PQ_ANCHOR_BLOCK first height that must carry a certificate
+ /// AERE_PQ_ANCHOR_INTERVAL one certificate every this many blocks (default 1)
+ /// AERE_PQ_ANCHOR_INTERVAL_SCHEDULE "h:interval,...": from h the interval changes (D-336); multiples of the base, on the new grid
+ /// AERE_PQ_ANCHOR_MIN_SEALS staged thresholds, "h:k,h:k" - the fleet's own syntax
+ /// AERE_PQ_REGISTRY path to the address-bound Falcon manifest
+ ///
+ private static PqAnchorRule BuildAnchorRule(ulong chainId, ILogger log, AereCommitRoundConfig roundCfg)
+ {
+ string? blockSpec = Environment.GetEnvironmentVariable("AERE_PQ_ANCHOR_BLOCK");
+ string? scheduleSpec = Environment.GetEnvironmentVariable("AERE_PQ_ANCHOR_MIN_SEALS");
+ string? registryPath = Environment.GetEnvironmentVariable("AERE_PQ_REGISTRY");
+ string? intervalSpec = Environment.GetEnvironmentVariable("AERE_PQ_ANCHOR_INTERVAL");
+
+ if (string.IsNullOrWhiteSpace(blockSpec) && string.IsNullOrWhiteSpace(registryPath))
+ {
+ if (log.IsInfo)
+ log.Info("AERE PQ ANCHOR: not configured on this node (no AERE_PQ_ANCHOR_BLOCK / " +
+ "AERE_PQ_REGISTRY). Anchor certificates are TOLERATED, not checked - this node " +
+ "cannot judge them, which is not the same as judging them good.");
+ return new PqAnchorRule(null, null, (long)chainId, null, roundCfg);
+ }
+
+ try
+ {
+ if (!long.TryParse(blockSpec?.Trim(), out long anchorBlock))
+ throw new FormatException($"AERE_PQ_ANCHOR_BLOCK='{blockSpec}' is not a height");
+
+ long interval = 1;
+ if (!string.IsNullOrWhiteSpace(intervalSpec) && !long.TryParse(intervalSpec.Trim(), out interval))
+ throw new FormatException($"AERE_PQ_ANCHOR_INTERVAL='{intervalSpec}' is not a number");
+
+ IReadOnlyDictionary? schedule = PqAnchorConfig.ParseSchedule(scheduleSpec);
+ if (schedule is null)
+ throw new FormatException($"AERE_PQ_ANCHOR_MIN_SEALS='{scheduleSpec}' does not parse");
+
+ // D-081: a ROTATION SCHEDULE when one is configured, otherwise the single registry.
+ // AERE_PQ_REGISTRY_SCHEDULE takes "h1=path1,h2=path2", one manifest per epoch.
+ // D-470 (2026-10-01): read through the ONE place the finalized tags' certified decision reads them too
+ AereFalconRegistrySchedule registries = AereFalconRegistrySchedule.FromEnvironment()
+ ?? throw new FormatException($"AERE_PQ_REGISTRY='{registryPath}' does not exist");
+
+ string? v2Spec = Environment.GetEnvironmentVariable("AERE_PQ_ANCHOR_V2_BLOCK");
+ long v2Block = -1;
+ if (!string.IsNullOrWhiteSpace(v2Spec) && (!long.TryParse(v2Spec.Trim(), out v2Block) || v2Block < 0))
+ throw new FormatException($"AERE_PQ_ANCHOR_V2_BLOCK='{v2Spec}' is not a height");
+ // the scheme schedule is refused WHOLE on any defect (HybridSchemeSchedule.Parse throws)
+ HybridSchemeSchedule? schemeSchedule =
+ HybridSchemeSchedule.Parse(Environment.GetEnvironmentVariable("AERE_PQ_SCHEME_SCHEDULE"));
+ // D-336: the interval schedule is a consensus parameter like the anchor height; refused whole on any defect
+ IReadOnlyDictionary? intervalSchedule =
+ PqAnchorConfig.ParseIntervalSchedule(Environment.GetEnvironmentVariable("AERE_PQ_ANCHOR_INTERVAL_SCHEDULE"));
+ // AERE 1(b): the emergency ceiling from the ONE reader (review of step 6): K_eff = min(scheduled, ceiling), as the first client
+ var config = new PqAnchorConfig(anchorBlock, interval, schedule, v2Block, intervalSchedule, roundCfg.MinSealsCeiling);
+ // pas 11 (nm-consens-01, 2026-10-09): under the brake the rule built here judges NO anchor (PqAnchorRule: "is not judged (the
+ // anchor brake ... is set)"), so the startup says that, not "ARMED ... is now REFUSED". Until this date a braked node wrote the
+ // ARMED line right above the brake line, the opposite of what it does, at the one moment an operator reads the journal to
+ // see whether the brake took.
+ if (log.IsInfo)
+ log.Info(roundCfg.AnchorDisabled
+ ? $"AERE PQ ANCHOR: configured on this node (from height {anchorBlock} every {interval} block(s), chainId {chainId}) " +
+ $"but NOT judged: the emergency brake {AereCommitRoundConfig.EnvAnchorDisable} is set, so anchor certificates are " +
+ "TOLERATED, not checked, until the brake is removed on this node"
+ : $"AERE PQ ANCHOR: ARMED on this node - certificates required from height {anchorBlock} " +
+ $"every {interval} block(s), key sets bound at [{string.Join(", ", registries.Heights)}], " +
+ $"chainId {chainId}. A header at an anchor height without a valid certificate is now REFUSED." +
+ (v2Block >= 0 ? $" Scheme-tagged (v2) certificates required from {v2Block}" +
+ (schemeSchedule is null ? " (no scheme schedule: Falcon only)." : $" under schedule {schemeSchedule}.") : ""));
+ return new PqAnchorRule(config, registries, (long)chainId, schemeSchedule, roundCfg);
+ }
+ catch (Exception e)
+ {
+ // FAIL CLOSED ON THE CONFIGURATION, not on the headers: a node told to check anchors
+ // but unable to build the rule must not quietly follow the chain as if nothing had
+ // been asked of it. It refuses to arm and says exactly why, which is what makes the
+ // difference visible instead of silent.
+ log.Error($"AERE PQ ANCHOR: REFUSING TO ARM - {e.Message}. This node will NOT check anchor " +
+ "certificates. Fix the configuration or remove it; a half-configured anchor check is " +
+ "indistinguishable from none, which is exactly what this message exists to make loud.");
+ return new PqAnchorRule(null, null, (long)chainId, null, roundCfg);
+ }
}
protected override bool ValidateExtraData(BlockHeader header, IReleaseSpec spec, bool isUncle, ref string? error)
@@ -71,4 +212,102 @@ public sealed class AereQbftHeaderValidator : HeaderValidator
error = "AERE-QBFT: timestamp before parent";
return false;
}
+
+ ///
+ /// Every stock check first; the anchor check only ever removes headers the base class accepted.
+ ///
+ protected override bool Validate(BlockHeader header, BlockHeader? parent, bool isUncle, out string? error)
+ {
+ if (!base.Validate(header, parent, isUncle, out error)) return false;
+ // AIP-22 stage 4 checkpoint: before the anchor check and independent of it (a header without a hash at a checkpoint
+ // height is refused, fail-closed)
+ string? cp = _checkpoint.Refusal(header.Number, header.Hash?.Bytes.ToArray() ?? Array.Empty());
+ if (cp is not null)
+ {
+ error = cp;
+ if (_log.IsWarn) _log.Warn(cp);
+ return false;
+ }
+ if (isUncle || parent is null || !_anchorRule.Configured) return true;
+
+ QbftBlockHeader? h = TryConvert(header);
+ QbftBlockHeader? p = TryConvert(parent);
+ if (h is null || p is null)
+ {
+ // A header the base class accepted but whose QBFT view cannot be rebuilt: say so out
+ // loud rather than let it pass unremarked. Silence here would be the same failure this
+ // whole rule exists to end.
+ if (_log.IsWarn)
+ _log.Warn($"AERE PQ ANCHOR: could not rebuild the QBFT view of block {header.Number}; " +
+ "its anchor certificate was NOT checked.");
+ return true;
+ }
+
+ PqAnchorResult result = _anchorRule.Check(h, p);
+ if (result.IsRefusal)
+ {
+ error = $"AERE PQ ANCHOR: {result.Detail}";
+ if (_log.IsWarn) _log.Warn($"AERE PQ ANCHOR: block {header.Number} REJECTED - {result.Detail}");
+ return false;
+ }
+ if (result.IsValid && _log.IsInfo)
+ _log.Info($"AERE PQ ANCHOR: {result.Detail}");
+ return true;
+ }
+
+ /// Nethermind header -> the QBFT view the anchor rule reads.
+ private static QbftBlockHeader? TryConvert(BlockHeader h)
+ {
+ try
+ {
+ return new QbftBlockHeader
+ {
+ Number = h.Number,
+ ParentHash = h.ParentHash?.Bytes.ToArray() ?? Array.Empty(),
+ OmmersHash = h.UnclesHash?.Bytes.ToArray() ?? Array.Empty(),
+ Beneficiary = h.Beneficiary?.Bytes.ToArray() ?? Array.Empty(),
+ StateRoot = h.StateRoot?.Bytes.ToArray() ?? Array.Empty(),
+ TransactionsRoot = h.TxRoot?.Bytes.ToArray() ?? Array.Empty(),
+ ReceiptsRoot = h.ReceiptsRoot?.Bytes.ToArray() ?? Array.Empty(),
+ LogsBloom = h.Bloom?.Bytes.ToArray() ?? Array.Empty(),
+ Difficulty = Minimal(h.Difficulty.ToBigEndian()),
+ GasLimit = MinimalOf(h.GasLimit),
+ GasUsed = MinimalOf(h.GasUsed),
+ Timestamp = MinimalOf((long)h.Timestamp),
+ ExtraData = h.ExtraData ?? Array.Empty(),
+ MixHash = h.MixHash?.Bytes.ToArray() ?? Array.Empty(),
+ Nonce = NonceBytes(h.Nonce),
+ BaseFeePerGas = h.BaseFeePerGas.IsZero ? null : Minimal(h.BaseFeePerGas.ToBigEndian()),
+ WithdrawalsRoot = h.WithdrawalsRoot?.Bytes.ToArray(),
+ BlobGasUsed = h.BlobGasUsed is null ? null : MinimalOf((long)h.BlobGasUsed.Value),
+ ExcessBlobGas = h.ExcessBlobGas is null ? null : MinimalOf((long)h.ExcessBlobGas.Value),
+ ParentBeaconBlockRoot = h.ParentBeaconBlockRoot?.Bytes.ToArray(),
+ RequestsHash = h.RequestsHash?.Bytes.ToArray(),
+ };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static byte[] NonceBytes(ulong nonce)
+ {
+ byte[] b = BitConverter.GetBytes(nonce);
+ if (BitConverter.IsLittleEndian) Array.Reverse(b);
+ return b;
+ }
+
+ private static byte[] MinimalOf(long value) =>
+ value == 0 ? Array.Empty()
+ : Minimal(((BigInteger)value).ToByteArray(isUnsigned: true, isBigEndian: true));
+
+ private static byte[] Minimal(byte[] be)
+ {
+ int i = 0;
+ while (i < be.Length && be[i] == 0) i++;
+ byte[] r = new byte[be.Length - i];
+ Array.Copy(be, i, r, 0, r.Length);
+ return r;
+ }
}
diff --git a/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs
index 35bd376..7547017 100644
--- a/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs
@@ -53,7 +53,9 @@ public sealed class AereQbftSealValidator : ISealValidator
QbftValidationResult r = QbftHeaderValidator.Validate(
qh,
expectedBlockHash: null, // hash equality is enforced by HeaderValidator.ValidateHash
- parentProposer: parentProposer);
+ parentProposer: parentProposer,
+ // AERE D-403: the list is judged against the set after the parent (Besu's BftValidatorsValidationRule)
+ parent: parent is null ? null : NethermindHeaderAdapter.ToQbft(parent));
if (!r.IsValid && _logger.IsWarn)
_logger.Warn($"AERE-QBFT seal invalid for block {header.Number}: {r.RejectReason} " +
diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs
index 84fd7cf..ca9a705 100644
--- a/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs
@@ -49,11 +49,12 @@ public static class AerePqcGasSchedule
{
// AERE FIX 2: a short input (no full outLen word) produces NO output and the
// reference chain (Besu) charges ZERO data words for it, a flat SHAKE256_BASE
- // of 60 gas. Measured by sweeping every length 0..31 against live 0x0AE5 with a
- // probe calibrated on IDENTITY and SHA2-256: all 31 non-empty short lengths
- // cost 60, none cost 72. Charging ceil(input.Length/32) here (the previous
- // behaviour) diverges by 12 gas on every such call. Note len(input)==0 is the
- // only short length where both forms agree, which is why this went unnoticed.
+ // of 60 gas. Measured by sweeping every length 0..31 against live 0x0AE5 with
+ // an in-EVM STATICCALL gas differential calibrated on IDENTITY: all 31
+ // non-empty short lengths cost 60, none cost 72. Charging ceil(input.Length/32)
+ // here (the previous behaviour) diverges by 12 gas on every such call. Note
+ // len(input)==0 is the only short length where both forms agree, which is why
+ // this went unnoticed.
dataLen = 0;
outLen = 0;
}
@@ -63,10 +64,11 @@ public static class AerePqcGasSchedule
dataLen = input.Length - 32;
}
- // AERE FIX: Besu prices the input and the output as SEPARATE word counts, not a
- // combined length. Verified against live Besu at 0x0AE5 with a hand-assembled gas
- // probe: "abc"/out=1 -> 84, "abc"/out=17 -> 84, "abc"/out=33 -> 96. Those fit
- // 60 + 12*(ceil(dataLen/32) + ceil(outLen/32)) and do NOT fit the combined form.
+ // AERE FIX 1: Besu prices the input and the output as SEPARATE word counts, not a
+ // combined length (SHAKE256PrecompiledContract.java:70). The two forms differ by
+ // exactly one word whenever dataLen%32 and outLen%32 are both non-zero and sum to
+ // at most 32. Measured on live 0x0AE5: data=1/out=1 -> 84, data=100/out=33 -> 132.
+ // Those fit 60 + 12*(ceil(dataLen/32) + ceil(outLen/32)) and NOT the combined form.
long words = (dataLen + 31) / 32 + (outLen + 31) / 32; // ceil each, separately
return ShakeWordGas * words;
}
diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs
index 5dad9e5..3f3282c 100644
--- a/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs
@@ -41,4 +41,26 @@ public sealed class AerePqcPrecompileProvider : IPrecompileProvider
}
public FrozenDictionary GetPrecompiles() => _all;
+
+ /// The AERE band alone, the contribution to EthereumPrecompileProvider.AereBand.
+ public static IEnumerable> Band() =>
+ [
+ new(Falcon512Precompile.Address, new CodeInfo(Falcon512Precompile.Instance)),
+ new(Falcon1024Precompile.Address, new CodeInfo(Falcon1024Precompile.Instance)),
+ new(MlDsa44Precompile.Address, new CodeInfo(MlDsa44Precompile.Instance)),
+ new(SlhDsa128sPrecompile.Address, new CodeInfo(SlhDsa128sPrecompile.Instance)),
+ new(Shake256Precompile.Address, new CodeInfo(Shake256Precompile.Instance)),
+ ];
+}
+
+///
+/// AERE 2026-09-23: registers the band with EthereumPrecompileProvider when this assembly loads (it loads when the container that
+/// registers AerePqcPrecompileProvider is built, before any block is processed), so the code repositories the block-access-list
+/// executor builds directly also know 0x0AE1-0x0AE5. Measured on testnet 28001 (EIP-7928 at genesis): without it block 3,135,404
+/// failed with KeyNotFoundException for 0x...0ae1 in InstructionCall.
+///
+internal static class AerePqcBandRegistration
+{
+ [System.Runtime.CompilerServices.ModuleInitializer]
+ internal static void Register() => EthereumPrecompileProvider.AereBand = AerePqcPrecompileProvider.Band;
}
diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs
index a1af169..9e370e1 100644
--- a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs
@@ -18,19 +18,47 @@ namespace Nethermind.AerePqc.Qbft;
public sealed class QbftExtraData
{
+ /// The opening of the refusal when element 6 is not a certificate the first client's codec decodes (AERE-D489). The anchor
+ /// rule names such a refusal AERE-PQ-V3-03 at every anchor height (pas 11, C-03 / part 1, A.1(b)), as the first client's codec
+ /// calls its own refusal of the slot "the AERE-PQ-V3-03 verdict" (QbftExtraDataCodec, the version dispatcher); one text, read in
+ /// one place (PqAnchorRule.Element6Code).
+ public const string CertificateSlotRefusalText = "QBFT extraData: element 6 is not a certificate the first client's codec decodes";
+
public byte[] Vanity { get; }
public IReadOnlyList Validators { get; }
public IReadOnlyList CommittedSeals { get; }
+
+ ///
+ /// AERE: the SIXTH element, verbatim, when the header carries one - the post-quantum anchor
+ /// certificate (PqAnchorV2). Null on a five-element header. Kept as raw encoded bytes because
+ /// the anchor digest is taken over exactly these bytes: re-encoding from parsed parts could
+ /// silently change what the digest covers. Until 2026-08-28 this element was parsed and
+ /// DROPPED, so the second client could tolerate a certificate but never check one.
+ ///
+ public byte[]? CertificateRaw { get; }
public byte[] RoundBytes { get; } // minimal big-endian; empty == round 0
+
+ /// AERE AIP-22 stage 5: the proposer's post-quantum seal (the SEVENTH element), or null below H_hdr.
+ public int? ProposerSealIndex { get; }
+ public byte[]? ProposerSealSignature { get; }
public long Round { get; }
private readonly RlpItem _vanityItem;
private readonly RlpItem _validatorsItem;
private readonly RlpItem _voteItem;
+ ///
+ /// AERE D-403: the validator vote this header carries, as Besu encodes it (a two-item list: the 20-byte recipient and 0xFF to
+ /// add / 0x00 to drop), or null for the empty vote list. A vote element of any other shape is null here, so it never relaxes
+ /// the validator-list rule ().
+ ///
+ public byte[]? VoteRecipient =>
+ _voteItem is RlpList { Items.Count: 2 } l && l.Items[0] is RlpBytes { Value.Length: 20 } r && l.Items[1] is RlpBytes ? r.Value : null;
+
private QbftExtraData(RlpItem vanity, RlpItem validators, RlpItem vote,
byte[] roundBytes, IReadOnlyList seals,
- IReadOnlyList validatorAddrs)
+ IReadOnlyList validatorAddrs, byte[]? certificateRaw = null,
+ int? proposerSealIndex = null, byte[]? proposerSealSignature = null)
{
_vanityItem = vanity;
_validatorsItem = validators;
@@ -38,6 +66,9 @@ public sealed class QbftExtraData
Vanity = ((RlpBytes)vanity).Value;
Validators = validatorAddrs;
CommittedSeals = seals;
+ CertificateRaw = certificateRaw;
+ ProposerSealIndex = proposerSealIndex;
+ ProposerSealSignature = proposerSealSignature;
RoundBytes = roundBytes;
Round = ToLong(roundBytes);
}
@@ -56,8 +87,11 @@ public sealed class QbftExtraData
// EncodeWithoutSeals below rebuilds exactly five items from the retained subtrees, so the
// sixth is dropped before re-encoding - which is precisely what Besu does, and precisely why
// the Falcon certificate never enters the block hash.
- if (Rlp.Decode(extraData) is not RlpList top || (top.Items.Count != 5 && top.Items.Count != 6))
- throw new FormatException("QBFT extraData must be an RLP list of 5 or 6 items");
+ // AIP-22 stage 5 (2026-09-23): a SEVENTH element, the proposer's seal [index, signature], after the certificate slot,
+ // which is then ALWAYS written (an empty list without a certificate); the position tells them apart, the same rule as
+ // the Besu-derived codec. Seven is the new ceiling.
+ if (Rlp.Decode(extraData) is not RlpList top || top.Items.Count < 5 || top.Items.Count > 7)
+ throw new FormatException("QBFT extraData must be an RLP list of 5, 6 or 7 items");
RlpItem vanity = top.Items[0];
if (vanity is not RlpBytes vb || vb.Value.Length != 32)
@@ -74,9 +108,31 @@ public sealed class QbftExtraData
}
RlpItem vote = top.Items[2];
+ // AERE-D489 (2026-10-06): the VOTE is judged here, as the first client's codec judges it (QbftExtraDataCodec.decodeRaw and
+ // decodeVote, then its canonical round trip): the empty list, or exactly [recipient(20 bytes), 0xff] to add / [recipient, the
+ // empty string] to drop. Until this line the element was taken raw and never looked at, and the vote sits UNDER the block
+ // hash: above H_hdr one proposer key was enough for a header with a malformed vote that this client's import rule admitted
+ // and no Besu node decodes (15 conformance vectors, 17 fuzz mutations; RAPORT-1B.md, step 7, round 3). A drop written 0x00,
+ // a type other than 0xff, a recipient that is not 20 bytes, one element, three, a byte string in place of the list: refused.
+ if (VoteFormRefusal(vote) is { } voteWhy)
+ throw new FormatException($"QBFT vote is not the empty list or [recipient(20 bytes), 0xff | empty]: {voteWhy} [AERE-D489]");
if (top.Items[3] is not RlpBytes roundB)
throw new FormatException("QBFT round must be a scalar");
+ // AERE-D414: the round in element 3 is a CANONICAL big-endian scalar, exactly like the proposer-seal index below (line ~140)
+ // and like Besu's rlpInput.readIntScalar, which reads at most four bytes and rejects a leading zero. Without this ToLong reads
+ // the round modulo 2^64, so a nine- or ten-byte round 2^64+r collapses to r, slips under the D-406 cap of 1024 and is admitted
+ // while Besu refuses it (measured by the AIP-22 differential fuzzer, class 2). A leading-zero round (e.g. 0x82000d) is a
+ // distinct byte string that RLP re-encodes UNCHANGED, so the D-413 round trip below cannot see it: it is caught only here.
+ // Four bytes is the exact width Besu decodes with and leaves ample room for the 1024 cap (two bytes); checking it at read time
+ // rejects a wide or non-minimal round at every height, not only where the seal-rule cap runs.
+ if (roundB.Value.Length > 4 || (roundB.Value.Length > 0 && roundB.Value[0] == 0))
+ throw new FormatException("QBFT round is a non-canonical scalar: at most 4 bytes, no leading zero [AERE-D414]");
+ // AERE-D489: the first client reads the round with readIntScalar into a SIGNED int and its canonical round trip writes a
+ // negative int back as eight bytes, so a four-byte round from 2^31 up never round-trips there and the header is refused.
+ // This decoder read it unsigned and admitted it (below H_hdr, with a genuine commit quorum over that round).
+ if (!IsIntScalarOfFirstClient(roundB.Value))
+ throw new FormatException("QBFT round is 2^31 or above: the first client reads it as a signed int and refuses it [AERE-D489]");
if (top.Items[4] is not RlpList sealsList)
throw new FormatException("QBFT committedSeals must be a list");
@@ -85,10 +141,53 @@ public sealed class QbftExtraData
{
if (it is not RlpBytes sb || sb.Value.Length != 65)
throw new FormatException("QBFT committed seal must be 65 bytes");
+ // AERE-D489: the first client builds every committed seal through SECPSignature.create AT DECODE TIME: recId 0 or 1 and
+ // r, s in 1..n-1. This decoder asked for 65 bytes and nothing else, so a genuine header whose seals had v rewritten to
+ // 27 / 28 was a second byte string for the same block (the seals are not under the hash) that only this client admitted.
+ if (Secp256k1Recover.SealFormRefusal(sb.Value) is { } sealWhy)
+ throw new FormatException($"QBFT committed seal {seals.Count} is not a signature the first client decodes: {sealWhy} [AERE-D489]");
seals.Add(sb.Value);
}
- return new QbftExtraData(vanity, top.Items[1], vote, roundB.Value, seals, vals);
+ byte[]? certificate = top.Items.Count >= 6 ? top.Items[5].Encode() : null;
+ int? psIndex = null; byte[]? psSig = null;
+ if (top.Items.Count == 6 && top.Items[5] is RlpList { Items.Count: 0 })
+ throw new FormatException("QBFT extraData: an empty certificate slot without a proposer seal is non-canonical");
+ if (top.Items.Count == 7)
+ {
+ if (top.Items[5] is not RlpList) throw new FormatException("QBFT extraData: the certificate slot must be a list");
+ if (top.Items[5] is RlpList { Items.Count: 0 }) certificate = null;
+ if (top.Items[6] is not RlpList { Items.Count: 2 } ps || ps.Items[0] is not RlpBytes pi || ps.Items[1] is not RlpBytes psb)
+ throw new FormatException("QBFT extraData: the proposer seal must be [index, signature]");
+ if (pi.Value.Length > 4 || (pi.Value.Length > 0 && pi.Value[0] == 0))
+ throw new FormatException("QBFT extraData: the proposer seal index is not a canonical scalar");
+ // AERE-D489: the same signed read as the round; an index from 2^31 up is refused by the first client, never truncated here
+ if (!IsIntScalarOfFirstClient(pi.Value))
+ throw new FormatException("QBFT extraData: the proposer seal index is 2^31 or above; the first client reads it as a signed int and refuses it [AERE-D489]");
+ psIndex = (int)ToLong(pi.Value); psSig = psb.Value;
+ }
+ // AERE-D489: ELEMENT 6 IS DECODED AT EVERY HEIGHT, through the same dispatcher as the first client's codec. Until this line
+ // the slot was kept as raw bytes and only an ANCHOR height looked inside, so between anchors a certificate that no Besu node
+ // decodes (a byte string, an unknown version, a non-canonical v2 or v3, a v1 row that is not [index, signature]) rode along
+ // on a header this client admitted: other bytes for the same block where the vanity was left alone, another block where it
+ // was not (the divergence the report calls D1). What a certificate must PROVE stays with the anchor rule; this is only
+ // whether the bytes are a certificate at all.
+ if (top.Items.Count >= 6 && CertificateSlotRefusal(top.Items[5]) is { } slotWhy)
+ throw new FormatException($"{CertificateSlotRefusalText}: {slotWhy} [AERE-D489]");
+ // AERE-D413: Besu computes the QBFT block hash AND the committed-seal pre-image over a RE-ENCODE of the decoded extraData
+ // (QbftExtraDataCodec.decodeRaw, gate 2 of 2), so a non-canonical RLP encoding that a lenient decoder tolerates would give two
+ // distinct header byte strings ONE block hash: the canonical header's genuine committed seals stay valid on the rewritten
+ // bytes, so anyone could take a good header off the chain and re-serialize its extraData into one only this client accepts.
+ // Require the decode to round-trip to exactly the received bytes, the same canonical loop Besu runs. Our RLP encoder is
+ // canonical (minimal length prefixes, single bytes < 0x80 self-encoded, scalars without leading zeros), proven byte-for-byte on
+ // real chain-2800 and 28001 headers in AereQbftExtraDataCanonicalTests, so every genuine header round-trips unchanged; only a
+ // non-minimal length or a mis-self-encoded byte anywhere in the structure is rejected here.
+ byte[] canonical = top.Encode();
+ if (!canonical.AsSpan().SequenceEqual(extraData))
+ throw new FormatException($"QBFT extraData is non-canonical: it is {extraData.Length} bytes but its canonical re-encoding is "
+ + $"{canonical.Length} bytes. A QBFT block hash is computed over the re-encoded form, so a non-canonical encoding would "
+ + "give two header byte strings one block hash. Rejected [AERE-D413]");
+ return new QbftExtraData(vanity, top.Items[1], vote, roundB.Value, seals, vals, certificate, psIndex, psSig);
}
///
@@ -113,4 +212,101 @@ public sealed class QbftExtraData
foreach (byte b in be) v = (v << 8) | b;
return v;
}
+
+ // ---------------------------------------------------------------- AERE-D489: the first client's codec, transcribed
+ //
+ // Everything below is QbftExtraDataCodec.decodeRaw of the Besu-derived client (consensus/qbft/.../QbftExtraDataCodec.java, lines
+ // 134-314 and decodeVote 408-426), read from the CODE and not from a description of it. That codec refuses in two ways: a read
+ // that throws, and a canonical round trip at the end (it re-encodes what it decoded and demands the bytes it was given). The
+ // accepted set is therefore exactly the image of its encoder, and each function here names one part of that image. Nothing in
+ // this block may be STRICTER than that codec: a header it admits and this client refuses is a block the tenth validator cannot
+ // follow. The two anchor decoders this client already had are stricter than the codec on purpose (PqAnchor.DecodeCertificate
+ // wants sorted indices, PqAnchorV2.Decode refuses an empty signature): they are the anchor RULE's reading and are not used here.
+
+ ///
+ /// The scalar the first client reads with readIntScalar and writes back with writeIntScalar: at most four bytes, no leading zero
+ /// byte, and below 2^31 (a negative int is written back as eight bytes, so it never round-trips).
+ ///
+ private static bool IsIntScalarOfFirstClient(byte[] v) =>
+ v.Length <= 4 && (v.Length == 0 || v[0] != 0) && !(v.Length == 4 && v[0] >= 0x80);
+
+ /// decodeVote and the round trip: the empty list, [recipient, 0xff] or [recipient, the empty string]. Null = the vote
+ /// is one of the three.
+ private static string? VoteFormRefusal(RlpItem vote)
+ {
+ if (vote is not RlpList list) return "it is a byte string, not a list";
+ if (list.Items.Count == 0) return null;
+ if (list.Items.Count != 2) return $"it carries {list.Items.Count} element(s)";
+ if (list.Items[0] is not RlpBytes recipient) return "the recipient is a list";
+ if (recipient.Value.Length != 20) return $"the recipient is {recipient.Value.Length} bytes long";
+ if (list.Items[1] is not RlpBytes type) return "the vote type is a list";
+ if (type.Value.Length == 0) return null; // drop: the empty string, as the encoder writes it
+ if (type.Value.Length == 1 && type.Value[0] == 0xFF) return null; // add
+ return type.Value.Length == 1 && type.Value[0] == 0x00
+ ? "the drop is written 0x00; the first client writes the empty string and refuses the other form"
+ : $"the vote type is 0x{Convert.ToHexString(type.Value).ToLowerInvariant()}, neither 0xff (add) nor empty (drop)";
+ }
+
+ ///
+ /// The dispatcher on element 6: the empty list (legal only by position, judged by the caller), the v1 list of [index, signature],
+ /// the scalar 2 and a canonical non-empty v2 row list, the scalar 3 and a canonical v3; any other version refused by name. Null =
+ /// the first client's codec decodes these bytes.
+ ///
+ private static string? CertificateSlotRefusal(RlpItem slot)
+ {
+ if (slot is not RlpList cert) return "it is a byte string, not a list";
+ if (cert.Items.Count == 0) return null;
+ if (cert.Items[0] is RlpList)
+ {
+ // v1, the Falcon list, as that codec reads it: [index, signature] rows, the index a signed-int scalar, the signature any
+ // byte string. Order and repetition are the anchor rule's business, at an anchor height.
+ try { _ = Nethermind.AerePqc.Consensus.Finality.AereCertifiedDecision.ReadV1AsWritten(cert.Encode()); }
+ catch (FormatException e) { return "the v1 list does not decode: " + e.Message; }
+ return null;
+ }
+ byte[] versionBytes = ((RlpBytes)cert.Items[0]).Value;
+ if (!IsIntScalarOfFirstClient(versionBytes))
+ return "it opens with an unreadable version scalar (more than four bytes, a leading zero, or 2^31 and above)";
+ long version = ToLong(versionBytes);
+ if (version == Nethermind.AerePqc.Consensus.Anchor.PqAnchorV2.Version) return V2SlotRefusal(cert);
+ if (version == Nethermind.AerePqc.Consensus.Anchor.PqAnchorV3.Version)
+ {
+ try { _ = Nethermind.AerePqc.Consensus.Anchor.PqAnchorV3.Decode(cert.Encode()); }
+ catch (FormatException e) { return "it does not decode as a canonical v3 certificate: " + e.Message; }
+ return null;
+ }
+ return $"{Nethermind.AerePqc.Consensus.Anchor.PqAnchorV3.V3_03}: it names version {version}; this build understands the v1 list, 2 and 3";
+ }
+
+ ///
+ /// A v2 certificate as the first client's codec admits it (PqAnchorV2.decode there, then the codec's round trip): exactly
+ /// [2, rows], 1..64 rows of exactly [scheme, index, signature], scheme a known wire tag, index a signed-int scalar, rows strictly
+ /// increasing by (index, scheme), the signature ANY byte string. An empty row list is refused because that encoder never writes
+ /// one (no certificate is no element 6). The empty SIGNATURE is not refused here: that codec admits it, and at an anchor height
+ /// the rule refuses it in both clients.
+ ///
+ private static string? V2SlotRefusal(RlpList cert)
+ {
+ if (cert.Items.Count != 2) return $"a v2 certificate is [2, rows], this one carries {cert.Items.Count} elements";
+ if (cert.Items[1] is not RlpList rows) return "the rows of the v2 certificate are a byte string, not a list";
+ if (rows.Items.Count == 0) return "a v2 certificate without rows: the first client never writes one, so it does not round-trip";
+ if (rows.Items.Count > Nethermind.AerePqc.Consensus.Anchor.PqAnchorV2.MaxSeals)
+ return $"the v2 certificate carries {rows.Items.Count} rows, above {Nethermind.AerePqc.Consensus.Anchor.PqAnchorV2.MaxSeals}";
+ long previousIndex = -1, previousScheme = -1;
+ foreach (RlpItem it in rows.Items)
+ {
+ if (it is not RlpList row || row.Items.Count != 3
+ || row.Items[0] is not RlpBytes schemeB || row.Items[1] is not RlpBytes indexB || row.Items[2] is not RlpBytes)
+ return "a v2 row is not [scheme, index, signature]";
+ if (!IsIntScalarOfFirstClient(schemeB.Value) || !IsIntScalarOfFirstClient(indexB.Value))
+ return "a v2 row carries a scheme or an index that is not a canonical scalar below 2^31";
+ long scheme = ToLong(schemeB.Value), index = ToLong(indexB.Value);
+ if (scheme > 0xff || Nethermind.AerePqc.Consensus.Anchor.SchemeSeal.SchemeIdOf((byte)scheme) is null)
+ return $"a v2 row names the unknown scheme tag 0x{scheme:x}";
+ if (!(index > previousIndex || (index == previousIndex && scheme > previousScheme)))
+ return "the v2 rows are not strictly increasing by (index, scheme)";
+ previousIndex = index; previousScheme = scheme;
+ }
+ return null;
+ }
}
diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs
index 59bbea7..1d5cab9 100644
--- a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs
@@ -38,10 +38,18 @@ public sealed class QbftValidationResult
/// Overall verdict: seals + proposer are consensus-valid.
public bool IsValid =>
- ProposerInValidatorSet
- && QuorumMet
- && AllSealsDistinctValidators
+ ListOk
+ && ProposerInValidatorSet
+ && SealsOk
&& (!RoundRobinChecked || RoundRobinProposerOk);
+
+ /// AERE D-403: the validator list is the set after the parent (checked only when the parent is given; true
+ /// otherwise). The SAME value the reject reason is built from, like .
+ public bool ListOk { get; init; } = true;
+
+ /// The seal rule in force at the header's height held: below H_hdr a quorum of distinct in-set committed seals,
+ /// from H_hdr (AIP-22 stage 5) the proposer's post-quantum seal. The SAME value the reject reason is built from.
+ public bool SealsOk { get; init; }
}
public static class QbftHeaderValidator
@@ -76,9 +84,12 @@ public static class QbftHeaderValidator
public static QbftValidationResult Validate(
QbftBlockHeader header,
byte[]? expectedBlockHash = null,
- byte[]? parentProposer = null)
+ byte[]? parentProposer = null,
+ QbftBlockHeader? parent = null)
{
QbftExtraData extra = QbftExtraData.Decode(header.ExtraData);
+ // AERE D-403: with the parent known, the list is judged against the set after the parent BEFORE anything is computed on it
+ string? listReject = parent is null ? null : ValidatorListRefusal(header, extra, parent);
var validators = extra.Validators.ToList();
int n = validators.Count;
int quorum = Quorum(n);
@@ -96,7 +107,9 @@ public static class QbftHeaderValidator
bool allDistinctInSet = extra.CommittedSeals.Count > 0;
foreach (byte[] seal in extra.CommittedSeals)
{
- byte[]? signer = Secp256k1Recover.RecoverAddress(commitDigest, seal);
+ byte[]? signerRecovered = Secp256k1Recover.RecoverAddress(commitDigest, seal);
+ // AIP-22 stage 3: from H_id a committed seal is authored by the id of its signer's v3 row
+ byte[]? signer = signerRecovered is null ? null : Nethermind.AerePqc.Consensus.Anchor.AerePqIdentity.Author(signerRecovered, header.Number);
if (signer == null) { allDistinctInSet = false; recovered.Add(Array.Empty()); continue; }
recovered.Add(signer);
if (!ContainsAddr(validators, signer) || ContainsAddr(seen, signer))
@@ -150,12 +163,37 @@ public static class QbftHeaderValidator
: (int)(((IndexOfNextProposer(sorted, parentProposer) + extra.Round) % n + n) % n); // missing proposer (genesis 0x0 / removed)
expectedProposer = sorted[expectedIdx];
rrOk = ByteEq(header.Beneficiary, expectedProposer);
+ // D-331 (2026-09-03, testnet 28001 block 168034): a block PREPARED at an earlier round is RE-PROPOSED
+ // unchanged at a later round (Besu re-proposes the locked value; only the round in extraData changes),
+ // so its coinbase is the proposer of the round it was BUILT in, not of the round it was committed in.
+ // QBFT accepts exactly that. Measured: a block built by the round-0 proposer, committed at round 1,
+ // was refused here as "proposer does not match round-robin expectation", the node dropped every peer
+ // that served it and stayed behind. The coinbase must match the expected proposer of SOME round
+ // r' <= round; a proposer of a LATER round (or a non-proposer) is still refused.
+ // the rotation is periodic in n, so rounds 0..min(round, n) - 1 already cover the set; the bound keeps a crafted
+ // round of 2^31 from costing 2^31 iterations (adversarial review of D-406, 2026-09-25); the verdict is unchanged
+ for (long rr = 0; !rrOk && rr < Math.Min(extra.Round, (long)n); rr++)
+ {
+ int idx = parentIdx >= 0
+ ? (int)(((parentIdx + 1 + rr) % n + n) % n)
+ : (int)(((IndexOfNextProposer(sorted, parentProposer) + rr) % n + n) % n);
+ if (ByteEq(header.Beneficiary, sorted[idx])) rrOk = true;
+ }
}
+ // AIP-22 stage 5: from H_hdr the committed-seal element MUST be empty and the proposer's post-quantum seal decides;
+ // below it the committed seals decide, unchanged. Round-robin and hash are checked the same either way.
+ string? sealReject = Nethermind.AerePqc.Consensus.Anchor.AerePqHeaderSeal.Active(header.Number)
+ // AERE D-406: the proposer of the round in element 3 (the round-robin above, null without the parent's proposer) may
+ // seal a block it re-proposed; the beneficiary stays the builder's
+ ? Nethermind.AerePqc.Consensus.Anchor.AerePqHeaderSeal.Refusal(header.Number, header.Beneficiary, extra, commitDigest, expectedProposer,
+ parentUnknown: parentProposer is null)
+ : !allDistinctInSet ? "a committed seal did not recover to a distinct in-set validator"
+ : !quorumMet ? $"committed seals below quorum ({seen.Count}/{quorum})" : null;
string? reject = null;
- if (!proposerInSet) reject = "proposer (coinbase) not in validator set";
- else if (!allDistinctInSet) reject = "a committed seal did not recover to a distinct in-set validator";
- else if (!quorumMet) reject = $"committed seals below quorum ({seen.Count}/{quorum})";
+ if (listReject is not null) reject = listReject;
+ else if (!proposerInSet) reject = "proposer (coinbase) not in validator set";
+ else if (sealReject is not null) reject = sealReject;
else if (rrChecked && !rrOk) reject = "proposer does not match round-robin expectation";
else if (!hashOk) reject = "computed block hash does not match expected";
@@ -172,11 +210,54 @@ public static class QbftHeaderValidator
CommittedSealCount = extra.CommittedSeals.Count,
QuorumMet = quorumMet,
AllSealsDistinctValidators = allDistinctInSet,
+ SealsOk = sealReject is null,
+ ListOk = listReject is null,
RecoveredSigners = recovered,
RejectReason = reject,
};
}
+ ///
+ /// AERE D-403, the rule Besu applies as BftValidatorsValidationRule: the validator list of a header is the set AFTER its parent,
+ /// never a set the header declares for itself. Measured 2026-09-24 (conformance vector 28001-h-id-3080010-self-declared-set):
+ /// without this, a header naming ONE invented validator, with that validator's valid committed seal, was admitted on the import
+ /// path, while Besu refused it; nothing but the next anchor stopped the tail. The list must be (1) strictly ascending, as Besu
+ /// stores it, and (2) the parent's list through (the
+ /// same list, or its sorted ids at exactly H_id). A vote changes Besu's tally only through the block that carries it, so the set
+ /// after a parent WITHOUT a vote is exactly the parent's list; after a parent WITH a vote the voted validator may have been added
+ /// or dropped (whether the tally reached its threshold is not visible from two headers), so there the list may differ from the
+ /// parent's by exactly ONE member. A header that forks off a real parent therefore still lists the real validators (plus or
+ /// minus one) and needs a quorum of THEIR seals. Null = the list holds.
+ ///
+ public static string? ValidatorListRefusal(QbftBlockHeader header, QbftExtraData extra, QbftBlockHeader parent)
+ {
+ IReadOnlyList list = extra.Validators;
+ for (int i = 1; i < list.Count; i++)
+ if (CompareAddr(list[i - 1], list[i]) >= 0)
+ return "validators are not sorted in ascending order (the list is the set after the parent, sorted)";
+ QbftExtraData pe;
+ try { pe = QbftExtraData.Decode(parent.ExtraData); }
+ catch (Exception e) { return "the parent's extraData does not decode, so the set after it is unknown: " + e.Message; }
+ List expected = new(Nethermind.AerePqc.Consensus.Anchor.AerePqIdentity.SetFor(header.Number, pe.Validators));
+ expected.Sort(CompareAddr);
+ if (SameList(list, expected)) return null;
+ if (pe.VoteRecipient is not null)
+ {
+ int onlyHere = list.Count(a => IndexOfAddr(expected, a) < 0);
+ int onlyThere = expected.Count(a => !list.Any(b => ByteEq(a, b)));
+ if (onlyHere + onlyThere == 1) return null;
+ }
+ return $"incorrect validators: the list is not the set after the parent (block {parent.Number}"
+ + (pe.VoteRecipient is null ? ", which carries no vote" : ", whose vote changes at most one member") + ")";
+ }
+
+ private static bool SameList(IReadOnlyList a, List b)
+ {
+ if (a.Count != b.Count) return false;
+ for (int i = 0; i < a.Count; i++) if (!ByteEq(a[i], b[i])) return false;
+ return true;
+ }
+
private static bool ByteEq(byte[] a, byte[] b)
{
if (a.Length != b.Length) return false;
diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs
index 554b0f2..9e931f1 100644
--- a/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs
+++ b/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs
@@ -19,22 +19,44 @@ public static class Secp256k1Recover
private static readonly ECPoint G = Curve.G;
private static readonly BigInteger N = Curve.N;
+ ///
+ /// AERE-D489 (2026-10-06): whether 65 bytes are a signature the first client DECODES. Transcribed from its SECPSignature.create,
+ /// which every ECDSA signature it reads goes through (a committed seal in a header, the commit seal and the signature of a
+ /// consensus message, the claim of a v3 registry row): r and s in 1..n-1 and the last byte, the recovery id, exactly 0 or 1.
+ /// Never 27 / 28: that is the transaction convention, and the first client refuses it here. Null = the form holds. The ONE place
+ /// this client asks the question: the header decoder asks it of every committed seal, the recovery below of every signature.
+ ///
+ public static string? SealFormRefusal(byte[] signature65)
+ {
+ if (signature65 is null || signature65.Length != 65) return "it is not 65 bytes long";
+ int v = signature65[64];
+ if (v != 0 && v != 1) return $"the recovery id (last byte) is {v}, not 0 or 1";
+ var r = new BigInteger(1, signature65, 0, 32);
+ var s = new BigInteger(1, signature65, 32, 32);
+ if (r.SignValue <= 0) return "r is zero";
+ if (s.SignValue <= 0) return "s is zero";
+ if (r.CompareTo(N) >= 0) return "r is not below the group order";
+ if (s.CompareTo(N) >= 0) return "s is not below the group order";
+ return null;
+ }
+
///
/// Recover the 20-byte address that produced
- /// (r[32]||s[32]||v[1], v in {0,1} or {27,28}) over the 32-byte digest.
+ /// (r[32]||s[32]||v[1], v in {0,1}) over the 32-byte digest.
/// Returns null if the signature is malformed or no point can be recovered.
///
public static byte[]? RecoverAddress(byte[] digest32, byte[] signature65)
{
if (digest32.Length != 32 || signature65.Length != 65) return null;
+ // AERE-D489: v is 0 or 1 and nothing else. Until 2026-10-06 this line also took 27 / 28 (v - 27), so a genuine header whose
+ // committed seals had their last byte rewritten recovered to the SAME signers and was admitted on the import path under the
+ // same block hash, with no key at all, while the first client refuses those bytes at decode time. Every caller of this
+ // function reads a signature the first client reads through the same strict decode, so the rule sits here for all of them.
+ if (SealFormRefusal(signature65) is not null) return null;
var r = new BigInteger(1, signature65, 0, 32);
var s = new BigInteger(1, signature65, 32, 32);
- int v = signature65[64];
- int recId = v >= 27 ? v - 27 : v; // accept 0/1 (Besu QBFT) or 27/28
- if (recId != 0 && recId != 1) return null;
- if (r.SignValue <= 0 || s.SignValue <= 0 || r.CompareTo(N) >= 0 || s.CompareTo(N) >= 0)
- return null;
+ int recId = signature65[64];
var e = new BigInteger(1, digest32);
diff --git a/src/Nethermind/Nethermind.Core/AereFloorLapse.cs b/src/Nethermind/Nethermind.Core/AereFloorLapse.cs
index 212a2a8..8a48af3 100644
--- a/src/Nethermind/Nethermind.Core/AereFloorLapse.cs
+++ b/src/Nethermind/Nethermind.Core/AereFloorLapse.cs
@@ -41,7 +41,24 @@ public static class AereFloorLapse
/// Last block of the lapse, inclusive.
public const long LastBlock = 13_087_959;
- /// True only for blocks inside the historical lapse.
+ ///
+ /// First block of the SECOND lapse, inclusive. On 2026-08-12 an operational rollback on one
+ /// validator briefly restored a container whose properties predated the floor fork, and that
+ /// validator's next thirteen proposals, every ninth block from 13,596,033 to 13,596,141, carry
+ /// 875,000,000 wei, one EIP-1559 decay step below the 1 Gwei floor. Measured exhaustively on
+ /// 2026-08-14: exactly thirteen violations in that range and none in the 300 blocks after it.
+ /// The producing client accepted them because its QBFT ruleset does not validate baseFee at
+ /// all; this client stopped at 13,596,032, which is precisely the job its stricter floor
+ /// validation exists to do. The blocks are final, so a follower must carry a dated exception
+ /// for them, bounded in code for the same reason as the first lapse.
+ ///
+ public const long SecondLapseFirstBlock = 13_596_033;
+
+ /// Last block of the second lapse, inclusive.
+ public const long SecondLapseLastBlock = 13_596_141;
+
+ /// True only for blocks inside one of the historical lapses.
public static bool Covers(long blockNumber) =>
- blockNumber >= FirstBlock && blockNumber <= LastBlock;
+ (blockNumber >= FirstBlock && blockNumber <= LastBlock)
+ || (blockNumber >= SecondLapseFirstBlock && blockNumber <= SecondLapseLastBlock);
}
diff --git a/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs b/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs
index bc906f2..21a3e61 100644
--- a/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs
+++ b/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs
@@ -60,7 +60,29 @@ namespace Nethermind.Serialization.Rlp
if (!ReadItem(extraData, ref pos, out int _, out int _, out bool i4IsList)) return false;
if (!i4IsList) return false;
- if (pos != payloadEnd) return false; // exactly 5 items, no trailing
+ // Item 5 (element 6): AERE Ancora V2 Falcon certificate, OPTIONAL.
+ // It is never under keccak: Besu computes the hash from a re-encode of
+ // the decoded extraData and drops it, so we accept it and drop it too.
+ // Refusing it here is what makes this client diverge at the anchor
+ // activation height. What binds the certificate to the hash is the
+ // 32-byte digest D in vanityData (item 0), which is already covered.
+ if (pos != payloadEnd)
+ {
+ if (!ReadItem(extraData, ref pos, out int _, out int _, out bool i5IsList)) return false;
+ if (!i5IsList) return false;
+ }
+
+ // Item 6 (element 7): AERE AIP-22 stage 5 (2026-09-23), the proposer's post-quantum seal
+ // [index, signature], OPTIONAL and only after item 5 (written as an empty list when the header
+ // carries no certificate). Outside every hash exactly like item 5, so it is dropped the same way;
+ // refusing it here would give this client another block hash for every header from H_hdr.
+ if (pos != payloadEnd)
+ {
+ if (!ReadItem(extraData, ref pos, out int _, out int _, out bool i6IsList)) return false;
+ if (!i6IsList) return false;
+ }
+
+ if (pos != payloadEnd) return false; // at most seven items, no trailing
// Re-emit: [ raw(item0), raw(item1), raw(item2), 0x80 (round=0), 0xc0 (no seals) ]
int newPayloadLen = i0Len + i1Len + i2Len + 1 /*0x80*/ + 1 /*0xc0*/;