aere-client2/src/Nethermind/Nethermind.AerePqc/Qbft/QbftExtraData.cs

313 lines
21 KiB
C#

// 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
{
/// <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, byte[]? certificateRaw = null,
int? proposerSealIndex = null, byte[]? proposerSealSignature = null)
{
_vanityItem = vanity;
_validatorsItem = validators;
_voteItem = vote;
Vanity = ((RlpBytes)vanity).Value;
Validators = validatorAddrs;
CommittedSeals = seals;
CertificateRaw = certificateRaw;
ProposerSealIndex = proposerSealIndex;
ProposerSealSignature = proposerSealSignature;
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.
// 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)
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<byte[]>(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];
// 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");
var seals = new List<byte[]>(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");
// 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);
}
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>
/// Re-encode the extraData with the committed seals removed and the round set
/// to <paramref name="roundBytes"/>. Besu's on-chain block hash uses round=0
/// (empty); the committed-seal signing hash uses the header's actual round.
/// </summary>
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;
}
// ---------------------------------------------------------------- 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;
}
}