313 lines
21 KiB
C#
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;
|
|
}
|
|
}
|