// 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 { /// The opening of the refusal when element 6 is not a certificate the first client's codec decodes (AERE-D489). The anchor /// rule names such a refusal AERE-PQ-V3-03 at every anchor height (pas 11, C-03 / part 1, A.1(b)), as the first client's codec /// calls its own refusal of the slot "the AERE-PQ-V3-03 verdict" (QbftExtraDataCodec, the version dispatcher); one text, read in /// one place (PqAnchorRule.Element6Code). public const string CertificateSlotRefusalText = "QBFT extraData: element 6 is not a certificate the first client's codec decodes"; public byte[] Vanity { get; } public IReadOnlyList Validators { get; } public IReadOnlyList CommittedSeals { get; } /// /// AERE: the SIXTH element, verbatim, when the header carries one - the post-quantum anchor /// certificate (PqAnchorV2). Null on a five-element header. Kept as raw encoded bytes because /// the anchor digest is taken over exactly these bytes: re-encoding from parsed parts could /// silently change what the digest covers. Until 2026-08-28 this element was parsed and /// DROPPED, so the second client could tolerate a certificate but never check one. /// public byte[]? CertificateRaw { get; } public byte[] RoundBytes { get; } // minimal big-endian; empty == round 0 /// AERE AIP-22 stage 5: the proposer's post-quantum seal (the SEVENTH element), or null below H_hdr. public int? ProposerSealIndex { get; } public byte[]? ProposerSealSignature { get; } public long Round { get; } private readonly RlpItem _vanityItem; private readonly RlpItem _validatorsItem; private readonly RlpItem _voteItem; /// /// AERE D-403: the validator vote this header carries, as Besu encodes it (a two-item list: the 20-byte recipient and 0xFF to /// add / 0x00 to drop), or null for the empty vote list. A vote element of any other shape is null here, so it never relaxes /// the validator-list rule (). /// public byte[]? VoteRecipient => _voteItem is RlpList { Items.Count: 2 } l && l.Items[0] is RlpBytes { Value.Length: 20 } r && l.Items[1] is RlpBytes ? r.Value : null; private QbftExtraData(RlpItem vanity, RlpItem validators, RlpItem vote, byte[] roundBytes, IReadOnlyList seals, IReadOnlyList validatorAddrs, 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(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(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); } /// /// Re-encode the extraData with the committed seals removed and the round set /// to . Besu's on-chain block hash uses round=0 /// (empty); the committed-seal signing hash uses the header's actual round. /// public byte[] EncodeWithoutSeals(byte[] roundBytes) { var list = new RlpList() .Add(_vanityItem) .Add(_validatorsItem) .Add(_voteItem) .Add(new RlpBytes(roundBytes)) .Add(new RlpList()); return list.Encode(); } private static long ToLong(byte[] be) { long v = 0; foreach (byte b in be) v = (v << 8) | b; return v; } // ---------------------------------------------------------------- AERE-D489: the first client's codec, transcribed // // Everything below is QbftExtraDataCodec.decodeRaw of the Besu-derived client (consensus/qbft/.../QbftExtraDataCodec.java, lines // 134-314 and decodeVote 408-426), read from the CODE and not from a description of it. That codec refuses in two ways: a read // that throws, and a canonical round trip at the end (it re-encodes what it decoded and demands the bytes it was given). The // accepted set is therefore exactly the image of its encoder, and each function here names one part of that image. Nothing in // this block may be STRICTER than that codec: a header it admits and this client refuses is a block the tenth validator cannot // follow. The two anchor decoders this client already had are stricter than the codec on purpose (PqAnchor.DecodeCertificate // wants sorted indices, PqAnchorV2.Decode refuses an empty signature): they are the anchor RULE's reading and are not used here. /// /// The scalar the first client reads with readIntScalar and writes back with writeIntScalar: at most four bytes, no leading zero /// byte, and below 2^31 (a negative int is written back as eight bytes, so it never round-trips). /// private static bool IsIntScalarOfFirstClient(byte[] v) => v.Length <= 4 && (v.Length == 0 || v[0] != 0) && !(v.Length == 4 && v[0] >= 0x80); /// decodeVote and the round trip: the empty list, [recipient, 0xff] or [recipient, the empty string]. Null = the vote /// is one of the three. private static string? VoteFormRefusal(RlpItem vote) { if (vote is not RlpList list) return "it is a byte string, not a list"; if (list.Items.Count == 0) return null; if (list.Items.Count != 2) return $"it carries {list.Items.Count} element(s)"; if (list.Items[0] is not RlpBytes recipient) return "the recipient is a list"; if (recipient.Value.Length != 20) return $"the recipient is {recipient.Value.Length} bytes long"; if (list.Items[1] is not RlpBytes type) return "the vote type is a list"; if (type.Value.Length == 0) return null; // drop: the empty string, as the encoder writes it if (type.Value.Length == 1 && type.Value[0] == 0xFF) return null; // add return type.Value.Length == 1 && type.Value[0] == 0x00 ? "the drop is written 0x00; the first client writes the empty string and refuses the other form" : $"the vote type is 0x{Convert.ToHexString(type.Value).ToLowerInvariant()}, neither 0xff (add) nor empty (drop)"; } /// /// The dispatcher on element 6: the empty list (legal only by position, judged by the caller), the v1 list of [index, signature], /// the scalar 2 and a canonical non-empty v2 row list, the scalar 3 and a canonical v3; any other version refused by name. Null = /// the first client's codec decodes these bytes. /// private static string? CertificateSlotRefusal(RlpItem slot) { if (slot is not RlpList cert) return "it is a byte string, not a list"; if (cert.Items.Count == 0) return null; if (cert.Items[0] is RlpList) { // v1, the Falcon list, as that codec reads it: [index, signature] rows, the index a signed-int scalar, the signature any // byte string. Order and repetition are the anchor rule's business, at an anchor height. try { _ = Nethermind.AerePqc.Consensus.Finality.AereCertifiedDecision.ReadV1AsWritten(cert.Encode()); } catch (FormatException e) { return "the v1 list does not decode: " + e.Message; } return null; } byte[] versionBytes = ((RlpBytes)cert.Items[0]).Value; if (!IsIntScalarOfFirstClient(versionBytes)) return "it opens with an unreadable version scalar (more than four bytes, a leading zero, or 2^31 and above)"; long version = ToLong(versionBytes); if (version == Nethermind.AerePqc.Consensus.Anchor.PqAnchorV2.Version) return V2SlotRefusal(cert); if (version == Nethermind.AerePqc.Consensus.Anchor.PqAnchorV3.Version) { try { _ = Nethermind.AerePqc.Consensus.Anchor.PqAnchorV3.Decode(cert.Encode()); } catch (FormatException e) { return "it does not decode as a canonical v3 certificate: " + e.Message; } return null; } return $"{Nethermind.AerePqc.Consensus.Anchor.PqAnchorV3.V3_03}: it names version {version}; this build understands the v1 list, 2 and 3"; } /// /// A v2 certificate as the first client's codec admits it (PqAnchorV2.decode there, then the codec's round trip): exactly /// [2, rows], 1..64 rows of exactly [scheme, index, signature], scheme a known wire tag, index a signed-int scalar, rows strictly /// increasing by (index, scheme), the signature ANY byte string. An empty row list is refused because that encoder never writes /// one (no certificate is no element 6). The empty SIGNATURE is not refused here: that codec admits it, and at an anchor height /// the rule refuses it in both clients. /// private static string? V2SlotRefusal(RlpList cert) { if (cert.Items.Count != 2) return $"a v2 certificate is [2, rows], this one carries {cert.Items.Count} elements"; if (cert.Items[1] is not RlpList rows) return "the rows of the v2 certificate are a byte string, not a list"; if (rows.Items.Count == 0) return "a v2 certificate without rows: the first client never writes one, so it does not round-trip"; if (rows.Items.Count > Nethermind.AerePqc.Consensus.Anchor.PqAnchorV2.MaxSeals) return $"the v2 certificate carries {rows.Items.Count} rows, above {Nethermind.AerePqc.Consensus.Anchor.PqAnchorV2.MaxSeals}"; long previousIndex = -1, previousScheme = -1; foreach (RlpItem it in rows.Items) { if (it is not RlpList row || row.Items.Count != 3 || row.Items[0] is not RlpBytes schemeB || row.Items[1] is not RlpBytes indexB || row.Items[2] is not RlpBytes) return "a v2 row is not [scheme, index, signature]"; if (!IsIntScalarOfFirstClient(schemeB.Value) || !IsIntScalarOfFirstClient(indexB.Value)) return "a v2 row carries a scheme or an index that is not a canonical scalar below 2^31"; long scheme = ToLong(schemeB.Value), index = ToLong(indexB.Value); if (scheme > 0xff || Nethermind.AerePqc.Consensus.Anchor.SchemeSeal.SchemeIdOf((byte)scheme) is null) return $"a v2 row names the unknown scheme tag 0x{scheme:x}"; if (!(index > previousIndex || (index == previousIndex && scheme > previousScheme))) return "the v2 rows are not strictly increasing by (index, scheme)"; previousIndex = index; previousScheme = scheme; } return null; } }