Skip to content

test: add hand-derived golden tests for eth/71 and snap serializers - #12699

Open
benaadams wants to merge 3 commits into
masterfrom
test-hygiene/snap-and-eth68-69-serializer-goldens
Open

test: add hand-derived golden tests for eth/71 and snap serializers#12699
benaadams wants to merge 3 commits into
masterfrom
test-hygiene/snap-and-eth68-69-serializer-goldens

Conversation

@benaadams

@benaadams benaadams commented Aug 5, 2026

Copy link
Copy Markdown
Member

Changes

How to review this fast: three commits - the golden tests, a user-requested constants hoist, then review-feedback fixes; 6 files, all under Nethermind.Network.Test (verified: no non-test file in the diff); +113/-39.

Rule for this chunk: every serializer test file in Eth V68/V69/V71 and Snap/V1 asserts an exact wire encoding somewhere. These encodings are consensus-observable, so no expected value was captured from Nethermind's own output - each was hand-derived from the RLP rules and independently verified with pyrlp (the ethereum reference RLP implementation) plus pycryptodome keccak.

Most files in these directories already had golden tests (V68, V69 Status/BlockRangeUpdate/Receipts with Geth-sourced vectors, Snap AccountRange/StorageRanges/TrieNodes/GetTrieNodes). The gaps:

  • V71 BlockAccessListsMessageSerializerTests: the two roundtrip cases that passed a null expectation now pin c22ac0 (empty) and ca88ff...c0 (a negative request id encodes as its unsigned two's-complement value); the now-unreachable null guard is gone and the expectation parameter is non-nullable. The GetBlockAccessLists fixture had no exact-encoding assert at all; its three roundtrip cases now carry hand-derived expected values.
  • Snap GetAccountRangeMessageSerializerTests: both tests pinned. Roundtrip (request id 1111) pins the flat [requestId, rootHash, startingHash, limitHash, responseBytes] layout. Roundtrip_Defaults pins RequestId = long.MaxValue - keeping the eight-byte-id encode path deterministically covered - and wire-pins the serializer's normalizations: a null limit hash goes out as Keccak.MaxValue and response bytes 0 as 1,000,000.
  • Snap GetByteCodes / GetStorageRanges: the empty-variant roundtrips pin their layouts; expected values are split at field boundaries so a reviewer can map segment to field.
  • Snap ByteCodesMessageSerializerTests: the roundtrip is parameterized over two pinned ids - 1 (ca01c884deadc0de82feed) and long.MaxValue (d288...), which covers the eight-byte-id path deterministically. The constructor assigns a random id by default - a first golden attempt caught that live.
  • Random request ids (MessageConstants.Random.NextLong()) are pinned only in tests that assert exact bytes; the other roundtrips keep them.
  • SnapSerializerGoldens (second commit): the repeated fragments - request id 1111, keccak("") item, range start/limit items - live once; each golden fragment and its Hash256 test input are built from the same hex constant, so the expectation cannot drift from the input.

Derivations (each re-derived byte-for-byte by two independent reviews): request ids per the RLP integer rules (including -1 -> eight 0xff bytes and long.MaxValue -> 887fffffffffffffff); hash items as 0xa0 + 32 bytes with keccak("") = c5d246... (cross-checked against the existing V62 GetBlockHeaders golden test), keccak("A")/("B") matching the values pinned in #12696; framing lengths verified by frame arithmetic and pyrlp.

Deliberately untouched: V68 and the anchored V69/Snap files above; the V71 keccak hex duplicates a constant #12696 adds (EthSerializerGoldens) - folding it in is a post-merge follow-up since that branch is not an ancestor. V69 ReceiptMessageDecoder69Tests does not pin the eth/69 bloom-absence on the wire; it is a decoder test outside this rule's wording and is tracked for a later chunk.

Part of the test-hygiene series (#12689, #12690, #12693, #12694, #12696).

Types of changes

What types of changes does your code introduce?

  • Refactoring

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

  • Mutation-checked, 5/5 detected (each deliberate break made the golden tests fail, then was reverted): requestId ^ 1 in the shared eth/66 serializer base fails 15 V71 tests including all five new expected values; swapping starting/limit hash in GetAccountRange fails both of its golden tests; per-serializer request-id mutations fail each snap golden test (ByteCodes fails all three tests including the pre-existing fixed vector). The constants hoist landed after the mutation pass; the reassembled values were machine-verified byte-identical, and all fixtures re-ran green.
  • Full local suite green (windows-x64, release): Nethermind.Network.Test 1289 total, 0 failed, 8 skipped (pre-existing discovery/rlpx skips, none in touched files). Subprotocol fixtures (546 tests) repeated 3x with no flakes.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • No

Every serializer test file in Eth V71 and Snap/V1 now asserts an exact
wire encoding. The goldens are hand-derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak).
Random request ids are pinned only in the goldened tests; ByteCodes
gains a sibling roundtrip that keeps the constructor-assigned id, and
GetAccountRange pins long.MaxValue to keep the eight-byte id path.
The request-id, keccak-of-empty-string, and range-hash fragments moved
into SnapSerializerGoldens. Each golden fragment and its test input
come from one hex constant, so the expectation cannot drift from the
input.
Copilot AI review requested due to automatic review settings August 5, 2026 13:26
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 6m 24s —— View job


Review: golden wire-encoding tests for eth/71 and snap

  • Read the PR diff
  • Independently re-derive all 11 new expected hex strings from the RLP rules
  • Cross-check serializer field order and normalizations against the assertions
  • Verify TestItem.KeccakA/B and Keccak.OfAnEmptyString against other repo usages
  • Check repo rules (test hygiene, DRY, comment policy)

Verdict: correct as far as I can verify by hand. No Critical/High/Medium findings — three Low nits, all posted inline.


Verification I actually did

I re-derived every new expectation from the RLP rules rather than trusting the PR body, then confirmed each serializer writes the fields in the order the golden asserts. All 11 check out:

Golden Payload arithmetic
c22ac0 2a + c0 = 2 → c2
ca88ffffffffffffffffc0 -1unchecked((ulong))88+8B = 9, +1 = 10 → ca
c263c0 63 + c0 = 2
e364e1a000… 1×33 → e1; 1 + 34 = 35 → e3
f86665f863a0… 3×33 = 99 → f863; 1 + 101 = 102 → f866
f867 + id + 3 hashes + 0a 3 + 99 + 1 = 103 = 0x67
f870887fffffffffffffff…830f4240 9 + 99 + 4 = 112 = 0x70
c5 820457 c0 0a 3 + 1 + 1 = 5
f86a8203e8 3+33+1+33+33+3 = 106 = 0x6a
ca01c884deadc0de82feed c8 = 5+3; 1 + 9 = 10
820457 1111 = 0x0457

Specific things I chased down rather than assumed:

  • Negative request id. Rlp.LengthOf(long) is LengthOf(unchecked((ulong)value)) (Rlp.cs:693), so -11 + 8 - 0 = 9 bytes. 88 ff×8 is right, and the comment explaining it is the kind that earns its place.
  • Field order and normalizations. GetAccountRangeMessageSerializer.Serialize writes requestId, rootHash, startingHash, LimitHash ?? Keccak.MaxValue, ResponseBytes == 0 ? 1000_000 : ResponseBytes — the Roundtrip_Defaults golden pins both normalizations exactly, and they're idempotent so TestZero's re-serialize comparison still holds. GetStorageRangesMessageSerializer writes the account paths as a nested sequence (c0 when empty), matching the golden. long.MaxValueLZC(0x7fff…)/8 = 088 7fffffffffffffff.
  • Hash literals. Keccak.OfAnEmptyString = c5d24601… (keccak of empty input). TestItem.KeccakA/KeccakB are Keccak.Compute("A")/("B"); the two 32-byte literals here match the same values used independently in Nethermind.JsonRpc.Test/Modules/SubscribeModuleTests.cs:457,555, which is a real cross-check rather than a self-consistency one.
  • Hex casing/prefix. ReadAllHexToHexString(), i.e. lowercase and unprefixed — matches every expectation. Hash256(string) goes through Bytes.FromHexString, which tolerates the dropped 0x in SnapSerializerGoldens.
  • No coverage lost to the de-randomization. MessageConstants.Random.NextLong() still drives Roundtrip_Many in both GetByteCodes and GetStorageRanges, so the multi-byte-id path stays exercised there; in GetAccountRange both tests became fixed but one is long.MaxValue, which covers it deterministically. Net improvement.
  • Double dispose. GetBlockAccessLists.Roundtrip has both a using on msg and SerializerTester.TestZero's message.TryDispose(). Pre-existing, and safe — ArrayPoolList.Dispose is guarded by _disposed, so the pooled array isn't returned twice. Not a finding, just ruling it out.
  • Production code untouched; the only import removed (Nethermind.Network.P2P in GetAccountRange) has no remaining use in that file.

Findings (all Low, inline)

  1. SnapSerializerGoldens.cs — the <remarks> drift-proofing claim doesn't hold for EmptyStringKeccakRlp (its test input is Keccak.OfAnEmptyString, not the private hex), and RequestId1111Rlp is the one fragment whose input value is hardcoded in three consuming tests instead of living here.
  2. ByteCodesMessageSerializerTests.cs — the new Roundtrip_random_request_id adds a non-deterministic test in a de-randomizing PR, duplicates Roundtrip's setup verbatim (against the AGENTS.md [TestCase] rule), and largely overlaps DecodeEncodeDecodeEmpty, which already pins an 8-byte id. Suggested a two-case [TestCase] collapse.
  3. BlockAccessListsMessageSerializerTests.cs:149// The hashes are Keccak.Zero, keccak("A"), and keccak("B"). restates the code below it. The other two comments added in this file do explain derivations and should stay.

The deliberate omissions in the PR body (V68, the anchored V69/snap files, the EthSerializerGoldens fold-in blocked on #12696 not being an ancestor, ReceiptMessageDecoder69Tests) are all reasonable and clearly scoped.

Caveat on my verification

I could not compile or execute the fixtures — dotnet build and dotnet test both require approval in this sandbox, so nothing here is machine-confirmed. Everything above is hand re-derivation plus source reading; treat CI, not this comment, as the pass/fail signal. If you want me to run the fixtures, add Bash(dotnet build:*) and Bash(dotnet test:*) to --allowedTools.
· branch test-hygiene/snap-and-eth68-69-serializer-goldens

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds hand-derived “golden” wire-encoding assertions for Eth/71 and Snap/V1 message serializers in Nethermind.Network.Test, and centralizes repeated Snap golden fragments/inputs to keep test vectors deterministic and reviewable.

Changes:

  • Added SnapSerializerGoldens to share Snap/V1 RLP fragments and matching Hash256 inputs.
  • Pinned exact RLP encodings for Snap/V1 serializer roundtrips (GetAccountRange/GetByteCodes/GetStorageRanges/ByteCodes).
  • Strengthened Eth/V71 BlockAccessLists / GetBlockAccessLists serializer tests by making expected encodings mandatory and adding concrete golden vectors.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Snap/V1/Messages/SnapSerializerGoldens.cs New shared constants for Snap/V1 golden fragments + matching hash inputs.
src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Snap/V1/Messages/GetStorageRangesMessageSerializerTests.cs Hoists common hashes to goldens and pins empty-case wire encoding.
src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Snap/V1/Messages/GetByteCodesMessageSerializerTests.cs Pins empty-case wire encoding and fixes request id determinism for that case.
src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Snap/V1/Messages/GetAccountRangeMessageSerializerTests.cs Pins wire encodings for both explicit and default-normalization roundtrips.
src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Snap/V1/Messages/ByteCodesMessageSerializerTests.cs Adds a pinned golden and an additional roundtrip to cover constructor-assigned request ids.
src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Eth/V71/BlockAccessListsMessageSerializerTests.cs Removes nullable-expected guard and adds concrete goldens for previously unpinned cases.
Suppressed comments (1)

src/Nethermind/Nethermind.Network.Test/P2P/Subprotocols/Snap/V1/Messages/ByteCodesMessageSerializerTests.cs:32

  • ByteCodesMessage implements Dispose() (and disposes the pooled Codes list); this test currently never disposes the message, which can leak ArrayPoolList rentals (and triggers DEBUG finalizer warnings).
            ByteCodesMessage message = new(new ByteArrayListAdapter(data));

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

{
ArrayPoolList<byte[]> data = new(2) { new byte[] { 0xde, 0xad, 0xc0, 0xde }, new byte[] { 0xfe, 0xed } };

ByteCodesMessage message = new(new ByteArrayListAdapter(data)) { RequestId = 1 };
Comment on lines 27 to 31
@@ -32,15 +31,22 @@ public void Roundtrip()
Assert.That(deserializedMsg.AccountRange.LimitHash, Is.EqualTo(msg.AccountRange.LimitHash));
Comment on lines +10 to +22
/// </summary>
/// <remarks>
/// Each golden fragment and its input come from one hex constant, so the
/// expectation cannot drift from the input. The values are verified with an
/// independent encoder (pyrlp + pycryptodome keccak).
/// </remarks>
internal static class SnapSerializerGoldens
{
private const string EmptyStringKeccakHex = "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470";
private const string RangeStartHex = "15d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470";
private const string RangeLimitHex = "20d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470";

/// <summary>Request id 1111 as an RLP item: 0x82 length prefix + 0x0457.</summary>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — two nits on the drift-proofing story in this file.

  1. The <remarks> says "Each golden fragment and its input come from one hex constant, so the expectation cannot drift from the input." That holds for RangeStart/RangeLimit, but not for EmptyStringKeccakRlp: its input in every consuming test is Keccak.OfAnEmptyString, not a Hash256 built from EmptyStringKeccakHex. That's arguably the better arrangement (the literal independently pins keccak(""), which the shared-constant fragments deliberately don't), but the doc comment currently claims a property that fragment doesn't have. Worth narrowing the sentence to the range hashes and saying explicitly that the keccak("") literal is independent of the input on purpose.

  2. RequestId1111Rlp is the one fragment whose input isn't co-located: each of the three consuming tests writes RequestId = 1111 as a bare literal while the encoding lives here. Exposing the value alongside the fragment would make it match the pattern the rest of the file establishes:

    /// <summary>The request id the range/bytecode request tests use.</summary>
    public const long RequestId1111 = 1111;

    /// <summary>Request id 1111 as an RLP item: 0x82 length prefix + 0x0457.</summary>
    public const string RequestId1111Rlp = "820457";

Neither is a correctness problem — the values themselves all check out.

Fix this →

}

[Test]
public void Roundtrip_random_request_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — this new fixture member is the one place the PR moves against its own thesis: it adds a fresh non-deterministic test (MessageConstants.Random-assigned id via the constructor) while the rest of the diff is removing random ids. Its marginal coverage over what's already here looks thin — DecodeEncodeDecodeEmpty further down already pins the 8-byte-request-id path with exact bytes (202, 136, ... = ca 88 176a156ae55348b0 c0), so the only thing this adds is "an arbitrary id survives a roundtrip".

It also duplicates the three setup lines of Roundtrip verbatim, which AGENTS.md calls out directly ("When tests differ only by inputs and expected outputs, parameterize a single test with [TestCase(...)]"). Both cases differ only by id and expectation:

        [TestCase(1L, "ca01c884deadc0de82feed")]
        [TestCase(long.MaxValue, "d2887fffffffffffffffc884deadc0de82feed")]
        public void Roundtrip(long requestId, string expectedData)
        {
            ArrayPoolList<byte[]> data = new(2) { new byte[] { 0xde, 0xad, 0xc0, 0xde }, new byte[] { 0xfe, 0xed } };

            ByteCodesMessage message = new(new ByteArrayListAdapter(data)) { RequestId = requestId };

            ByteCodesMessageSerializer serializer = new();

            // The message encodes as [requestId, codes].
            SerializerTester.TestZero(serializer, message, expectedData);
        }

That keeps the multi-byte-id path covered and deterministic. (Second vector derived the same way: 9 + 9 = 18 = 0x12 payload → 0xd2; please re-verify before taking it.) Sibling fixtures also spell case names Roundtrip_Empty / Roundtrip_Many, so Roundtrip_random_request_id is off-convention if it stays.

Fix this →

})),
"e364e1a00000000000000000000000000000000000000000000000000000000000000000")
.SetName("Roundtrip_single_hash");
// The hashes are Keccak.Zero, keccak("A"), and keccak("B").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — this comment restates the three lines directly below it (Keccak.Zero, TestItem.KeccakA, TestItem.KeccakB), which AGENTS.md asks you to skip ("Comments that merely restate the code are noise"). The other two comments added in this file earn their keep — // Each hash encodes as 0xa0 + 32 bytes. and // A negative request id encodes as its unsigned two's-complement value. both explain a derivation a reader can't get from the code. This one doesn't.

If the intent was to make the two 32-byte literals traceable to their inputs, the useful version says that instead — e.g. // keccak("A") and keccak("B"); the same values #12696 pins in EthSerializerGoldens. — otherwise dropping the line is cleaner.

Fix this →

- ByteCodes: one parameterized Roundtrip; long.MaxValue pins the
  eight-byte request id deterministically instead of a random id
- GetAccountRange: independent field asserts in EnterMultipleScope
- SnapSerializerGoldens: expose RequestId1111 next to its RLP item;
  the remarks state which fragments share hex with inputs and that
  the keccak("") fragment is an independent literal on purpose
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants