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