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*/;