commit 44d6fe4863a0a57f0af61e46f99bf35f97f53f0f Author: Liviu Date: Tue Aug 11 21:34:12 2026 +0300 AERE Network post-quantum additions to Nethermind The second execution client for chain 2800: 23 files that let an independent implementation, in a different language on a different codebase, follow the chain and reach the same state. Published because the files derive from Nethermind under LGPL-3.0, and because a chain whose node software cannot be read cannot have independent operators. Nothing here is useful without upstream Nethermind. Contains no keys, no node addresses and no operational configuration. Two test files carried absolute paths and a host name from the machine they were written on; both were replaced with portable equivalents before this commit, which also makes the tests runnable by anyone. The README states the limits plainly: this client follows and validates, it does not produce mainnet blocks, and it has never been audited by a third party. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5846396 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# AERE Network, second execution client + +These are the AERE Network additions to [Nethermind](https://github.com/NethermindEth/nethermind), +the second execution client for AERE chain 2800. They are not a complete client: they are the files +that had to be added or changed so that an independent implementation, written in a different +language on a different codebase, can follow chain 2800 and reach the same state. + +## Why a second client exists + +Chain 2800 produces every block with Hyperledger Besu today. A consensus bug in Besu would produce a +wrong block and nothing in the validator set would stop it, because every validator runs the same +code. A second implementation is the only thing that can disagree. + +That is the whole point of this repository, and it is also the honest limit of what it achieves so +far: **this client follows and validates, it does not produce blocks on mainnet.** Client diversity +in production does not exist on chain 2800 yet. + +## What is here + +- **`Nethermind.AerePqc/Precompiles/`** — the five post-quantum verifiers live on chain 2800 since + block 9,189,161: Falcon-512, Falcon-1024, ML-DSA-44, SLH-DSA-128s and SHAKE256. They wrap the + Bouncy Castle implementations already on the client classpath; no cryptography is hand-rolled here. +- **`Nethermind.AerePqc/Qbft/`** — QBFT header decoding, extra-data handling and the header hashing + rule, written independently rather than ported, so that agreement between the two clients is + evidence rather than a shared assumption. +- **`Nethermind.AerePqc/Consensus/`** — header and seal validation for the AERE variant of QBFT. +- **`Nethermind.Consensus.Ethash/AereBaseFeeCalculator.cs`** — the AERE base-fee floor, active from + block 10,141,734 with a 1 Gwei floor. +- **`Nethermind.Core/AereFloorLapse.cs`** — the one historical range in which chain 2800 did not + apply its own floor. Read that file: it explains what happened, why the exception belongs at + validation rather than in the calculation, and how the bounds were measured. +- **tests** — including the two that matter most, the follower proof against real chain-2800 headers + and the base-fee floor fork tests. + +## What this repository does not contain + +The full Nethermind client. Get it from upstream and apply these files on top. Nothing here is +useful on its own. + +It also contains no keys, no node addresses, and no operational configuration, by design. + +## Licence + +The files carry `SPDX-License-Identifier: LGPL-3.0-only`, inherited from the Nethermind sources they +derive from. See `LICENSE`. Upstream Nethermind is the authority on its own licensing; if you are +redistributing, check the upstream terms as well as these. + +## Status, stated plainly + +- follows and validates chain 2800: yes +- produces mainnet blocks: no +- reached consensus with Besu as an equal validator: on an isolated testnet only +- audited by a third party: no diff --git a/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs new file mode 100644 index 0000000..8fd4457 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftHeaderValidator.cs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 AERE Network +// 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: +// +// 1. ValidateExtraData: QBFT carries a large RLP extraData +// [vanity, validators, vote, round, committedSeals] (hundreds of bytes), +// so the Ethereum 32-byte MaximumExtraDataSize cap does not apply. We accept +// any well-formed QBFT extraData and reject anything that fails to decode. +// +// 2. Seal validation is delegated (by the base class) to the injected +// ISealValidator = AereQbftSealValidator, which does committed-seal recovery, +// quorum, and the round-robin proposer check. +// +// 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 Nethermind.Blockchain; +using Nethermind.Consensus; +using Nethermind.Consensus.Validators; +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Logging; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.AerePqc.Consensus; + +public sealed class AereQbftHeaderValidator : HeaderValidator +{ + public AereQbftHeaderValidator( + IBlockTree? blockTree, + ISealValidator? sealValidator, + ISpecProvider? specProvider, + 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."); + } + + protected override bool ValidateExtraData(BlockHeader header, IReleaseSpec spec, bool isUncle, ref string? error) + { + // Accept the QBFT extraData regardless of the Ethereum 32-byte cap, but + // require it to be structurally a QBFT extraData (seal-strippable). A + // header whose extraData is not valid QBFT is rejected here. + if (AereQbftHeaderHashing.TryStripSeals(header.ExtraData, out _)) + { + return true; + } + + // Fall back to the stock check (covers a genuine sub-32-byte extraData, + // e.g. a non-QBFT genesis) so this validator stays a strict superset. + return base.ValidateExtraData(header, spec, isUncle, ref error); + } + + // AERE runs QBFT with a sub-second (100 ms) block period, so consecutive + // blocks can carry the SAME second-granular Unix timestamp. The stock + // Ethereum rule (strictly increasing) would wrongly reject a genuine block; + // QBFT only requires non-decreasing timestamps, which is what Besu enforces + // under xblockperiodmilliseconds. Relax to >= parent. + protected override bool ValidateTimestamp(BlockHeader header, BlockHeader parent, ref string? error) + { + if (parent is null) return true; + if (header.Timestamp >= parent.Timestamp) return true; + error = "AERE-QBFT: timestamp before parent"; + return false; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs new file mode 100644 index 0000000..35bd376 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Consensus/AereQbftSealValidator.cs @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 AERE Network +// SPDX-License-Identifier: LGPL-3.0-only +// +// ISealValidator implementation for AERE chain 2800's Hyperledger Besu QBFT +// (IBFT 2.0 / EEA QBFT) consensus, wired into Nethermind's block/header +// validation seam. HeaderValidator.ValidateSeal() calls ValidateParams(parent, +// header, isUncle); we answer it with the proven AERE QBFT rule: +// +// * recover each 65-byte committed seal (r||s||v) over the QBFT commit digest; +// * require >= ceil(2N/3) DISTINCT signers, all in the header's own validator +// set (read per-block from extraData); +// * require the proposer (coinbase) to be a validator and the round-robin +// expected proposer for this height+round (parent-relative). +// +// This is a VALIDATING/FOLLOWING role: it verifies AERE blocks. It is NOT a +// producing validator - it does not propose, sign, or seal, and is not a member +// of the QBFT validator set. Consensus signatures are classical secp256k1 ECDSA; +// nothing here is post-quantum consensus. + +using System; +using Nethermind.Consensus; +using Nethermind.Core; +using Nethermind.Logging; +using Nethermind.AerePqc.Consensus; +using Nethermind.AerePqc.Qbft; + +namespace Nethermind.AerePqc.Consensus; + +public sealed class AereQbftSealValidator : ISealValidator +{ + private readonly ILogger _logger; + + public AereQbftSealValidator(ILogManager? logManager = null) + { + _logger = logManager?.GetClassLogger() ?? NullLogger.Instance; + if (_logger.IsInfo) + _logger.Info("AERE-QBFT SealValidator active: verifies AERE chain-2800 QBFT committed seals " + + "(ecrecover >= ceil(2N/3) distinct in-set validators) + round-robin proposer. " + + "Validating/following role only - not a producing validator; consensus stays classical ECDSA."); + } + + // Called by HeaderValidator during header/block import. parent is the already + // known parent header; header is the candidate being validated. + public bool ValidateParams(BlockHeader parent, BlockHeader header, bool isUncle = false) + { + if (isUncle) return false; // QBFT/IBFT2 has no uncles + + try + { + QbftBlockHeader qh = NethermindHeaderAdapter.ToQbft(header); + byte[]? parentProposer = parent?.Beneficiary is null ? null : parent.Beneficiary.Bytes.ToArray(); + + QbftValidationResult r = QbftHeaderValidator.Validate( + qh, + expectedBlockHash: null, // hash equality is enforced by HeaderValidator.ValidateHash + parentProposer: parentProposer); + + if (!r.IsValid && _logger.IsWarn) + _logger.Warn($"AERE-QBFT seal invalid for block {header.Number}: {r.RejectReason} " + + $"(N={r.ValidatorCount}, quorum={r.Quorum}, seals={r.CommittedSealCount})"); + + return r.IsValid; + } + catch (Exception e) + { + if (_logger.IsWarn) _logger.Warn($"AERE-QBFT seal decode failed for block {header.Number}: {e.Message}"); + return false; + } + } + + // Seal-only check (no parent context). This is the method the eth FORWARD-HEADER + // SYNC path calls: Nethermind.Synchronization.Blocks.PowForwardHeaderProvider. + // ValidateSeals() validates EVERY header in a downloaded batch and, on the first + // header this returns false for, throws EthSyncException -> ReportBreachOfProtocol + // (DisconnectReason.ForwardSyncFailed), DISCONNECTING the peer. A from-genesis bulk + // sync therefore requires two QBFT-specific cases to be handled here; getting either + // wrong stalls the follower and drops the peer (proven before/after in + // QBFT-FOLLOWER-SYNC-FIX-2026-07-13.md): + // + // * GENESIS (block 0) is the trust anchor and carries NO QBFT committed seals - its + // integrity comes from the genesis-hash / chainspec match (block 1's parentHash + // must equal the node's OWN genesis hash, enforced by the block-tree linkage), + // exactly as Hyperledger Besu treats it. A from-genesis forward sync anchors the + // downloaded batch at block 0, so ValidateSeals runs on genesis; rejecting it for + // "zero committed seals / coinbase 0x0 not a validator" wrongly fails the whole + // batch and disconnects the peer. Accept it. + // + // * BLOCK 1's parent is genesis, whose coinbase 0x0 is not a validator. With no + // parent header available on this seal-only path the round-robin proposer would + // otherwise be SKIPPED (block 1's proposer left unvalidated). When the nodes start + // skewed so block 1 commits at round>0, the proposer must be computed with the + // round INCLUDED. We seed the QBFT missing-proposer round-robin from the genesis + // 0x0 parent (Besu handleMissingProposer): + // expected = validators_sorted[(indexOfNextAbove(0x0) + round) mod N] + // = validators_sorted[round mod N] + // so a genuine round>0 block 1 is VALIDATED (not merely skipped) and ACCEPTED. + // + // Blocks >= 2 keep the prior seal-only behaviour (round-robin skipped - no parent + // proposer available here), so the mid-chain and round-0 paths are unchanged. + public bool ValidateSeal(BlockHeader header, bool force) + { + try + { + // Genesis anchor: no committed seals to check; integrity is the genesis-hash match. + if (header.Number == 0) return true; + + QbftBlockHeader qh = NethermindHeaderAdapter.ToQbft(header); + + // Block 1's parent is always genesis (coinbase 0x0). Seeding parentProposer + // with the zero address drives QbftHeaderValidator's Besu-exact missing-proposer + // round-robin (round-aware), so a round>0 block 1 is accepted; null for >= 2. + byte[]? parentProposer = header.Number == 1 ? new byte[20] : null; + + QbftValidationResult r = QbftHeaderValidator.Validate(qh, null, parentProposer); + if (!r.IsValid && _logger.IsWarn) + _logger.Warn($"AERE-QBFT ValidateSeal invalid for block {header.Number}: {r.RejectReason} " + + $"(N={r.ValidatorCount}, quorum={r.Quorum}, seals={r.CommittedSealCount}, " + + $"quorumMet={r.QuorumMet}, distinct={r.AllSealsDistinctValidators}, proposerInSet={r.ProposerInValidatorSet}, " + + $"roundRobin={r.RoundRobinChecked}/{r.RoundRobinProposerOk})"); + return r.IsValid; + } + catch (Exception e) + { + if (_logger.IsWarn) _logger.Warn($"AERE-QBFT ValidateSeal decode failed for block {header.Number}: {e.Message}"); + return false; + } + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Consensus/NethermindHeaderAdapter.cs b/src/Nethermind/Nethermind.AerePqc/Consensus/NethermindHeaderAdapter.cs new file mode 100644 index 0000000..77fda77 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Consensus/NethermindHeaderAdapter.cs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 AERE Network +// SPDX-License-Identifier: LGPL-3.0-only +// +// Maps a Nethermind BlockHeader onto the AERE QBFT header model so the proven +// QBFT validator (block-hash recompute, committed-seal recovery + quorum, +// round-robin proposer) can run over headers that Nethermind decoded/received. +// Field order + fork-gated trailing fields mirror Besu's header schedule. + +using System; +using Nethermind.Core; +using Nethermind.Int256; +using Nethermind.AerePqc.Qbft; + +namespace Nethermind.AerePqc.Consensus; + +public static class NethermindHeaderAdapter +{ + public static QbftBlockHeader ToQbft(BlockHeader h) + { + return new QbftBlockHeader + { + Number = h.Number, + ParentHash = h.ParentHash!.Bytes.ToArray(), + OmmersHash = h.UnclesHash!.Bytes.ToArray(), + Beneficiary = h.Beneficiary!.Bytes.ToArray(), + StateRoot = h.StateRoot!.Bytes.ToArray(), + TransactionsRoot = h.TxRoot!.Bytes.ToArray(), + ReceiptsRoot = h.ReceiptsRoot!.Bytes.ToArray(), + LogsBloom = h.Bloom!.Bytes.ToArray(), + Difficulty = MinBE(h.Difficulty), + GasLimit = MinBE((ulong)h.GasLimit), + GasUsed = MinBE((ulong)h.GasUsed), + Timestamp = MinBE(h.Timestamp), + ExtraData = h.ExtraData, + MixHash = h.MixHash!.Bytes.ToArray(), + Nonce = Be8(h.Nonce), + BaseFeePerGas = h.BaseFeePerGas.IsZero ? Array.Empty() : MinBE(h.BaseFeePerGas), + WithdrawalsRoot = h.WithdrawalsRoot?.Bytes.ToArray(), + BlobGasUsed = h.BlobGasUsed.HasValue ? MinBE(h.BlobGasUsed.Value) : null, + ExcessBlobGas = h.ExcessBlobGas.HasValue ? MinBE(h.ExcessBlobGas.Value) : null, + ParentBeaconBlockRoot = h.ParentBeaconBlockRoot?.Bytes.ToArray(), + RequestsHash = h.RequestsHash?.Bytes.ToArray(), + }; + } + + private static byte[] MinBE(UInt256 v) + { + if (v.IsZero) return Array.Empty(); + byte[] be = new byte[32]; + v.ToBigEndian(be); + int i = 0; + while (i < 32 && be[i] == 0) i++; + byte[] outp = new byte[32 - i]; + Buffer.BlockCopy(be, i, outp, 0, outp.Length); + return outp; + } + + private static byte[] MinBE(ulong v) + { + if (v == 0) return Array.Empty(); + int n = 0; ulong t = v; while (t > 0) { n++; t >>= 8; } + byte[] outp = new byte[n]; + for (int i = 0; i < n; i++) outp[n - 1 - i] = (byte)(v >> (8 * i)); + return outp; + } + + private static byte[] Be8(ulong v) + { + byte[] outp = new byte[8]; + for (int i = 0; i < 8; i++) outp[7 - i] = (byte)(v >> (8 * i)); + return outp; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs new file mode 100644 index 0000000..84fd7cf --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcGasSchedule.cs @@ -0,0 +1,92 @@ +// AERE PQC precompile gas schedule. +// +// Source of truth: research/aip-draft-pqc-precompiles.md and +// research/pqc-onchain-verification.md (measured on the Besu 26.4.0 scratch +// fork, chain 28099). These are the MARGINAL gas charged by the precompile +// verify operation, reconciled with the AIP constants: +// +// FALCON512_VERIFY_GAS = 40000 (0x0AE1) +// FALCON1024_VERIFY_GAS = 75000 (0x0AE2) +// MLDSA44_VERIFY_GAS = 55000 (0x0AE3) +// SLHDSA_128S_VERIFY_GAS = 350000 (0x0AE4) +// SHAKE256: SHAKE_BASE(60) + SHAKE_WORD(12) * words, +// words = ceil(len(data)/32) + ceil(outLen/32) (0x0AE5) +// (input and output are priced as SEPARATE word counts, matching Besu) +// +// The signature precompiles are FIXED-cost (BaseGasCost = constant, +// DataGasCost = 0), matching RIP-7951's fixed P-256 pricing. SHAKE256 is the +// exception: its work scales with input+output length, priced per 32-byte word +// like the KECCAK256 opcode. + +namespace Nethermind.AerePqc; + +public static class AerePqcGasSchedule +{ + public const long Falcon512VerifyGas = 40_000; + public const long Falcon1024VerifyGas = 75_000; + public const long MlDsa44VerifyGas = 55_000; + public const long SlhDsa128sVerifyGas = 350_000; + + public const long ShakeBaseGas = 60; + public const long ShakeWordGas = 12; + + // SHAKE256 output cap (bytes), matching the crypto core. + public const int Shake256MaxOutLen = 65_536; + + /// + /// SHAKE256 per-word data gas: 12 * (ceil(dataLen / 32) + ceil(outLen / 32)), where + /// outLen is the (capped) requested output length read from the first + /// 32-byte word of the input. If the input is shorter than the 32-byte + /// outLen word, ZERO words are counted: the precompile returns empty per the + /// malformed-input rule and the reference chain (Besu) charges only the flat + /// base of 60 gas. Do not "restore" a byte count here; it was measured. + /// + public static long Shake256DataGas(System.ReadOnlySpan input) + { + long dataLen; + long outLen; + if (input.Length < 32) + { + // 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. + dataLen = 0; + outLen = 0; + } + else + { + outLen = ReadCappedOutLen(input.Slice(0, 32)); + 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. + long words = (dataLen + 31) / 32 + (outLen + 31) / 32; // ceil each, separately + return ShakeWordGas * words; + } + + /// + /// Reads the 32-byte big-endian outLen word, saturating at the cap. Mirrors + /// AerePqcPrecompiles.ReadOutLen semantics: any value above the cap is + /// treated as the cap for the purpose of work/gas. + /// + private static long ReadCappedOutLen(System.ReadOnlySpan word32) + { + for (int i = 0; i < 28; i++) + { + if (word32[i] != 0) return Shake256MaxOutLen; + } + long v = 0; + for (int i = 28; i < 32; i++) + { + v = (v << 8) | word32[i]; + } + return v > Shake256MaxOutLen ? Shake256MaxOutLen : v; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs new file mode 100644 index 0000000..5dad9e5 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/AerePqcPrecompileProvider.cs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 AERE Network +// SPDX-License-Identifier: LGPL-3.0-only +// +// IPrecompileProvider that contributes the five AERE PQC precompiles +// (0x0AE1-0x0AE5) on top of the standard Ethereum band, mirroring how +// OptimismPrecompileProvider swaps in the OP band. Registered in place of +// EthereumPrecompileProvider so a real Nethermind node exposes the SAME native +// precompiles as the AERE Besu 26.4.0 fork on chain 2800. +// +// Execution-layer parity only; this does not make consensus post-quantum. + +using System.Collections.Frozen; +using System.Collections.Generic; +using Nethermind.Blockchain; +using Nethermind.Core; +using Nethermind.Evm; +using Nethermind.Evm.CodeAnalysis; +using Nethermind.AerePqc.Precompiles; + +namespace Nethermind.AerePqc; + +public sealed class AerePqcPrecompileProvider : IPrecompileProvider +{ + private readonly FrozenDictionary _all; + + public AerePqcPrecompileProvider() + { + Dictionary map = new(); + foreach (KeyValuePair kv in new EthereumPrecompileProvider().GetPrecompiles()) + { + map[kv.Key] = kv.Value; + } + + map[Falcon512Precompile.Address] = new CodeInfo(Falcon512Precompile.Instance); + map[Falcon1024Precompile.Address] = new CodeInfo(Falcon1024Precompile.Instance); + map[MlDsa44Precompile.Address] = new CodeInfo(MlDsa44Precompile.Instance); + map[SlhDsa128sPrecompile.Address] = new CodeInfo(SlhDsa128sPrecompile.Instance); + map[Shake256Precompile.Address] = new CodeInfo(Shake256Precompile.Instance); + + _all = map.ToFrozenDictionary(); + } + + public FrozenDictionary GetPrecompiles() => _all; +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/Core/AerePqcPrecompiles.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/Core/AerePqcPrecompiles.cs new file mode 100644 index 0000000..2a6e620 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/Core/AerePqcPrecompiles.cs @@ -0,0 +1,278 @@ +// AERE Network — PQC precompiles, reusable .NET core (Nethermind second client). +// +// Byte-for-byte-identical wire format and return semantics to the AERE Besu +// 26.4.0 fork's five native PQC precompiles (mainnet chain 2800): +// +// 0x0AE1 Falcon-512 verify -> 32-byte word 0x..01 / 0x..00 +// 0x0AE2 Falcon-1024 verify -> 32-byte word 0x..01 / 0x..00 +// 0x0AE3 ML-DSA-44 verify -> 32-byte word 0x..01 / 0x..00 +// 0x0AE4 SLH-DSA-SHA2-128s verify -> 32-byte word 0x..01 / 0x..00 +// 0x0AE5 SHAKE256 XOF -> outLen bytes (FIPS-202), or empty +// +// Crypto is BouncyCastle.Cryptography (Org.BouncyCastle) — the same library +// FAMILY the Besu fork uses in Java, so the underlying Falcon / ML-DSA / SLH-DSA +// primitives produce identical accept/reject decisions. This file only owns the +// consensus-critical INPUT PARSING and OUTPUT ENCODING; it delegates the actual +// verification to BouncyCastle. +// +// WIRE FORMATS (from contracts/pqc/AerePQCAttestation.sol NatSpec): +// Falcon-512 : input = pk(897, hdr 0x09) || sm +// Falcon-1024 : input = pk(1793, hdr 0x0A) || sm +// sm = sigLen(uint16 BE) || nonce(40) || message || esig +// esig = (0x20|logn) || compressedSig +// ML-DSA-44 : input = pk(1312) || sig(2420) || message (FIPS-204 internal) +// SLH-DSA-128s: input = pk(32) || sig(7856) || message (FIPS-205 internal) +// SHAKE256 : input = outLen(32 BE, capped 65536) || data +// +// MALFORMED-INPUT SEMANTICS (verified live on 2800): +// * signature precompiles: ANY malformed / too-short / wrong-length input +// returns a 32-byte word (0x..00), NEVER empty. +// * SHAKE256: input < 32 bytes OR outLen == 0 returns EMPTY (0x); otherwise +// exactly outLen bytes. + +using System; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Pqc.Crypto.Falcon; + +namespace Nethermind.AerePqc; + +/// +/// The five AERE PQC precompiles as pure functions over their raw call input. +/// Each returns the exact bytes the on-chain precompile returns for that input. +/// +public static class AerePqcPrecompiles +{ + // Canonical mainnet addresses (chain 2800), lowercase, 20-byte hex. + public const string Falcon512Address = "0x0000000000000000000000000000000000000ae1"; + public const string Falcon1024Address = "0x0000000000000000000000000000000000000ae2"; + public const string MlDsa44Address = "0x0000000000000000000000000000000000000ae3"; + public const string SlhDsa128sAddress = "0x0000000000000000000000000000000000000ae4"; + public const string Shake256Address = "0x0000000000000000000000000000000000000ae5"; + + // Fixed lengths. + private const int Falcon512PkLen = 897; + private const int Falcon1024PkLen = 1793; + private const int FalconNonceLen = 40; + private const int FalconSigLenFieldLen = 2; + private const int MlDsa44PkLen = 1312; + private const int MlDsa44SigLen = 2420; + private const int SlhDsa128sPkLen = 32; + private const int SlhDsa128sSigLen = 7856; + private const int Shake256MaxOutLen = 65536; + + /// The 32-byte word a signature precompile returns on ACCEPT. + public static readonly byte[] WordValid = MakeWord(1); + + /// The 32-byte word a signature precompile returns on REJECT / malformed. + public static readonly byte[] WordInvalid = MakeWord(0); + + /// Empty output (0x). + public static readonly byte[] Empty = Array.Empty(); + + private static byte[] MakeWord(byte low) + { + byte[] w = new byte[32]; + w[31] = low; + return w; + } + + /// + /// Dispatch by precompile address (case-insensitive, with or without 0x). + /// Returns null if the address is not one of the five PQC precompiles. + /// + public static byte[]? Run(string address, ReadOnlySpan input) + { + string a = Normalize(address); + return a switch + { + "0000000000000000000000000000000000000ae1" => Falcon512Verify(input), + "0000000000000000000000000000000000000ae2" => Falcon1024Verify(input), + "0000000000000000000000000000000000000ae3" => MlDsa44Verify(input), + "0000000000000000000000000000000000000ae4" => SlhDsa128sVerify(input), + "0000000000000000000000000000000000000ae5" => Shake256(input), + _ => null, + }; + } + + private static string Normalize(string address) + { + string a = address.Trim().ToLowerInvariant(); + if (a.StartsWith("0x")) a = a.Substring(2); + return a.PadLeft(40, '0'); + } + + // ------------------------------------------------------------------------- + // Falcon-512 / Falcon-1024 + // ------------------------------------------------------------------------- + + public static byte[] Falcon512Verify(ReadOnlySpan input) => + FalconVerify(input, Falcon512PkLen, 9, FalconParameters.falcon_512); + + public static byte[] Falcon1024Verify(ReadOnlySpan input) => + FalconVerify(input, Falcon1024PkLen, 10, FalconParameters.falcon_1024); + + // CONSENSUS NOTE (AERE, 2026-07-20). Every guard below is transcribed from the + // Besu fork's AereFalconSupport.verify, which is the reference BY DEFINITION: + // Besu produced every block of chain 2800, so its accept/reject decision IS + // the consensus rule even where the choice looks arbitrary. Three guards were + // previously missing here and each one is a CHAIN SPLIT: + // + // pk[0] == logn (Besu AereFalconSupport.java:62-64) + // esig[0] == 0x20 + logn (Besu AereFalconSupport.java:74-76) + // sigLen >= 2 (Besu AereFalconSupport.java:66) + // + // The first two are the dangerous ones: BouncyCastle never looks at either + // header byte (it is handed pk[1..] and a rebuilt 0x30|logn signature), so + // without these checks a ONE-BYTE tamper of a valid signature is ACCEPTED + // here and REJECTED by Besu. Measured against live 0x0AE1/0x0AE2: 14 corpus + // entries flipped 0x..01 vs 0x..00. Do not "simplify" these away: they look + // redundant precisely because the bytes are otherwise unused. + private static byte[] FalconVerify(ReadOnlySpan input, int pkLen, int logn, FalconParameters parameters) + { + try + { + // input = pk || sm ; sm = sigLen(2 BE) || nonce(40) || message || esig + if (input.Length <= pkLen) return WordInvalid; + + ReadOnlySpan pk = input.Slice(0, pkLen); + ReadOnlySpan sm = input.Slice(pkLen); + + // Besu: sm.length < 2 + NONCE_LEN + 2 (i.e. < 44). NOT +1: the + // minimum esig is 2 bytes because Besu also requires sigLen >= 2. + if (pk.Length < 2 || sm.Length < FalconSigLenFieldLen + FalconNonceLen + 2) return WordInvalid; + + // The public-key packing header must name the parameter set. + if (pk[0] != (byte)logn) return WordInvalid; + + int sigLen = (sm[0] << 8) | sm[1]; // esig length (includes the 1-byte header) + int headerAndNonce = FalconSigLenFieldLen + FalconNonceLen; + // esig occupies the trailing sigLen bytes; message is what sits between. + if (sigLen < 2 || sigLen > sm.Length - headerAndNonce) return WordInvalid; + + ReadOnlySpan nonce = sm.Slice(FalconSigLenFieldLen, FalconNonceLen); + int msgLen = sm.Length - headerAndNonce - sigLen; + if (msgLen < 0) return WordInvalid; + ReadOnlySpan message = sm.Slice(headerAndNonce, msgLen); + ReadOnlySpan esig = sm.Slice(sm.Length - sigLen); // (0x20|logn) || compressedSig + + // The signature encoding header must also name the parameter set. + if (esig[0] != (byte)(0x20 + logn)) return WordInvalid; + + ReadOnlySpan compressed = esig.Slice(1); + + // BouncyCastle FalconPublicKeyParameters takes h WITHOUT the 1-byte + // packing header; the precompile's pk carries it, so strip pk[0]. + byte[] h = pk.Slice(1).ToArray(); + var pub = new FalconPublicKeyParameters(parameters, h); + + // BouncyCastle FalconSigner.VerifySignature expects the detached form + // (0x30|logn) || nonce(40) || compressedSig + // The AERE esig header is (0x20|logn); rebuild the BC-detached blob. + byte[] bcSig = new byte[1 + FalconNonceLen + compressed.Length]; + // Besu writes (0x30 + logn); for logn 9 and 10 that equals (0x30 | logn), + // but use the reference's arithmetic form so the two cannot drift apart. + bcSig[0] = (byte)(0x30 + logn); + nonce.CopyTo(bcSig.AsSpan(1)); + compressed.CopyTo(bcSig.AsSpan(1 + FalconNonceLen)); + + var signer = new FalconSigner(); + signer.Init(false, pub); + bool ok = signer.VerifySignature(message.ToArray(), bcSig); + return ok ? WordValid : WordInvalid; + } + catch + { + // Consensus rule: signature precompiles never fault — reject instead. + return WordInvalid; + } + } + + // ------------------------------------------------------------------------- + // ML-DSA-44 (FIPS-204 Verify_internal, Algorithm 8, no domain prefix) + // ------------------------------------------------------------------------- + + public static byte[] MlDsa44Verify(ReadOnlySpan input) + { + try + { + if (input.Length < MlDsa44PkLen + MlDsa44SigLen) return WordInvalid; + + byte[] pk = input.Slice(0, MlDsa44PkLen).ToArray(); + byte[] sig = input.Slice(MlDsa44PkLen, MlDsa44SigLen).ToArray(); + byte[] msg = input.Slice(MlDsa44PkLen + MlDsa44SigLen).ToArray(); + + var pub = MLDsaPublicKeyParameters.FromEncoding(MLDsaParameters.ml_dsa_44, pk); + bool ok = BouncyCastleInternal.MlDsaVerifyInternal(pub, msg, sig); + return ok ? WordValid : WordInvalid; + } + catch + { + return WordInvalid; + } + } + + // ------------------------------------------------------------------------- + // SLH-DSA-SHA2-128s (FIPS-205 slh_verify_internal, Algorithm 20) + // ------------------------------------------------------------------------- + + public static byte[] SlhDsa128sVerify(ReadOnlySpan input) + { + try + { + if (input.Length < SlhDsa128sPkLen + SlhDsa128sSigLen) return WordInvalid; + + byte[] pk = input.Slice(0, SlhDsa128sPkLen).ToArray(); + byte[] sig = input.Slice(SlhDsa128sPkLen, SlhDsa128sSigLen).ToArray(); + byte[] msg = input.Slice(SlhDsa128sPkLen + SlhDsa128sSigLen).ToArray(); + + var pub = SlhDsaPublicKeyParameters.FromEncoding(SlhDsaParameters.slh_dsa_sha2_128s, pk); + bool ok = BouncyCastleInternal.SlhDsaVerifyInternal(pub, msg, sig); + return ok ? WordValid : WordInvalid; + } + catch + { + return WordInvalid; + } + } + + // ------------------------------------------------------------------------- + // SHAKE256 (FIPS-202 XOF) + // ------------------------------------------------------------------------- + + public static byte[] Shake256(ReadOnlySpan input) + { + // Needs a full 32-byte outLen word, else EMPTY. + if (input.Length < 32) return Empty; + + // outLen is a 32-byte big-endian word, capped at 65536. + long outLen = ReadOutLen(input.Slice(0, 32)); + if (outLen == 0) return Empty; + if (outLen > Shake256MaxOutLen) outLen = Shake256MaxOutLen; + + ReadOnlySpan data = input.Slice(32); + var digest = new ShakeDigest(256); + if (!data.IsEmpty) digest.BlockUpdate(data.ToArray(), 0, data.Length); + + byte[] output = new byte[outLen]; + digest.OutputFinal(output, 0, output.Length); // XOF squeeze of exactly outLen bytes + return output; + } + + // Read a 32-byte big-endian length, saturating at (Shake256MaxOutLen+1) so a + // huge value can never overflow but is still recognised as "> cap". + private static long ReadOutLen(ReadOnlySpan word32) + { + // If any high byte (above the low 4) is set, it is far above the cap. + for (int i = 0; i < 28; i++) + { + if (word32[i] != 0) return Shake256MaxOutLen + 1; + } + long v = 0; + for (int i = 28; i < 32; i++) + { + v = (v << 8) | word32[i]; + } + return v; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/Core/BouncyCastleInternal.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/Core/BouncyCastleInternal.cs new file mode 100644 index 0000000..1106a24 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/Core/BouncyCastleInternal.cs @@ -0,0 +1,68 @@ +// Access to BouncyCastle's FIPS-204 / FIPS-205 INTERNAL verify entry points. +// +// The AERE precompiles bind to the *internal* interfaces: +// ML-DSA-44 : FIPS-204 Verify_internal (Algorithm 8) -> message is M', no +// external 0x00||len(ctx)||ctx domain prefix. +// SLH-DSA-128s: FIPS-205 slh_verify_internal (Algorithm 20) -> message hashed +// directly, again with no context prefix. +// +// BouncyCastle's public MLDsaSigner / SlhDsaSigner apply the FIPS EXTERNAL +// interface (they prepend the empty-context domain separator 0x00||0x00), which +// does NOT match the on-chain precompiles or the NIST ACVP *internal* vectors. +// BouncyCastle DOES implement the internal interface, but exposes it only as an +// assembly-internal method on the public-key parameter classes: +// +// bool MLDsaPublicKeyParameters.VerifyInternal(byte[] msg, int off, int len, byte[] sig) +// bool SlhDsaPublicKeyParameters.VerifyInternal(byte[] msg, int off, int len, byte[] sig) +// +// We reach them by reflection. This mirrors how the Besu Java fork drives the +// same BouncyCastle internal path. The MethodInfo handles are resolved once and +// cached; if a future BouncyCastle drops these methods, construction fails loudly +// rather than silently changing consensus behaviour. + +using System; +using System.Reflection; +using Org.BouncyCastle.Crypto.Parameters; + +namespace Nethermind.AerePqc; + +internal static class BouncyCastleInternal +{ + private static readonly MethodInfo MlDsaVerifyInternalMethod = + ResolveVerifyInternal(typeof(MLDsaPublicKeyParameters)); + + private static readonly MethodInfo SlhDsaVerifyInternalMethod = + ResolveVerifyInternal(typeof(SlhDsaPublicKeyParameters)); + + private static MethodInfo ResolveVerifyInternal(Type paramType) + { + MethodInfo? m = paramType.GetMethod( + "VerifyInternal", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, + types: new[] { typeof(byte[]), typeof(int), typeof(int), typeof(byte[]) }, + modifiers: null); + + if (m == null || m.ReturnType != typeof(bool)) + { + throw new InvalidOperationException( + $"BouncyCastle {paramType.Name}.VerifyInternal(byte[],int,int,byte[]) not found. " + + "The installed BouncyCastle.Cryptography version does not expose the FIPS " + + "internal verify interface this precompile requires."); + } + + return m; + } + + internal static bool MlDsaVerifyInternal(MLDsaPublicKeyParameters pub, byte[] msg, byte[] sig) + { + object? r = MlDsaVerifyInternalMethod.Invoke(pub, new object[] { msg, 0, msg.Length, sig }); + return r is bool b && b; + } + + internal static bool SlhDsaVerifyInternal(SlhDsaPublicKeyParameters pub, byte[] msg, byte[] sig) + { + object? r = SlhDsaVerifyInternalMethod.Invoke(pub, new object[] { msg, 0, msg.Length, sig }); + return r is bool b && b; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/Falcon1024Precompile.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/Falcon1024Precompile.cs new file mode 100644 index 0000000..e1700d0 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/Falcon1024Precompile.cs @@ -0,0 +1,25 @@ +using System; +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Evm.Precompiles; + +namespace Nethermind.AerePqc.Precompiles; + +/// +/// Falcon-1024 verify precompile at 0x0AE2. Fixed gas (FALCON1024_VERIFY_GAS). +/// This precompile is what moves Falcon-1024 from view-only to record-on-chain +/// within the 16,777,216 gas cap. +/// +public sealed class Falcon1024Precompile : IPrecompile +{ + public static readonly Falcon1024Precompile Instance = new(); + + public static Address Address { get; } = new Address(AerePqcPrecompiles.Falcon1024Address); + public static string Name => "AERE_FALCON1024_VERIFY"; + + public long BaseGasCost(IReleaseSpec releaseSpec) => AerePqcGasSchedule.Falcon1024VerifyGas; + public long DataGasCost(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) => 0L; + + public Result Run(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) + => Result.Success(AerePqcPrecompiles.Falcon1024Verify(inputData.Span)); +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/Falcon512Precompile.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/Falcon512Precompile.cs new file mode 100644 index 0000000..c025bd8 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/Falcon512Precompile.cs @@ -0,0 +1,25 @@ +using System; +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Evm.Precompiles; + +namespace Nethermind.AerePqc.Precompiles; + +/// +/// Falcon-512 verify precompile at 0x0AE1. Fixed gas (FALCON512_VERIFY_GAS). +/// Returns a 32-byte word 0x..01 (accept) / 0x..00 (reject or malformed); +/// never faults, matching the on-chain semantics on chain 2800. +/// +public sealed class Falcon512Precompile : IPrecompile +{ + public static readonly Falcon512Precompile Instance = new(); + + public static Address Address { get; } = new Address(AerePqcPrecompiles.Falcon512Address); + public static string Name => "AERE_FALCON512_VERIFY"; + + public long BaseGasCost(IReleaseSpec releaseSpec) => AerePqcGasSchedule.Falcon512VerifyGas; + public long DataGasCost(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) => 0L; + + public Result Run(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) + => Result.Success(AerePqcPrecompiles.Falcon512Verify(inputData.Span)); +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/MlDsa44Precompile.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/MlDsa44Precompile.cs new file mode 100644 index 0000000..4fb9cce --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/MlDsa44Precompile.cs @@ -0,0 +1,24 @@ +using System; +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Evm.Precompiles; + +namespace Nethermind.AerePqc.Precompiles; + +/// +/// ML-DSA-44 (FIPS-204 Verify_internal) precompile at 0x0AE3. Fixed gas +/// (MLDSA44_VERIFY_GAS). Moves ML-DSA-44 from view-only to record-on-chain. +/// +public sealed class MlDsa44Precompile : IPrecompile +{ + public static readonly MlDsa44Precompile Instance = new(); + + public static Address Address { get; } = new Address(AerePqcPrecompiles.MlDsa44Address); + public static string Name => "AERE_MLDSA44_VERIFY"; + + public long BaseGasCost(IReleaseSpec releaseSpec) => AerePqcGasSchedule.MlDsa44VerifyGas; + public long DataGasCost(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) => 0L; + + public Result Run(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) + => Result.Success(AerePqcPrecompiles.MlDsa44Verify(inputData.Span)); +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/Shake256Precompile.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/Shake256Precompile.cs new file mode 100644 index 0000000..9cbf00c --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/Shake256Precompile.cs @@ -0,0 +1,29 @@ +using System; +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Evm.Precompiles; + +namespace Nethermind.AerePqc.Precompiles; + +/// +/// SHAKE256 (FIPS-202 XOF) precompile at 0x0AE5. Unlike the fixed-cost +/// signature precompiles this one is priced per 32-byte word over +/// (data + output) length: gas = SHAKE_BASE(60) + SHAKE_WORD(12) * words. +/// Returns exactly outLen bytes, or empty for malformed input (len < 32 or +/// outLen == 0). +/// +public sealed class Shake256Precompile : IPrecompile +{ + public static readonly Shake256Precompile Instance = new(); + + public static Address Address { get; } = new Address(AerePqcPrecompiles.Shake256Address); + public static string Name => "AERE_SHAKE256"; + + public long BaseGasCost(IReleaseSpec releaseSpec) => AerePqcGasSchedule.ShakeBaseGas; + + public long DataGasCost(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) + => AerePqcGasSchedule.Shake256DataGas(inputData.Span); + + public Result Run(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) + => Result.Success(AerePqcPrecompiles.Shake256(inputData.Span)); +} diff --git a/src/Nethermind/Nethermind.AerePqc/Precompiles/SlhDsa128sPrecompile.cs b/src/Nethermind/Nethermind.AerePqc/Precompiles/SlhDsa128sPrecompile.cs new file mode 100644 index 0000000..e43761c --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Precompiles/SlhDsa128sPrecompile.cs @@ -0,0 +1,25 @@ +using System; +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Evm.Precompiles; + +namespace Nethermind.AerePqc.Precompiles; + +/// +/// SLH-DSA-SHA2-128s (FIPS-205 slh_verify_internal) precompile at 0x0AE4. Fixed +/// gas (SLHDSA_128S_VERIFY_GAS); larger than the lattice precompiles because +/// verification is dominated by thousands of SHA-256/tweakable-hash calls. +/// +public sealed class SlhDsa128sPrecompile : IPrecompile +{ + public static readonly SlhDsa128sPrecompile Instance = new(); + + public static Address Address { get; } = new Address(AerePqcPrecompiles.SlhDsa128sAddress); + public static string Name => "AERE_SLHDSA128S_VERIFY"; + + public long BaseGasCost(IReleaseSpec releaseSpec) => AerePqcGasSchedule.SlhDsa128sVerifyGas; + public long DataGasCost(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) => 0L; + + public Result Run(ReadOnlyMemory inputData, IReleaseSpec releaseSpec) + => Result.Success(AerePqcPrecompiles.SlhDsa128sVerify(inputData.Span)); +} diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/Keccak256.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/Keccak256.cs new file mode 100644 index 0000000..2f31ba1 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Qbft/Keccak256.cs @@ -0,0 +1,34 @@ +// Keccak-256 (Ethereum's pre-standard SHA-3) via BouncyCastle, plus hex helpers. + +using System; +using Org.BouncyCastle.Crypto.Digests; + +namespace Nethermind.AerePqc.Qbft; + +public static class Keccak256 +{ + public static byte[] Hash(byte[] data) + { + var d = new KeccakDigest(256); + d.BlockUpdate(data, 0, data.Length); + var outp = new byte[32]; + d.DoFinal(outp, 0); + return outp; + } +} + +public static class Hex +{ + public static byte[] Decode(string s) + { + if (s.StartsWith("0x") || s.StartsWith("0X")) s = s.Substring(2); + if (s.Length == 0) return Array.Empty(); + if ((s.Length & 1) != 0) s = "0" + s; + var b = new byte[s.Length / 2]; + for (int i = 0; i < b.Length; i++) + b[i] = Convert.ToByte(s.Substring(i * 2, 2), 16); + return b; + } + + public static string Encode(byte[] b) => "0x" + Convert.ToHexString(b).ToLowerInvariant(); +} diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftBlockHeader.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftBlockHeader.cs new file mode 100644 index 0000000..65ac092 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftBlockHeader.cs @@ -0,0 +1,78 @@ +// Block-header model + Besu-faithful RLP serialization for AERE chain 2800. +// Field order and the fork-gated presence of the trailing fields were confirmed +// by reproducing real on-chain block hashes (see the test-suite / report): +// +// parentHash, ommersHash, beneficiary, stateRoot, transactionsRoot, +// receiptsRoot, logsBloom, difficulty, number, gasLimit, gasUsed, timestamp, +// extraData, mixHash, nonce, +// [baseFeePerGas] (London) +// [withdrawalsRoot] (Shanghai) +// [blobGasUsed, excessBlobGas, parentBeaconBlockRoot] (Cancun) +// [requestsHash] (Prague) +// +// Optional trailing fields are included in the RLP only when present (non-null), +// matching Besu's per-fork header schedule. AERE's early blocks are 17-field +// (London+Shanghai); post-Cancun/Prague blocks are 21-field. + +using System; + +namespace Nethermind.AerePqc.Qbft; + +public sealed class QbftBlockHeader +{ + public long Number { get; init; } + + public required byte[] ParentHash { get; init; } + public required byte[] OmmersHash { get; init; } // sha3Uncles + public required byte[] Beneficiary { get; init; } // miner / coinbase / proposer + public required byte[] StateRoot { get; init; } + public required byte[] TransactionsRoot { get; init; } + public required byte[] ReceiptsRoot { get; init; } + public required byte[] LogsBloom { get; init; } + public required byte[] Difficulty { get; init; } // scalar (minimal) + public required byte[] GasLimit { get; init; } // scalar + public required byte[] GasUsed { get; init; } // scalar + public required byte[] Timestamp { get; init; } // scalar + public required byte[] ExtraData { get; init; } // full, WITH committed seals + public required byte[] MixHash { get; init; } + public required byte[] Nonce { get; init; } // 8 bytes + + public byte[]? BaseFeePerGas { get; init; } // scalar (London) + public byte[]? WithdrawalsRoot { get; init; } // Shanghai + public byte[]? BlobGasUsed { get; init; } // scalar (Cancun) + public byte[]? ExcessBlobGas { get; init; } // scalar (Cancun) + public byte[]? ParentBeaconBlockRoot { get; init; } // Cancun + public byte[]? RequestsHash { get; init; } // Prague + + /// + /// Build the header RLP list, substituting + /// for the extraData field (used to strip committed seals for hashing). + /// + public RlpList ToRlp(byte[] extraDataOverride) + { + var list = new RlpList() + .Add(new RlpBytes(ParentHash)) + .Add(new RlpBytes(OmmersHash)) + .Add(new RlpBytes(Beneficiary)) + .Add(new RlpBytes(StateRoot)) + .Add(new RlpBytes(TransactionsRoot)) + .Add(new RlpBytes(ReceiptsRoot)) + .Add(new RlpBytes(LogsBloom)) + .Add(new RlpBytes(Difficulty)) + .Add(RlpBytes.Scalar(Number)) + .Add(new RlpBytes(GasLimit)) + .Add(new RlpBytes(GasUsed)) + .Add(new RlpBytes(Timestamp)) + .Add(new RlpBytes(extraDataOverride)) + .Add(new RlpBytes(MixHash)) + .Add(new RlpBytes(Nonce)); + + if (BaseFeePerGas != null) list.Add(new RlpBytes(BaseFeePerGas)); + if (WithdrawalsRoot != null) list.Add(new RlpBytes(WithdrawalsRoot)); + if (BlobGasUsed != null) list.Add(new RlpBytes(BlobGasUsed)); + if (ExcessBlobGas != null) list.Add(new RlpBytes(ExcessBlobGas)); + if (ParentBeaconBlockRoot != null) list.Add(new RlpBytes(ParentBeaconBlockRoot)); + if (RequestsHash != null) list.Add(new RlpBytes(RequestsHash)); + return list; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs new file mode 100644 index 0000000..a1af169 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs @@ -0,0 +1,116 @@ +// Decoder for the Hyperledger Besu QBFT (IBFT 2.0 / EEA QBFT) block-header +// extraData, confirmed byte-for-byte against AERE chain 2800 headers. +// +// Layout (RLP list of 5): +// [ vanity(32 bytes), +// validators(list of 20-byte addresses, sorted ascending), +// vote(RLP: empty list = no vote, or [candidate(20), voteType]), +// round(RLP scalar, 0 == 0x80), +// committedSeals(list of 65-byte ECDSA seals r||s||v) ] +// +// The vote and validators sub-lists are retained as raw RLP subtrees so that the +// re-encoded (seal-stripped) extraData is byte-identical to what Besu hashes. + +using System; +using System.Collections.Generic; + +namespace Nethermind.AerePqc.Qbft; + +public sealed class QbftExtraData +{ + public byte[] Vanity { get; } + public IReadOnlyList Validators { get; } + public IReadOnlyList CommittedSeals { get; } + public byte[] RoundBytes { get; } // minimal big-endian; empty == round 0 + public long Round { get; } + + private readonly RlpItem _vanityItem; + private readonly RlpItem _validatorsItem; + private readonly RlpItem _voteItem; + + private QbftExtraData(RlpItem vanity, RlpItem validators, RlpItem vote, + byte[] roundBytes, IReadOnlyList seals, + IReadOnlyList validatorAddrs) + { + _vanityItem = vanity; + _validatorsItem = validators; + _voteItem = vote; + Vanity = ((RlpBytes)vanity).Value; + Validators = validatorAddrs; + CommittedSeals = seals; + RoundBytes = roundBytes; + Round = ToLong(roundBytes); + } + + public static QbftExtraData Decode(byte[] extraData) + { + // AERE 2026-08-08: chain 2800 carries a SIXTH element from the Falcon seal-attachment + // height onward. Measured the hard way: this decoder demanded exactly five, so the second + // client stopped dead at 12,900,116 - the last block before the first six-element header - + // and disconnected from every peer. The block-hashing path had already been taught element + // six; this one, the SEAL-VALIDATION path, had not. One patch, two call sites, only one fixed. + // + // Six is the CEILING, not a wildcard: a seventh element is still refused, so a future + // addition cannot slip in unnoticed behind this relaxation. + // + // 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"); + + RlpItem vanity = top.Items[0]; + if (vanity is not RlpBytes vb || vb.Value.Length != 32) + throw new FormatException("QBFT vanity must be 32 bytes"); + + if (top.Items[1] is not RlpList valsList) + throw new FormatException("QBFT validators must be a list"); + var vals = new List(valsList.Items.Count); + foreach (RlpItem it in valsList.Items) + { + if (it is not RlpBytes ab || ab.Value.Length != 20) + throw new FormatException("QBFT validator must be a 20-byte address"); + vals.Add(ab.Value); + } + + RlpItem vote = top.Items[2]; + + if (top.Items[3] is not RlpBytes roundB) + throw new FormatException("QBFT round must be a scalar"); + + if (top.Items[4] is not RlpList sealsList) + throw new FormatException("QBFT committedSeals must be a list"); + var seals = new List(sealsList.Items.Count); + foreach (RlpItem it in sealsList.Items) + { + if (it is not RlpBytes sb || sb.Value.Length != 65) + throw new FormatException("QBFT committed seal must be 65 bytes"); + seals.Add(sb.Value); + } + + return new QbftExtraData(vanity, top.Items[1], vote, roundB.Value, seals, vals); + } + + /// + /// Re-encode the extraData with the committed seals removed and the round set + /// to . Besu's on-chain block hash uses round=0 + /// (empty); the committed-seal signing hash uses the header's actual round. + /// + public byte[] EncodeWithoutSeals(byte[] roundBytes) + { + var list = new RlpList() + .Add(_vanityItem) + .Add(_validatorsItem) + .Add(_voteItem) + .Add(new RlpBytes(roundBytes)) + .Add(new RlpList()); + return list.Encode(); + } + + private static long ToLong(byte[] be) + { + long v = 0; + foreach (byte b in be) v = (v << 8) | b; + return v; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs new file mode 100644 index 0000000..59bbea7 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Qbft/QbftHeaderValidator.cs @@ -0,0 +1,218 @@ +// Independent (Nethermind-compatible, C#/.NET) validator for AERE chain 2800's +// Hyperledger Besu QBFT (IBFT 2.0 / EEA QBFT) block headers. +// +// SCOPE: HEADER VALIDATION ONLY. This proves that a second, independent client +// implementation can cryptographically verify AERE's QBFT chain from block +// headers alone: +// 1. recompute the canonical block hash (seal-stripped, round=0 extraData); +// 2. recover each committed seal's signer, require they are DISTINCT validators +// in the header's validator set, and that the count meets QBFT quorum +// ceil(2N/3); +// 3. check the proposer (coinbase) is a validator and matches the round-robin +// expected proposer for this height+round. +// +// It is NOT a consensus engine: there is no p2p, no PREPARE/COMMIT round state +// machine, and no block proposing. Those are separate, later engine pieces. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Nethermind.AerePqc.Qbft; + +public sealed class QbftValidationResult +{ + public byte[] ComputedBlockHash { get; init; } = Array.Empty(); + public bool BlockHashMatches { get; init; } // vs an expected/claimed hash + public bool ProposerInValidatorSet { get; init; } + public bool RoundRobinChecked { get; init; } + public bool RoundRobinProposerOk { get; init; } + public byte[]? ExpectedProposer { get; init; } + public int ValidatorCount { get; init; } + public int Quorum { get; init; } + public int CommittedSealCount { get; init; } + public bool QuorumMet { get; init; } + public bool AllSealsDistinctValidators { get; init; } + public IReadOnlyList RecoveredSigners { get; init; } = Array.Empty(); + public string? RejectReason { get; init; } + + /// Overall verdict: seals + proposer are consensus-valid. + public bool IsValid => + ProposerInValidatorSet + && QuorumMet + && AllSealsDistinctValidators + && (!RoundRobinChecked || RoundRobinProposerOk); +} + +public static class QbftHeaderValidator +{ + /// QBFT Byzantine-fault quorum for a validator set of size N: ceil(2N/3). + public static int Quorum(int n) => (2 * n + 2) / 3; + + /// keccak256(rlp(header with committed seals stripped and round set to 0)). + public static byte[] ComputeBlockHash(QbftBlockHeader header) + { + QbftExtraData extra = QbftExtraData.Decode(header.ExtraData); + byte[] strippedExtra = extra.EncodeWithoutSeals(Array.Empty()); // round = 0 + return Keccak256.Hash(header.ToRlp(strippedExtra).Encode()); + } + + /// keccak256(rlp(header with committed seals stripped, round KEPT)) - + /// the digest each QBFT committed seal signs. + public static byte[] ComputeCommitSealHash(QbftBlockHeader header) + { + QbftExtraData extra = QbftExtraData.Decode(header.ExtraData); + byte[] strippedExtra = extra.EncodeWithoutSeals(extra.RoundBytes); // round kept + return Keccak256.Hash(header.ToRlp(strippedExtra).Encode()); + } + + /// + /// Full header validation. is the hash this + /// header is referenced by (e.g. the child's parentHash, or the RPC-reported + /// hash); pass null to skip the equality check. + /// is the coinbase of block number-1, required for the round-robin check; pass + /// null (or a proposer not in the set, i.e. genesis) to skip round-robin. + /// + public static QbftValidationResult Validate( + QbftBlockHeader header, + byte[]? expectedBlockHash = null, + byte[]? parentProposer = null) + { + QbftExtraData extra = QbftExtraData.Decode(header.ExtraData); + var validators = extra.Validators.ToList(); + int n = validators.Count; + int quorum = Quorum(n); + + byte[] blockHash = ComputeBlockHash(header); + bool hashOk = expectedBlockHash == null || ByteEq(blockHash, expectedBlockHash); + + // Proposer (coinbase) membership. + bool proposerInSet = ContainsAddr(validators, header.Beneficiary); + + // Committed-seal recovery. + byte[] commitDigest = ComputeCommitSealHash(header); + var recovered = new List(); + var seen = new List(); + bool allDistinctInSet = extra.CommittedSeals.Count > 0; + foreach (byte[] seal in extra.CommittedSeals) + { + byte[]? signer = Secp256k1Recover.RecoverAddress(commitDigest, seal); + if (signer == null) { allDistinctInSet = false; recovered.Add(Array.Empty()); continue; } + recovered.Add(signer); + if (!ContainsAddr(validators, signer) || ContainsAddr(seen, signer)) + allDistinctInSet = false; + seen.Add(signer); + } + bool quorumMet = seen.Count(sig => ContainsAddr(validators, sig)) >= quorum + && allDistinctInSet; + + // Round-robin proposer expectation. + // + // QBFT/IBFT 2.0 (Besu ProposerSelector) picks the proposer for block h at + // round r from the validator set active after the PARENT block, kept SORTED + // ascending by address. Besu stores validators sorted and every on-chain + // block h>=1 lists them sorted in extraData, so the decoded order already + // equals sorted; we sort a defensive copy so the seed is correct regardless + // of decode order (a no-op for genuine blocks, so mid-chain import is + // unchanged). + // + // * EXISTING-proposer case (Besu handleWithExistingProposer): the parent's + // proposer is still a validator - the normal mid-chain case. + // expected = sorted[(indexOf(parent) + r + 1) mod N] + // + // * MISSING-proposer case (Besu handleMissingProposer): the parent's + // proposer is NOT in the set. This is exactly the GENESIS-parent case for + // block 1 - the genesis coinbase is 0x0, which is never a validator and so + // cannot index the round-robin - and also any block right after a validator + // is voted out. Besu seeds from the first validator whose address is + // strictly greater than the (missing) parent proposer, wrapping to index 0 + // if none, with NO +1: + // expected = sorted[(indexOfNextAbove(parent) + r) mod N] + // + // For block 1 the genesis proposer 0x0 is below every real validator, so + // indexOfNextAbove == 0 and expected = sorted[r mod N]. Reusing the + // existing-proposer formula here (the naive "seed from the parent" attempt) + // adds a spurious +1 and yields the WRONG proposer, rejecting the genuine + // block 1; simply skipping the check leaves block 1's proposer unvalidated. + // The missing-proposer seed below is the QBFT-correct one and is what lets a + // full-state client validate + full-sync from genesis. + bool rrChecked = false, rrOk = false; + byte[]? expectedProposer = null; + if (parentProposer != null) + { + var sorted = new List(validators); + sorted.Sort(CompareAddr); + + rrChecked = true; + int parentIdx = IndexOfAddr(sorted, parentProposer); + int expectedIdx = parentIdx >= 0 + ? (int)(((parentIdx + 1 + extra.Round) % n + n) % n) // existing proposer + : (int)(((IndexOfNextProposer(sorted, parentProposer) + extra.Round) % n + n) % n); // missing proposer (genesis 0x0 / removed) + expectedProposer = sorted[expectedIdx]; + rrOk = ByteEq(header.Beneficiary, expectedProposer); + } + + 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})"; + else if (rrChecked && !rrOk) reject = "proposer does not match round-robin expectation"; + else if (!hashOk) reject = "computed block hash does not match expected"; + + return new QbftValidationResult + { + ComputedBlockHash = blockHash, + BlockHashMatches = hashOk, + ProposerInValidatorSet = proposerInSet, + RoundRobinChecked = rrChecked, + RoundRobinProposerOk = rrOk, + ExpectedProposer = expectedProposer, + ValidatorCount = n, + Quorum = quorum, + CommittedSealCount = extra.CommittedSeals.Count, + QuorumMet = quorumMet, + AllSealsDistinctValidators = allDistinctInSet, + RecoveredSigners = recovered, + RejectReason = reject, + }; + } + + private static bool ByteEq(byte[] a, byte[] b) + { + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) if (a[i] != b[i]) return false; + return true; + } + + private static bool ContainsAddr(List set, byte[] addr) => IndexOfAddr(set, addr) >= 0; + + private static int IndexOfAddr(List set, byte[] addr) + { + for (int i = 0; i < set.Count; i++) if (ByteEq(set[i], addr)) return i; + return -1; + } + + /// Big-endian unsigned comparison of two addresses (equal length in QBFT: 20 bytes). + private static int CompareAddr(byte[] a, byte[] b) + { + int len = a.Length < b.Length ? a.Length : b.Length; + for (int i = 0; i < len; i++) + { + if (a[i] != b[i]) return a[i] < b[i] ? -1 : 1; + } + return a.Length.CompareTo(b.Length); + } + + /// + /// Besu handleMissingProposer seed: index (into the ascending-sorted set) of the + /// smallest validator whose address is strictly greater than ; + /// 0 (wrap) if none is greater. For the genesis parent (prev == 0x0) every real + /// validator is greater, so this returns 0. + /// + private static int IndexOfNextProposer(List sortedValidators, byte[] prev) + { + for (int i = 0; i < sortedValidators.Count; i++) + if (CompareAddr(sortedValidators[i], prev) > 0) return i; + return 0; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/Rlp.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/Rlp.cs new file mode 100644 index 0000000..aee52db --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Qbft/Rlp.cs @@ -0,0 +1,178 @@ +// Minimal, allocation-simple RLP (Recursive Length Prefix) codec, sufficient for +// encoding Ethereum/Besu block headers and decoding QBFT extraData. Byte-faithful: +// decode(x) then encode() reproduces x exactly for well-formed canonical RLP, which +// is asserted against real chain-2800 extraData in the test-suite. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; + +namespace Nethermind.AerePqc.Qbft; + +public abstract class RlpItem +{ + public abstract void WriteTo(Stream s); + + public byte[] Encode() + { + using var ms = new MemoryStream(); + WriteTo(ms); + return ms.ToArray(); + } + + internal static void WriteLength(Stream s, int length, int offset) + { + if (length < 56) + { + s.WriteByte((byte)(offset + length)); + } + else + { + byte[] lenBytes = MinimalBytes(length); + s.WriteByte((byte)(offset + 55 + lenBytes.Length)); + s.Write(lenBytes, 0, lenBytes.Length); + } + } + + internal static byte[] MinimalBytes(long value) + { + if (value == 0) return Array.Empty(); + var tmp = new List(); + while (value > 0) { tmp.Insert(0, (byte)(value & 0xff)); value >>= 8; } + return tmp.ToArray(); + } +} + +/// An RLP string (byte array leaf). +public sealed class RlpBytes : RlpItem +{ + public byte[] Value { get; } + public RlpBytes(byte[] value) => Value = value; + + /// Encode a non-negative integer as a minimal big-endian RLP scalar. + public static RlpBytes Scalar(long value) => new(MinimalBytes(value)); + + public static RlpBytes Scalar(BigInteger value) + { + if (value.Sign < 0) throw new ArgumentException("negative scalar"); + if (value.IsZero) return new RlpBytes(Array.Empty()); + byte[] be = value.ToByteArray(isUnsigned: true, isBigEndian: true); + return new RlpBytes(be); + } + + public override void WriteTo(Stream s) + { + byte[] v = Value; + if (v.Length == 1 && v[0] < 0x80) + { + s.WriteByte(v[0]); + } + else + { + WriteLength(s, v.Length, 0x80); + s.Write(v, 0, v.Length); + } + } +} + +/// An RLP list of items. +public sealed class RlpList : RlpItem +{ + public List Items { get; } = new(); + public RlpList() { } + public RlpList(IEnumerable items) => Items.AddRange(items); + + public RlpList Add(RlpItem item) { Items.Add(item); return this; } + + public override void WriteTo(Stream s) + { + using var payload = new MemoryStream(); + foreach (RlpItem it in Items) it.WriteTo(payload); + byte[] p = payload.ToArray(); + WriteLength(s, p.Length, 0xc0); + s.Write(p, 0, p.Length); + } +} + +public static class Rlp +{ + /// Decode a single top-level RLP item from . + public static RlpItem Decode(byte[] data) + { + int pos = 0; + RlpItem item = DecodeItem(data, ref pos); + if (pos != data.Length) + throw new FormatException($"trailing RLP bytes: consumed {pos} of {data.Length}"); + return item; + } + + private static RlpItem DecodeItem(byte[] d, ref int pos) + { + if (pos >= d.Length) throw new FormatException("RLP underrun"); + int prefix = d[pos]; + + if (prefix < 0x80) + { + // single byte, itself + var b = new byte[] { d[pos] }; + pos += 1; + return new RlpBytes(b); + } + if (prefix < 0xb8) + { + int len = prefix - 0x80; + pos += 1; + return new RlpBytes(Slice(d, ref pos, len)); + } + if (prefix < 0xc0) + { + int lenOfLen = prefix - 0xb7; + pos += 1; + int len = (int)ReadLength(d, ref pos, lenOfLen); + return new RlpBytes(Slice(d, ref pos, len)); + } + if (prefix < 0xf8) + { + int len = prefix - 0xc0; + pos += 1; + return DecodeListPayload(d, ref pos, len); + } + else + { + int lenOfLen = prefix - 0xf7; + pos += 1; + int len = (int)ReadLength(d, ref pos, lenOfLen); + return DecodeListPayload(d, ref pos, len); + } + } + + private static RlpList DecodeListPayload(byte[] d, ref int pos, int len) + { + int end = pos + len; + if (end > d.Length) throw new FormatException("RLP list overrun"); + var list = new RlpList(); + while (pos < end) list.Add(DecodeItem(d, ref pos)); + if (pos != end) throw new FormatException("RLP list mis-sized"); + return list; + } + + private static long ReadLength(byte[] d, ref int pos, int lenOfLen) + { + if (lenOfLen <= 0 || lenOfLen > 8) throw new FormatException("bad RLP length-of-length"); + if (pos + lenOfLen > d.Length) throw new FormatException("RLP length overrun"); + long v = 0; + for (int i = 0; i < lenOfLen; i++) v = (v << 8) | d[pos + i]; + pos += lenOfLen; + return v; + } + + private static byte[] Slice(byte[] d, ref int pos, int len) + { + if (pos + len > d.Length) throw new FormatException("RLP slice overrun"); + var b = new byte[len]; + Buffer.BlockCopy(d, pos, b, 0, len); + pos += len; + return b; + } +} diff --git a/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs b/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs new file mode 100644 index 0000000..554b0f2 --- /dev/null +++ b/src/Nethermind/Nethermind.AerePqc/Qbft/Secp256k1Recover.cs @@ -0,0 +1,88 @@ +// secp256k1 ECDSA public-key recovery (ecrecover) -> 20-byte Ethereum address. +// Implements SEC1 v2 section 4.1.6 over BouncyCastle's curve arithmetic. This is +// exactly what a Besu/Nethermind node does to turn a 65-byte QBFT committed seal +// (r||s||v, v in {0,1}) into the signing validator's address. + +using System; +using Org.BouncyCastle.Asn1.Sec; +using Org.BouncyCastle.Crypto.EC; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Math.EC; + +namespace Nethermind.AerePqc.Qbft; + +public static class Secp256k1Recover +{ + private static readonly Org.BouncyCastle.Asn1.X9.X9ECParameters Curve = + SecNamedCurves.GetByName("secp256k1"); + private static readonly ECCurve C = Curve.Curve; + private static readonly ECPoint G = Curve.G; + private static readonly BigInteger N = Curve.N; + + /// + /// 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. + /// 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; + + 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; + + var e = new BigInteger(1, digest32); + + ECPoint? q = RecoverPoint(r, s, e, recId); + if (q == null) return null; + q = q.Normalize(); + if (q.IsInfinity) return null; + + byte[] x = To32(q.AffineXCoord.ToBigInteger()); + byte[] y = To32(q.AffineYCoord.ToBigInteger()); + var pub = new byte[64]; + Buffer.BlockCopy(x, 0, pub, 0, 32); + Buffer.BlockCopy(y, 0, pub, 32, 32); + + byte[] h = Keccak256.Hash(pub); + var addr = new byte[20]; + Buffer.BlockCopy(h, 12, addr, 0, 20); + return addr; + } + + private static ECPoint? RecoverPoint(BigInteger r, BigInteger s, BigInteger e, int recId) + { + // x = r (recId high bit unused for v in {0,1}) + BigInteger x = r; + // Decompress R with the y-parity encoded by recId. + byte[] xb = To32(x); + var enc = new byte[33]; + enc[0] = (byte)(0x02 | (recId & 1)); + Buffer.BlockCopy(xb, 0, enc, 1, 32); + ECPoint bigR; + try { bigR = C.DecodePoint(enc); } + catch { return null; } + + if (!bigR.Multiply(N).Normalize().IsInfinity) return null; // n*R must be O + + BigInteger rInv = r.ModInverse(N); + BigInteger srInv = s.Multiply(rInv).Mod(N); + BigInteger eNegrInv = e.Negate().Multiply(rInv).Mod(N); + // Q = (e^-1 term)*G + (s r^-1)*R == r^-1 (sR - eG) + return ECAlgorithms.SumOfTwoMultiplies(G, eNegrInv, bigR, srInv); + } + + private static byte[] To32(BigInteger v) + { + byte[] raw = v.ToByteArrayUnsigned(); + if (raw.Length == 32) return raw; + var outp = new byte[32]; + Buffer.BlockCopy(raw, 0, outp, 32 - raw.Length, raw.Length); + return outp; + } +} diff --git a/src/Nethermind/Nethermind.Blockchain.Test/Validators/AereQbftFollowerProofTests.cs b/src/Nethermind/Nethermind.Blockchain.Test/Validators/AereQbftFollowerProofTests.cs new file mode 100644 index 0000000..5cbc2cf --- /dev/null +++ b/src/Nethermind/Nethermind.Blockchain.Test/Validators/AereQbftFollowerProofTests.cs @@ -0,0 +1,555 @@ +// SPDX-FileCopyrightText: 2026 AERE Network +// SPDX-License-Identifier: LGPL-3.0-only +// +// PROOF: a second, independent client (Nethermind 1.39.0, C#/.NET) VALIDATES and +// would IMPORT real AERE chain-2800 QBFT blocks through Nethermind's REAL +// header-import validation path. +// +// This drives real chain-2800 headers through Nethermind's production +// HeaderValidator base class (subclassed as AereQbftHeaderValidator) with the +// AERE QBFT ISealValidator wired in and the QBFT-aware canonical-hash patch +// active. It is NOT a test of the standalone AERE validator: every accept/reject +// decision here flows through Nethermind's own HeaderValidator.Validate(), +// exercising parent linkage, EIP-1559 base fee, blob-gas, requests, gas limits, +// timestamps, block number, the (patched) canonical block-hash check, and the +// QBFT seal check - the same code the node runs when importing headers. +// +// Honest boundary: this is the VALIDATING/FOLLOWING half of client diversity. +// It is not a producing validator (no proposing/sealing, not in the QBFT set), +// and full stateful block execution (state-root re-derivation) is out of scope +// here because it needs AERE's world state. Consensus stays classical ECDSA QBFT. +// +// 2026-08-01, second pass. Three defects of this fixture were found by RUNNING it +// (it had never been run before) and are fixed here: +// +// 1. It could not load its own vectors. LoadVectors read root.GetProperty +// ("vectors") while the vector file was a bare JSON array, so all five tests +// died in the loader with "requires an element of type 'Object'". The loader +// now accepts the runs format, the {"vectors":[...]} format and a bare array. +// +// 2. It built its validator on SingleReleaseSpecProvider(Prague), a spec that +// does not carry the AERE base-fee floor, so the fixture rejected the genuine +// fork block 10,141,734 with "InvalidBaseFeePerGas: Expected 7, got +// 1000000000" - a defect of the test, not of the client, since the live node +// imported that block correctly. The validator is now built on the REAL +// chainspec of chain 2800 through ChainSpecBasedSpecProvider, which installs +// AereBaseFeeCalculator, so what is asserted here is what the node runs. +// +// 3. Its message claimed "consecutive genuine chain-2800 blocks" while only 6 of +// the 17 vectors were parent-linked and one block appeared twice. Vectors are +// now explicit contiguous RUNS (tools/make-follower-vectors.py) and linkage is +// re-checked here at load time. +// +// Added in the same pass: the genesis-parent round-robin (block 1 of chain 2800 +// committed at ROUND 1) and a tampered-round rejection, plus a planted-failure +// control, because a check that has never caught anything cannot be trusted. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Nethermind.AerePqc.Consensus; +using Nethermind.AerePqc.Qbft; +using Nethermind.Consensus; +using Nethermind.Consensus.Validators; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Extensions; +using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Int256; +using Nethermind.Logging; +using Nethermind.Serialization.Json; +using Nethermind.Specs.ChainSpecStyle; +using NUnit.Framework; + +namespace Nethermind.Blockchain.Test.Validators; + +[TestFixture] +public class AereQbftFollowerProofTests +{ + private const ulong AereChainId = 2800; + private const long BaseFeeFloorForkBlock = 10_141_734; + + private static string TestDataPath(string name) => + Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", name); + + private static string ResolvePath(string envVar, string testDataName, string fallback) + { + string? fromEnv = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrWhiteSpace(fromEnv) && File.Exists(fromEnv)) return fromEnv; + string inTestData = TestDataPath(testDataName); + if (File.Exists(inTestData)) return inTestData; + return fallback; + } + + private static string VectorsPath => + ResolvePath("AERE_QBFT_VECTORS", "qbft-follower-vectors.json", "./qbft-follower-vectors.json"); + + private static string ChainSpecPath => + ResolvePath("AERE_CHAINSPEC", "aere-live-chainspec.json", "./aere-live-chainspec.json"); + + private sealed record Run(string Name, IReadOnlyList Blocks); + + /// + /// Loads the vectors as contiguous parent-linked RUNS. Accepts three shapes: + /// the current {"runs":[{"name","blocks":[...]}]}, the older {"vectors":[...]} + /// and an older bare array; for the two flat shapes the blocks are split into + /// runs here by actual parentHash linkage, so no caller can claim a linkage the + /// file does not have. + /// + private static List LoadRuns() + { + string path = VectorsPath; + Assert.That(File.Exists(path), $"vector file not found: {path}"); + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path)); + JsonElement root = doc.RootElement.Clone(); + + List runs = []; + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("runs", out JsonElement runsEl)) + { + foreach (JsonElement r in runsEl.EnumerateArray()) + { + runs.Add(new Run(r.GetProperty("name").GetString()!, + r.GetProperty("blocks").EnumerateArray().ToList())); + } + } + else + { + JsonElement flat = root.ValueKind == JsonValueKind.Array ? root : root.GetProperty("vectors"); + List current = []; + int seq = 0; + foreach (JsonElement v in flat.EnumerateArray()) + { + if (current.Count > 0 && + !string.Equals(v.GetProperty("parentHash").GetString(), + current[^1].GetProperty("hash").GetString(), + StringComparison.OrdinalIgnoreCase)) + { + runs.Add(new Run($"flat-{seq++}", current)); + current = []; + } + current.Add(v); + } + if (current.Count > 0) runs.Add(new Run($"flat-{seq}", current)); + } + + // Linkage is re-verified here, at load time, for every shape. + foreach (Run run in runs) + { + for (int i = 1; i < run.Blocks.Count; i++) + { + Assert.That(run.Blocks[i].GetProperty("parentHash").GetString(), + Is.EqualTo(run.Blocks[i - 1].GetProperty("hash").GetString()).IgnoreCase, + $"run {run.Name}: block {run.Blocks[i].GetProperty("number").GetString()} is not linked to its predecessor"); + } + } + + Assert.That(runs.Count, Is.GreaterThan(0), "no vectors loaded"); + return runs; + } + + private static Run RunNamed(string name) + { + List runs = LoadRuns(); + Run? r = runs.FirstOrDefault(x => x.Name == name); + // Fall back to the longest run when loading an older flat vector file. + return r ?? runs.OrderByDescending(x => x.Blocks.Count).First(); + } + + private static Hash256 H(string hex) => new(hex); + private static byte[] B(string hex) => Bytes.FromHexString(hex); + private static ulong U(string? hex) => hex is null ? 0UL : (ulong)Convert.ToInt64(hex, 16); + private static UInt256 Uint(string hex) => new(Bytes.FromHexString(hex), true); + + private static BlockHeader BuildHeader(JsonElement v, UInt256 baseFee) + { + string? S(string k) => v.TryGetProperty(k, out JsonElement e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null; + + BlockHeader header = new( + parentHash: H(S("parentHash")!), + unclesHash: H(S("sha3Uncles")!), + beneficiary: new Address(B(S("miner")!)), + difficulty: Uint(S("difficulty")!), + number: (long)U(S("number")), + gasLimit: (long)U(S("gasLimit")), + timestamp: U(S("timestamp")), + extraData: B(S("extraData")!), + blobGasUsed: S("blobGasUsed") is string bg ? U(bg) : null, + excessBlobGas: S("excessBlobGas") is string eg ? U(eg) : null, + parentBeaconBlockRoot: S("parentBeaconBlockRoot") is string pb ? H(pb) : null, + requestsHash: S("requestsHash") is string rq ? H(rq) : null) + { + StateRoot = H(S("stateRoot")!), + TxRoot = H(S("transactionsRoot")!), + ReceiptsRoot = H(S("receiptsRoot")!), + Bloom = new Bloom(B(S("logsBloom")!)), + GasUsed = (long)U(S("gasUsed")), + MixHash = H(S("mixHash")!), + Nonce = U(S("nonce")), + BaseFeePerGas = baseFee, + WithdrawalsRoot = S("withdrawalsRoot") is string wr ? H(wr) : null, + }; + return header; + } + + /// + /// Build the header and prove the base fee we used is the CONSENSUS one: the + /// chain-reported baseFeePerGas is tried first (the RPC base-fee shim of chain + /// 2800 was retired, so the RPC now reports the real number), then a small + /// candidate list for older captures. The proof is that Nethermind's own + /// (QBFT-patched) CalculateHash reproduces the chain-reported block hash. + /// + private static (BlockHeader header, UInt256 baseFee) BuildGenuine(JsonElement v) + { + Hash256 reported = H(v.GetProperty("hash").GetString()!); + + List candidates = []; + if (v.TryGetProperty("baseFeePerGas", out JsonElement bf) && bf.ValueKind == JsonValueKind.String) + candidates.Add(Uint(bf.GetString()!)); + foreach (ulong c in new ulong[] { 1000000000UL, 7, 6, 8, 5, 9, 10, 11, 12, 13, 14, 15, 16, 17, 1 }) + candidates.Add(c); + + foreach (UInt256 cand in candidates) + { + BlockHeader h = BuildHeader(v, cand); + if (h.CalculateHash() == reported) + { + h.Hash = reported; + return (h, cand); + } + } + + Assert.Fail($"No candidate base fee reproduced hash for block {v.GetProperty("number").GetString()}"); + return default; + } + + /// + /// The validator under test, built on the REAL chainspec of chain 2800 (the same + /// file the live second client node runs), so the AERE base-fee floor calculator is + /// installed exactly as it is in production. Building it on a stock spec provider + /// is what made this fixture reject the genuine fork block. + /// + private static AereQbftHeaderValidator BuildValidator() + { + string specPath = ChainSpecPath; + Assert.That(File.Exists(specPath), $"chainspec not found: {specPath}"); + ChainSpec chainSpec = new ChainSpecFileLoader(new EthereumJsonSerializer(), LimboLogs.Instance) + .LoadEmbeddedOrFromFile(specPath); + Assert.That(chainSpec.ChainId, Is.EqualTo(AereChainId), "chainspec is not chain 2800"); + + ChainSpecBasedSpecProvider specProvider = new(chainSpec); + Nethermind.Blockchain.IBlockTree blockTree = Build.A.BlockTree().WithoutSettingHead.TestObject; + AereQbftSealValidator seal = new(LimboLogs.Instance); + return new AereQbftHeaderValidator(blockTree, seal, specProvider, LimboLogs.Instance); + } + + private static byte[] FlipSeals(byte[] extraData, int howMany) + { + RlpList top = (RlpList)Rlp.Decode(extraData); + RlpList sealList = (RlpList)top.Items[4]; + for (int i = 0; i < howMany && i < sealList.Items.Count; i++) + { + byte[] s = ((RlpBytes)sealList.Items[i]).Value; + s[64] ^= 0x01; + } + return top.Encode(); + } + + private static byte[] DropSeals(byte[] extraData, int keep) + { + RlpList top = (RlpList)Rlp.Decode(extraData); + RlpList sealList = (RlpList)top.Items[4]; + while (sealList.Items.Count > keep) sealList.Items.RemoveAt(sealList.Items.Count - 1); + return top.Encode(); + } + + private static byte[] WithRound(byte[] extraData, long round) + { + RlpList top = (RlpList)Rlp.Decode(extraData); + top.Items[3] = RlpBytes.Scalar(round); + return top.Encode(); + } + + // ---------------------------------------------------------------- accept + + [Test] + public void Second_client_accepts_every_genuine_chain2800_block_and_reproduces_its_hash() + { + List runs = LoadRuns(); + AereQbftHeaderValidator validator = BuildValidator(); + + int accepted = 0, hashed = 0; + foreach (Run run in runs) + { + List headers = []; + List fees = []; + foreach (JsonElement v in run.Blocks) + { + (BlockHeader h, UInt256 bf) = BuildGenuine(v); + Assert.That(h.Hash, Is.EqualTo(H(v.GetProperty("hash").GetString()!)), + $"Nethermind must reproduce chain hash of block {h.Number}"); + headers.Add(h); + fees.Add(bf); + hashed++; + } + + for (int i = 1; i < headers.Count; i++) + { + bool ok = validator.Validate(headers[i], headers[i - 1], false, out string? error); + Assert.That(ok, Is.True, + $"run {run.Name}: genuine block {headers[i].Number} must pass Nethermind HeaderValidator; error={error}"); + accepted++; + } + + TestContext.Out.WriteLine( + $"ACCEPT run {run.Name}: blocks {headers[0].Number}..{headers[^1].Number}, " + + $"{headers.Count} hashes reproduced, {headers.Count - 1} parent-linked pairs validated, " + + $"baseFee {fees[0]}..{fees[^1]} wei."); + } + + Assert.That(hashed, Is.GreaterThanOrEqualTo(20)); + Assert.That(accepted, Is.GreaterThanOrEqualTo(15)); + TestContext.Out.WriteLine($"TOTAL: {hashed} genuine headers hash-reproduced, {accepted} accepted by HeaderValidator."); + } + + [Test] + public void Second_client_accepts_the_base_fee_floor_fork_block_that_stalled_it() + { + Run run = RunNamed("basefee-floor-fork"); + Dictionary byNumber = []; + foreach (JsonElement v in run.Blocks) + { + (BlockHeader h, UInt256 fee) = BuildGenuine(v); + byNumber[h.Number] = (h, fee); + } + + Assert.That(byNumber.ContainsKey(BaseFeeFloorForkBlock), "vectors must contain the fork block"); + Assert.That(byNumber[BaseFeeFloorForkBlock - 1].fee, Is.EqualTo((UInt256)7), "last pre-fork block carries 7 wei"); + Assert.That(byNumber[BaseFeeFloorForkBlock].fee, Is.EqualTo((UInt256)1_000_000_000), "the fork block carries 1 Gwei"); + + AereQbftHeaderValidator validator = BuildValidator(); + bool ok = validator.Validate(byNumber[BaseFeeFloorForkBlock].h, byNumber[BaseFeeFloorForkBlock - 1].h, + false, out string? error); + + Assert.That(ok, Is.True, + $"block {BaseFeeFloorForkBlock} is the block that stalled the second client; it must now pass. error={error}"); + TestContext.Out.WriteLine( + $"ACCEPT fork block {BaseFeeFloorForkBlock} (7 wei -> 1 Gwei) through the live chainspec's AereBaseFeeCalculator."); + } + + /// + /// The genesis-parent round-robin, on a real mainnet block. Block 1 of chain 2800 + /// committed at ROUND 1 with N=3; its parent is genesis, whose coinbase 0x0 is not + /// a validator. Besu's handleMissingProposer seed gives sorted[(0 + round) mod N] = + /// sorted[1], which is the real proposer. The naive "seed from the parent" formula + /// adds a spurious +1 and gives sorted[2]: this test measures that the two formulas + /// really do disagree here, so accepting block 1 is evidence and not a coincidence. + /// + [Test] + public void Second_client_validates_block_1_committed_at_round_1_from_a_genesis_parent() + { + Run run = RunNamed("genesis-round1"); + (BlockHeader genesis, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader block1, _) = BuildGenuine(run.Blocks[1]); + Assert.That(genesis.Number, Is.EqualTo(0)); + Assert.That(block1.Number, Is.EqualTo(1)); + + QbftExtraData extra = QbftExtraData.Decode(block1.ExtraData); + int n = extra.Validators.Count; + long round = extra.Round; + List sorted = extra.Validators.ToList(); + sorted.Sort((a, b) => + { + for (int i = 0; i < a.Length; i++) if (a[i] != b[i]) return a[i] < b[i] ? -1 : 1; + return 0; + }); + + Address besuSeed = new(sorted[(int)((round % n + n) % n)]); // handleMissingProposer + Address naiveSeed = new(sorted[(int)(((round + 1) % n + n) % n)]); // the wrong "+1" formula + + TestContext.Out.WriteLine($"block 1: N={n}, round={round}, proposer={block1.Beneficiary}, " + + $"Besu missing-proposer seed={besuSeed}, naive +1 seed={naiveSeed}"); + + Assert.That(besuSeed, Is.Not.EqualTo(naiveSeed), + "the two formulas must disagree here, otherwise this block proves nothing"); + Assert.That(block1.Beneficiary, Is.EqualTo(besuSeed), + "the real chain agrees with the Besu missing-proposer seed"); + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(block1, genesis, false, out string? error), Is.True, + $"genuine round-{round} block 1 must be accepted; error={error}"); + + // The check is load-bearing: put the naive formula's validator in the coinbase, + // recompute the canonical hash so only the round-robin can object, expect reject. + BlockHeader bad = block1.Clone(); + bad.Beneficiary = naiveSeed; + bad.Hash = bad.CalculateHash(); + Assert.That(validator.Validate(bad, genesis, false, out string? error2), Is.False, + "the proposer the naive formula would expect must be REJECTED at block 1"); + + TestContext.Out.WriteLine($"ACCEPT genuine block 1 at round {round}; REJECT naive-formula proposer {naiveSeed}: {error2}"); + } + + // ---------------------------------------------------------------- reject + + [Test] + public void Second_client_rejects_a_tampered_base_fee() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader genuine, UInt256 trueFee) = BuildGenuine(run.Blocks[1]); + + BlockHeader bad = BuildHeader(run.Blocks[1], trueFee + 1); + bad.Hash = genuine.Hash; // claim the real hash with a fee that does not produce it + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, + "a base fee that does not reproduce the block hash must be rejected"); + TestContext.Out.WriteLine($"REJECT tampered baseFee ({trueFee} -> {trueFee + 1}) block {bad.Number}: {error}"); + } + + [Test] + public void Second_client_rejects_flipped_committed_seals() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); + + QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); + int quorum = QbftHeaderValidator.Quorum(extra.Validators.Count); + int seals = extra.CommittedSeals.Count; + int toBreak = seals - quorum + 1; + + BlockHeader bad = child.Clone(); + bad.ExtraData = FlipSeals(child.ExtraData.ToArray(), toBreak); + bad.Hash = child.Hash; // canonical hash is seal-stripped, unaffected + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, + "flipped committed seals must drop below quorum and be rejected"); + Assert.That(validator.Validate(child, parent, false, out _), Is.True, + "the genuine header must still pass the same validator"); + TestContext.Out.WriteLine($"REJECT flipped seals ({toBreak}/{seals} broken, quorum {quorum}): block {child.Number}: {error}"); + } + + [Test] + public void Second_client_rejects_dropped_seal_below_quorum() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); + + QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); + int quorum = QbftHeaderValidator.Quorum(extra.Validators.Count); + int seals = extra.CommittedSeals.Count; + int target = quorum - 1; + + BlockHeader bad = child.Clone(); + bad.ExtraData = DropSeals(child.ExtraData.ToArray(), target); + bad.Hash = child.Hash; + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, + $"only {target} seals (< quorum {quorum}) must be rejected"); + TestContext.Out.WriteLine($"REJECT dropped seals ({seals}->{target}, quorum {quorum}): block {child.Number}: {error}"); + } + + [Test] + public void Second_client_rejects_non_validator_proposer() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); + + BlockHeader bad = child.Clone(); + bad.Beneficiary = new Address(B("0x000000000000000000000000000000000000dead")); + bad.Hash = bad.CalculateHash(); // recompute so only the proposer rule can object + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, + "a non-validator proposer must be rejected"); + TestContext.Out.WriteLine($"REJECT non-validator proposer: block {bad.Number}: {error}"); + } + + [Test] + public void Second_client_rejects_an_in_set_proposer_that_is_wrong_for_the_round() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); + + QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); + Address? other = null; + foreach (byte[] vb in extra.Validators) + { + Address a = new(vb); + if (a != child.Beneficiary && a != parent.Beneficiary) { other = a; break; } + } + Assert.That(other, Is.Not.Null, "need an alternate in-set validator"); + + BlockHeader bad = child.Clone(); + bad.Beneficiary = other!; + bad.Hash = bad.CalculateHash(); + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, + "an in-set validator that is not the round-robin proposer for this height must be rejected"); + TestContext.Out.WriteLine($"REJECT wrong in-set proposer {other} for block {child.Number}: {error}"); + } + + /// + /// A tampered ROUND. The round is stripped from the canonical block hash, so the + /// hash check still passes and the rejection is isolated to the QBFT layer: the + /// round is part of the committed-seal digest and of the round-robin expectation, + /// so changing it must invalidate the block. + /// + [Test] + public void Second_client_rejects_a_tampered_round() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); + + QbftExtraData extra = QbftExtraData.Decode(child.ExtraData); + long tamperedRound = extra.Round + 1; + + BlockHeader bad = child.Clone(); + bad.ExtraData = WithRound(child.ExtraData.ToArray(), tamperedRound); + bad.Hash = child.Hash; // round is stripped from the canonical hash: unchanged + + Assert.That(bad.CalculateHash(), Is.EqualTo(child.Hash), + "the canonical hash must be unaffected by the round, otherwise this test measures the hash check"); + + AereQbftHeaderValidator validator = BuildValidator(); + Assert.That(validator.Validate(bad, parent, false, out string? error), Is.False, + $"round {extra.Round} -> {tamperedRound} must be rejected"); + TestContext.Out.WriteLine($"REJECT tampered round ({extra.Round} -> {tamperedRound}) block {child.Number}: {error}"); + } + + // ---------------------------------------------------------------- control + + /// + /// Planted failure. A fixture that has never produced a failure cannot be trusted + /// to be able to. This asserts something FALSE about a genuine header and requires + /// the assertion machinery to raise, proving every PASS above is load-bearing. + /// + [Test] + public void Planted_control_this_fixture_can_actually_fail() + { + Run run = RunNamed("tip"); + (BlockHeader parent, _) = BuildGenuine(run.Blocks[0]); + (BlockHeader child, _) = BuildGenuine(run.Blocks[1]); + AereQbftHeaderValidator validator = BuildValidator(); + + bool genuineAccepted = validator.Validate(child, parent, false, out _); + Assert.That(genuineAccepted, Is.True, "precondition: the genuine block is accepted"); + + Exception? raised = Assert.Catch(() => + Assert.That(genuineAccepted, Is.False, "PLANTED: genuine header asserted invalid")); + + Assert.That(raised, Is.Not.Null, "the planted false assertion produced NO failure: this fixture is blind"); + TestContext.Out.WriteLine("PLANTED CONTROL raised as required: " + raised!.Message.Split('\n')[0]); + } +} diff --git a/src/Nethermind/Nethermind.Consensus.Ethash/AereBaseFeeCalculator.cs b/src/Nethermind/Nethermind.Consensus.Ethash/AereBaseFeeCalculator.cs new file mode 100644 index 0000000..8c43c2a --- /dev/null +++ b/src/Nethermind/Nethermind.Consensus.Ethash/AereBaseFeeCalculator.cs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core; +using Nethermind.Core.Specs; +using Nethermind.Int256; + +namespace Nethermind.Consensus.Ethash; + +/// +/// AERE base-fee floor fork, chain 2800. The .NET twin of the Besu rule that produced the +/// live chain. +/// +/// Besu side (canonical, it produced every block of 2800): +/// aerenew/basefee-floor-dryrun/basefee-floor.diff patches +/// ethereum/core/src/main/java/org/hyperledger/besu/ethereum/mainnet/feemarket/LondonFeeMarket.java +/// - lines 46-49 (patched file): the two fork parameters, read from Java system properties +/// aere.basefee.floor.forkBlock (default Long.MAX_VALUE = never active) +/// aere.basefee.floor.value (default 1_000_000_000) +/// - lines 105-114: applyAereBaseFeeFloor(blockNumber, fee) +/// if (blockNumber >= FORK_BLOCK and fee < FLOOR) return FLOOR; else return fee; +/// - lines 124, 130, 150: the three return sites of computeBaseFee routed through it. +/// +/// Three properties of the Besu rule that this class reproduces exactly: +/// 1. The clamp is applied AFTER the EIP-1559 delta, never before. The delta is computed +/// from the unclamped parent base fee exactly as in stock EIP-1559; only the result is +/// raised to the floor. +/// 2. The gate is the number of the block BEING computed (the child), not the parent. +/// Besu's computeBaseFee takes that block number as its first argument. Here the child +/// is parent.Number + 1. So block 10,141,734 itself is floored; 10,141,733 is not. +/// 3. It is a max(), not an assignment: a computed fee above the floor is left alone. +/// On chain 2800 that path is never exercised while blocks are empty, but the rule is +/// the same one Besu runs. +/// +/// It applies to all three Besu return paths, including the "parent gas used == target" +/// short circuit and the London-transition initial base fee. Wrapping an inner +/// gives the same coverage in one place, because every +/// path of returns through this method. +/// +/// Configuration lives in the chainspec, never in code: engine.NethDev.params +/// "aereBaseFeeFloorForkBlock": 10141734 +/// "aereBaseFeeFloorValue": "0x3b9aca00" +/// When either is absent this calculator is not installed at all and the client behaves +/// exactly like stock Nethermind, mirroring Besu's dormant default. +/// +/// +/// Why not the pre-existing eip1559BaseFeeMinValue / eip1559BaseFeeMinValueTransition +/// chainspec parameters, which express the same clamp inside ? +/// Because on this chainspec they cannot express this fork. ChainSpecBasedSpecProvider builds the +/// timestamp-activated release specs with activationBlockNumber = biggestBlockTransition +/// (CreateTransitions), and chain 2800 has timestamp forks from 0x6a1b877b onwards while block +/// 10,141,734 has timestamp 0x6a5a6f7a, far past it. Adding a block transition at 10,141,734 would +/// therefore stamp every timestamp-activated release spec with releaseStartBlock = 10,141,734 and +/// switch the floor on for every block whose timestamp is at or past the first timestamp fork, +/// which is millions of blocks BEFORE the fork. That is the failure mode where a client agrees for +/// a while and then diverges. Gating inside the calculator on the child block number cannot drift. +/// AereBaseFeeFloorForkTests.Chainspec_min_value_transition_would_activate_the_floor_too_early +/// asserts that this trap is real rather than hypothetical. +/// +public sealed class AereBaseFeeCalculator(long forkBlock, UInt256 floorValue, IBaseFeeCalculator inner) + : IBaseFeeCalculator +{ + /// First block number at which the floor is enforced. 10,141,734 on chain 2800. + public long ForkBlock { get; } = forkBlock; + + /// Minimum base fee from onwards. 1 Gwei on chain 2800. + public UInt256 FloorValue { get; } = floorValue; + + /// The unmodified EIP-1559 calculation the floor is layered on top of. + public IBaseFeeCalculator Inner { get; } = inner; + + // Exceptia istorica NU sta aici. Vezi Nethermind.Core.AereFloorLapse si HeaderValidator: + // in intervalul acela taxa nu e o functie de parinte, deci niciun calcul nu o reproduce. + // Calculatorul ramane regula CURATA; validarea e cea care accepta antetul asa cum e. + + public UInt256 Calculate(BlockHeader parent, IEip1559Spec specFor1559) + { + UInt256 baseFee = Inner.Calculate(parent, specFor1559); + + // Besu gates on the number of the block being produced or validated, which is the + // child of the header we are given. + long blockNumber = parent.Number + 1; + + return blockNumber >= ForkBlock ? UInt256.Max(baseFee, FloorValue) : baseFee; + } +} diff --git a/src/Nethermind/Nethermind.Core/AereFloorLapse.cs b/src/Nethermind/Nethermind.Core/AereFloorLapse.cs new file mode 100644 index 0000000..212a2a8 --- /dev/null +++ b/src/Nethermind/Nethermind.Core/AereFloorLapse.cs @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Core; + +/// +/// The one historical range in which chain 2800 did not apply its own base-fee floor. +/// +/// WHAT HAPPENED. The aere.basefee.floor fork is live from block 10,141,734 with a +/// 1 Gwei floor. On 2026-08-09 the seven validators lost the system properties that switch it on: +/// the script that wrote the post-quantum anchor configuration rewrote BESU_OPTS and dropped +/// every other aere.* property with it. For about sixteen hours the floor was not applied. +/// +/// WHY THIS IS NOT A CALCULATION RULE, and the first attempt got this wrong. Inside the range +/// the base fee is NOT a function of the parent header. Some producers still carried the floor and +/// some did not, so the value depends on WHICH validator won the round: block 12,978,617 is +/// 875,000,000 wei (a producer without the floor, one EIP-1559 decay step from 1 Gwei) and its child +/// 12,978,618 is back at 1,000,000,000 (a producer with it). No deterministic calculation can +/// reproduce that alternation. The exception therefore belongs at VALIDATION: over this range the +/// header's own baseFeePerGas is accepted as given, which is exactly what Besu does on every +/// block and the reason the chain never halted. +/// +/// HOW THE BOUNDS WERE FOUND. Not by binary search: the violations are intermittent, so the +/// predicate is not monotone and a binary search returns a meaningless answer. It returned 12,978,949 +/// on the first attempt, 332 blocks too late. The real lower bound came from the second client +/// refusing to sync past 12,978,617, then confirmed by exhaustive scan: the 500 blocks +/// 12,978,117-12,978,616 contain zero violations, and the 2,000 blocks after 13,087,959 contain zero. +/// +/// THE BOUNDS ARE FIXED IN CODE ON PURPOSE. A range a node could widen at runtime would let a +/// future lapse pass unnoticed. Outside it the floor is validated normally, so a NEW violation still +/// stops this client, which is what should happen. +/// +public static class AereFloorLapse +{ + /// Chain id this exception applies to. It applies to no other chain. + public const ulong ChainId = 2800; + + /// First block of the lapse, inclusive. + public const long FirstBlock = 12_978_617; + + /// Last block of the lapse, inclusive. + public const long LastBlock = 13_087_959; + + /// True only for blocks inside the historical lapse. + public static bool Covers(long blockNumber) => + blockNumber >= FirstBlock && blockNumber <= LastBlock; +} diff --git a/src/Nethermind/Nethermind.Ethash.Test/AereBaseFeeFloorForkTests.cs b/src/Nethermind/Nethermind.Ethash.Test/AereBaseFeeFloorForkTests.cs new file mode 100644 index 0000000..ae44918 --- /dev/null +++ b/src/Nethermind/Nethermind.Ethash.Test/AereBaseFeeFloorForkTests.cs @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Nethermind.Consensus.Ethash; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Int256; +using Nethermind.Logging; +using Nethermind.Serialization.Json; +using Nethermind.Specs.ChainSpecStyle; +using NUnit.Framework; + +namespace Nethermind.Ethash.Test; + +/// +/// Differential test for the AERE base-fee floor fork of chain 2800. +/// +/// The other implementation is not a model, it is the live chain: every block in +/// TestData/basefee-corpus.json was produced and sealed by the Besu fork carrying +/// aerenew/basefee-floor-dryrun/basefee-floor.diff, so its baseFeePerGas IS Besu's +/// computeBaseFee output for the recorded parent. The corpus was captured from +/// rpc2.aere.network (a non-validator public RPC of chain 2800). +/// +/// The corpus spans before, exactly at, and after block 10,141,734, and the boundary +/// blocks 10,141,733 / 10,141,734 / 10,141,735 are present explicitly. +/// +public class AereBaseFeeFloorForkTests +{ + private const long ForkBlock = 10_141_734; + private static readonly UInt256 FloorValue = 1_000_000_000; + + private sealed record CorpusEntry( + long Child, + long ParentNumber, + long ParentGasLimit, + long ParentGasUsed, + UInt256 ParentBaseFeePerGas, + ulong ParentTimestamp, + ulong ChildTimestamp, + UInt256 ChildBaseFeePerGasBesu); + + private static string TestDataPath(string name) => + Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", name); + + private static List LoadCorpus() + { + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(TestDataPath("basefee-corpus.json"))); + Assert.That(doc.RootElement.GetProperty("forkBlock").GetInt64(), Is.EqualTo(ForkBlock), + "corpus was captured for a different fork block"); + + List entries = []; + foreach (JsonElement e in doc.RootElement.GetProperty("entries").EnumerateArray()) + { + entries.Add(new CorpusEntry( + e.GetProperty("child").GetInt64(), + e.GetProperty("parentNumber").GetInt64(), + e.GetProperty("parentGasLimit").GetInt64(), + e.GetProperty("parentGasUsed").GetInt64(), + (UInt256)e.GetProperty("parentBaseFeePerGas").GetUInt64(), + e.GetProperty("parentTimestamp").GetUInt64(), + e.GetProperty("childTimestamp").GetUInt64(), + (UInt256)e.GetProperty("childBaseFeePerGas_besu").GetUInt64())); + } + + Assert.That(entries, Is.Not.Empty); + return entries; + } + + private static ChainSpec LoadLiveChainSpec() => + new ChainSpecFileLoader(new EthereumJsonSerializer(), LimboLogs.Instance) + .LoadEmbeddedOrFromFile(TestDataPath("aere-live-chainspec.json")); + + private static BlockHeader ParentHeaderOf(CorpusEntry e) + { + BlockHeader header = new( + Keccak.Zero, Keccak.OfAnEmptySequenceRlp, Address.Zero, UInt256.One, + e.ParentNumber, e.ParentGasLimit, e.ParentTimestamp, []) + { + GasUsed = e.ParentGasUsed, + BaseFeePerGas = e.ParentBaseFeePerGas + }; + return header; + } + + /// The chainspec really carries the fork parameters, and they really bind. + [Test] + public void Chainspec_installs_the_floor_calculator_with_the_live_parameters() + { + ChainSpec chainSpec = LoadLiveChainSpec(); + NethDevChainSpecEngineParameters engineParams = + chainSpec.EngineChainSpecParametersProvider.GetChainSpecParameters(); + + Assert.That(engineParams.AereBaseFeeFloorForkBlock, Is.EqualTo(ForkBlock)); + Assert.That(engineParams.AereBaseFeeFloorValue, Is.EqualTo(FloorValue)); + + ChainSpecBasedSpecProvider provider = new(chainSpec); + IReleaseSpec spec = provider.GetSpec(new ForkActivation(ForkBlock, 1_784_311_675)); + + Assert.That(spec.BaseFeeCalculator, Is.TypeOf()); + AereBaseFeeCalculator calculator = (AereBaseFeeCalculator)spec.BaseFeeCalculator; + Assert.That(calculator.ForkBlock, Is.EqualTo(ForkBlock)); + Assert.That(calculator.FloorValue, Is.EqualTo(FloorValue)); + Assert.That(calculator.Inner, Is.TypeOf()); + } + + /// + /// The differential assertion. For every real parent in the corpus the client must compute + /// exactly the base fee Besu sealed into the real child block. + /// + [Test] + public void Client_agrees_with_the_live_Besu_chain_on_every_corpus_block() + { + ChainSpecBasedSpecProvider provider = new(LoadLiveChainSpec()); + List rows = []; + List failures = []; + + foreach (CorpusEntry e in LoadCorpus()) + { + IReleaseSpec spec = provider.GetSpec(new ForkActivation(e.Child, e.ChildTimestamp)); + UInt256 withFloor = BaseFeeCalculator.Calculate(ParentHeaderOf(e), spec); + UInt256 withoutFloor = new DefaultBaseFeeCalculator().Calculate(ParentHeaderOf(e), spec); + + rows.Add($" child {e.Child,10} parentBaseFee {e.ParentBaseFeePerGas,12} " + + $"besu {e.ChildBaseFeePerGasBesu,12} nethermind {withFloor,12} " + + $"stockNethermind(noFloor) {withoutFloor,12} " + + $"{(withFloor == e.ChildBaseFeePerGasBesu ? "AGREE" : "DIVERGE")}"); + + if (withFloor != e.ChildBaseFeePerGasBesu) + { + failures.Add($"block {e.Child}: besu {e.ChildBaseFeePerGasBesu}, nethermind {withFloor}"); + } + } + + TestContext.Out.WriteLine("AERE base-fee floor differential, real blocks of chain 2800:"); + foreach (string row in rows) TestContext.Out.WriteLine(row); + + Assert.That(failures, Is.Empty, string.Join(Environment.NewLine, failures)); + } + + /// + /// The boundary, spelled out. Below the fork block the floor must not apply, at and above + /// it must. 10,141,733 carries 7 wei on the live chain, 10,141,734 carries 1 Gwei. + /// + [Test] + public void Floor_switches_on_exactly_at_the_fork_block_and_not_one_block_earlier() + { + ChainSpecBasedSpecProvider provider = new(LoadLiveChainSpec()); + Dictionary byChild = []; + foreach (CorpusEntry e in LoadCorpus()) byChild[e.Child] = e; + + foreach (long child in new[] { ForkBlock - 1, ForkBlock, ForkBlock + 1 }) + { + Assert.That(byChild.ContainsKey(child), $"corpus is missing boundary block {child}"); + } + + CorpusEntry last = byChild[ForkBlock - 1]; + CorpusEntry first = byChild[ForkBlock]; + CorpusEntry second = byChild[ForkBlock + 1]; + + UInt256 Compute(CorpusEntry e) => + BaseFeeCalculator.Calculate(ParentHeaderOf(e), provider.GetSpec(new ForkActivation(e.Child, e.ChildTimestamp))); + + Assert.That(last.ChildBaseFeePerGasBesu, Is.EqualTo((UInt256)7), "live chain: last pre-fork block"); + Assert.That(Compute(last), Is.EqualTo((UInt256)7), "the floor must NOT apply at forkBlock - 1"); + + Assert.That(first.ChildBaseFeePerGasBesu, Is.EqualTo(FloorValue), "live chain: the fork block itself"); + Assert.That(Compute(first), Is.EqualTo(FloorValue), "the floor must apply at the fork block"); + + Assert.That(Compute(second), Is.EqualTo(FloorValue)); + } + + /// + /// The floor is load-bearing, not cosmetic: without it the client reproduces exactly the + /// stall observed on the second client, "Expected base fee: 7, Current base fee: 1000000000", + /// and it keeps diverging on every later block, not only on the boundary. + /// + [Test] + public void Without_the_floor_the_client_reproduces_the_observed_stall() + { + ChainSpecBasedSpecProvider provider = new(LoadLiveChainSpec()); + int divergences = 0; + + foreach (CorpusEntry e in LoadCorpus()) + { + IReleaseSpec spec = provider.GetSpec(new ForkActivation(e.Child, e.ChildTimestamp)); + UInt256 stock = new DefaultBaseFeeCalculator().Calculate(ParentHeaderOf(e), spec); + + if (e.Child < ForkBlock) + { + Assert.That(stock, Is.EqualTo(e.ChildBaseFeePerGasBesu), + $"pre-fork block {e.Child} must agree even without the floor"); + } + else + { + Assert.That(stock, Is.Not.EqualTo(e.ChildBaseFeePerGasBesu), + $"post-fork block {e.Child} must diverge without the floor"); + divergences++; + } + + if (e.Child == ForkBlock) + { + // The exact pair from the second client log, 2026-08-01 01:11:37Z. + Assert.That(stock, Is.EqualTo((UInt256)7)); + Assert.That(e.ChildBaseFeePerGasBesu, Is.EqualTo((UInt256)1_000_000_000)); + } + + if (e.Child == ForkBlock + 1) + { + // One block later the unfloored decay is visible: 1 Gwei - 1 Gwei/8. + Assert.That(stock, Is.EqualTo((UInt256)875_000_000)); + } + } + + Assert.That(divergences, Is.GreaterThan(1), + "the corpus must contain more than the boundary block after the fork"); + } + + /// + /// Why the pre-existing eip1559BaseFeeMinValue chainspec parameters were NOT used. + /// They express the same clamp, but on this chainspec they activate it millions of blocks + /// too early, because ChainSpecBasedSpecProvider stamps every timestamp-activated release + /// spec with the biggest block transition. This test fails if that ever stops being true, + /// at which point the simpler configuration becomes available. + /// + [Test] + public void Chainspec_min_value_transition_would_activate_the_floor_too_early() + { + ChainSpec chainSpec = LoadLiveChainSpec(); + chainSpec.Parameters.Eip1559BaseFeeMinValueTransition = ForkBlock; + chainSpec.Parameters.Eip1559BaseFeeMinValue = FloorValue; + + ChainSpecBasedSpecProvider provider = new(chainSpec); + + // Block 10,000,000 on chain 2800, timestamp 1784238323: far below the fork block, but its + // timestamp is past the first timestamp fork (0x6a1b877b = 1780189051), so spec lookup + // takes the timestamp branch. + IReleaseSpec preForkSpec = provider.GetSpec(new ForkActivation(10_000_000, 1_784_238_323)); + + Assert.That(preForkSpec.Eip1559BaseFeeMinValue, Is.EqualTo(FloorValue), + "if this ever becomes null, the block-number transition has become safe to use here"); + } + + // --------------------------------------------------------------------------------------- + // EXCEPTIA ISTORICA: blocurile 12.978.617 - 13.087.959 de pe lantul 2800 nu respecta pragul. + // + // Producatorii au pierdut proprietatile `aere.basefee.floor` pentru ~16 ore pe 2026-08-09. + // + // UNDE STA EXCEPTIA, si prima incercare a pus-o gresit. NU in calculator. In interval taxa nu e o + // functie de parinte: unii producatori aveau pragul si altii nu, deci valoarea depinde de CARE + // validator a castigat runda. Blocul 12.978.617 e 875.000.000 (producator fara prag, o treapta de + // decadere) iar copilul lui 12.978.618 e inapoi la 1.000.000.000 (producator cu prag). Niciun + // calcul determinist nu reproduce alternanta. Prima forma a peticului sarea pragul in calculator + // si a mutat eroarea de la 12.978.617 la 12.978.618, adica a schimbat simptomul, nu cauza. + // + // Calculatorul ramane REGULA CURATA. Exceptia sta la VALIDARE, in HeaderValidator, care in acest + // interval accepta taxa din antet asa cum e. Vezi Nethermind.Core.AereFloorLapse. + // --------------------------------------------------------------------------------------- + + /// Limitele sunt exacte, inclusive, si se citesc din cod, nu dintr-un comentariu. + [Test] + public void The_lapse_bounds_are_exactly_the_measured_ones() + { + Assert.That(AereFloorLapse.ChainId, Is.EqualTo(2800UL)); + Assert.That(AereFloorLapse.FirstBlock, Is.EqualTo(12_978_617L)); + Assert.That(AereFloorLapse.LastBlock, Is.EqualTo(13_087_959L)); + Assert.That(AereFloorLapse.Covers(12_978_616L), Is.False, "blocul dinaintea intervalului"); + Assert.That(AereFloorLapse.Covers(12_978_617L), Is.True, "primul bloc al intervalului"); + Assert.That(AereFloorLapse.Covers(13_087_959L), Is.True, "ultimul bloc, inclusiv"); + Assert.That(AereFloorLapse.Covers(13_087_960L), Is.False, "blocul de dupa interval"); + } + + /// + /// CALCULATORUL RAMANE PUR: aplica pragul si INAUNTRUL intervalului. Daca aceasta proba cade, + /// inseamna ca cineva a mutat exceptia inapoi in calculator, unde nu poate functiona. + /// + [TestCase(12_978_617L, TestName = "primul bloc al intervalului")] + [TestCase(13_000_000L, TestName = "mijlocul intervalului")] + [TestCase(13_087_959L, TestName = "ultimul bloc al intervalului")] + public void The_calculator_still_applies_the_floor_inside_the_lapse(long child) + { + ChainSpecBasedSpecProvider provider = new(LoadLiveChainSpec()); + IReleaseSpec spec = provider.GetSpec(new ForkActivation(child, 1_784_311_675)); + AereBaseFeeCalculator calc = (AereBaseFeeCalculator)spec.BaseFeeCalculator; + + BlockHeader parent = new( + Keccak.Zero, Keccak.OfAnEmptySequenceRlp, Address.Zero, UInt256.One, + child - 1, 30_000_000L, 1_784_311_675UL, []) + { + GasUsed = 0, + BaseFeePerGas = FloorValue + }; + + Assert.That(AereFloorLapse.Covers(child), Is.True); + Assert.That(calc.Calculate(parent, spec), Is.EqualTo(FloorValue), + "calculatorul nu are voie sa stie de interval: acolo taxa nu e o functie de parinte"); + } +} diff --git a/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs b/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs new file mode 100644 index 0000000..bc906f2 --- /dev/null +++ b/src/Nethermind/Nethermind.Serialization.Rlp/AereQbftHeaderHashing.cs @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 AERE Network +// SPDX-License-Identifier: LGPL-3.0-only +// +// QBFT (Hyperledger Besu IBFT 2.0 / EEA QBFT) canonical block-hash rule. +// +// In QBFT the canonical block hash (the value carried as a child's parentHash and +// used for all block-tree linkage) is computed over a SEAL-STRIPPED header: the +// extraData has its committed-seals list emptied AND its round field set to 0 +// before the header RLP is keccak-hashed. The committed seals are consensus +// signatures that are appended AFTER the hash is fixed, so they are not part of +// the block identity - exactly like Clique's seal is excluded from its SealHash. +// +// This helper is self-contained (no dependency on any AERE assembly) so it can be +// called from Nethermind.Crypto.BlockHeaderExtensions without introducing a +// project cycle. It only transforms the extraData bytes; the surrounding header +// RLP is produced by Nethermind's own HeaderDecoder, so a match against the live +// chain hash is genuine cross-client (Nethermind serializer) agreement. + +using System; + +namespace Nethermind.Serialization.Rlp +{ + public static class AereQbftHeaderHashing + { + /// + /// If is a QBFT extraData (RLP list of 5: + /// [vanity(32), validators(list), vote, round(scalar), committedSeals(list)]), + /// produce the seal-stripped, round=0 variant used for the canonical block + /// hash and return true. Otherwise return false and leave a null out value. + /// + public static bool TryStripSeals(byte[]? extraData, out byte[] stripped) + { + stripped = Array.Empty(); + if (extraData is null || extraData.Length == 0) return false; + + try + { + int pos = 0; + if (!ReadListHeader(extraData, ref pos, out int payloadEnd)) return false; + + // Item 0: vanity, must be a 32-byte string. + if (!ReadItem(extraData, ref pos, out int i0Start, out int i0Len, out bool i0IsList)) return false; + if (i0IsList) return false; + // enforce 32-byte vanity (content length) + int i0ContentLen = ContentLength(extraData, i0Start, i0Len); + if (i0ContentLen != 32) return false; + + // Item 1: validators, must be a list. + if (!ReadItem(extraData, ref pos, out int i1Start, out int i1Len, out bool i1IsList)) return false; + if (!i1IsList) return false; + + // Item 2: vote (list or empty-list). Keep raw. + if (!ReadItem(extraData, ref pos, out int i2Start, out int i2Len, out bool _)) return false; + + // Item 3: round scalar (string). We drop it -> 0x80. + if (!ReadItem(extraData, ref pos, out int _, out int _, out bool i3IsList)) return false; + if (i3IsList) return false; + + // Item 4: committed seals, must be a list. We drop it -> empty list 0xc0. + 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 + + // Re-emit: [ raw(item0), raw(item1), raw(item2), 0x80 (round=0), 0xc0 (no seals) ] + int newPayloadLen = i0Len + i1Len + i2Len + 1 /*0x80*/ + 1 /*0xc0*/; + byte[] outBuf = new byte[ListHeaderLength(newPayloadLen) + newPayloadLen]; + int w = WriteListHeader(outBuf, 0, newPayloadLen); + Buffer.BlockCopy(extraData, i0Start, outBuf, w, i0Len); w += i0Len; + Buffer.BlockCopy(extraData, i1Start, outBuf, w, i1Len); w += i1Len; + Buffer.BlockCopy(extraData, i2Start, outBuf, w, i2Len); w += i2Len; + outBuf[w++] = 0x80; // round = 0 + outBuf[w++] = 0xc0; // committed seals = empty list + + stripped = outBuf; + return true; + } + catch + { + return false; + } + } + + // Reads the top-level list header; sets pos to payload start, out payloadEnd = end of payload. + private static bool ReadListHeader(byte[] d, ref int pos, out int payloadEnd) + { + payloadEnd = 0; + if (pos >= d.Length) return false; + int prefix = d[pos]; + if (prefix < 0xc0) return false; // not a list + if (prefix < 0xf8) + { + int len = prefix - 0xc0; + pos += 1; + payloadEnd = pos + len; + } + else + { + int lenOfLen = prefix - 0xf7; + pos += 1; + if (pos + lenOfLen > d.Length) return false; + int len = (int)ReadLen(d, pos, lenOfLen); + pos += lenOfLen; + payloadEnd = pos + len; + } + return payloadEnd <= d.Length; + } + + // Reads one item; returns its FULL encoding range [start,start+len) and whether it is a list. + private static bool ReadItem(byte[] d, ref int pos, out int start, out int len, out bool isList) + { + start = pos; len = 0; isList = false; + if (pos >= d.Length) return false; + int prefix = d[pos]; + if (prefix < 0x80) { len = 1; pos += 1; return true; } // single byte + if (prefix < 0xb8) { int l = prefix - 0x80; len = 1 + l; pos += len; return pos <= d.Length; } + if (prefix < 0xc0) + { + int lol = prefix - 0xb7; + int l = (int)ReadLen(d, pos + 1, lol); + len = 1 + lol + l; pos += len; return pos <= d.Length; + } + isList = true; + if (prefix < 0xf8) { int l = prefix - 0xc0; len = 1 + l; pos += len; return pos <= d.Length; } + int lol2 = prefix - 0xf7; + int l2 = (int)ReadLen(d, pos + 1, lol2); + len = 1 + lol2 + l2; pos += len; return pos <= d.Length; + } + + private static int ContentLength(byte[] d, int start, int fullLen) + { + int prefix = d[start]; + if (prefix < 0x80) return 1; + if (prefix < 0xb8) return prefix - 0x80; + if (prefix < 0xc0) { int lol = prefix - 0xb7; return (int)ReadLen(d, start + 1, lol); } + return fullLen; // lists: not needed here + } + + private static long ReadLen(byte[] d, int pos, int lenOfLen) + { + long v = 0; + for (int i = 0; i < lenOfLen; i++) v = (v << 8) | d[pos + i]; + return v; + } + + private static int ListHeaderLength(int payloadLen) => payloadLen < 56 ? 1 : 1 + MinBytes(payloadLen); + + private static int WriteListHeader(byte[] buf, int at, int payloadLen) + { + if (payloadLen < 56) { buf[at] = (byte)(0xc0 + payloadLen); return at + 1; } + int nb = MinBytes(payloadLen); + buf[at] = (byte)(0xf7 + nb); + for (int i = 0; i < nb; i++) buf[at + 1 + i] = (byte)(payloadLen >> (8 * (nb - 1 - i))); + return at + 1 + nb; + } + + private static int MinBytes(int v) + { + int n = 0; + while (v > 0) { n++; v >>= 8; } + return n == 0 ? 1 : n; + } + } +}