test: anchor crypto and RLP tests to independent expectations - #12712
test: anchor crypto and RLP tests to independent expectations#12712benaadams wants to merge 10 commits into
Conversation
…d indexed asserts
|
Claude finished @benaadams's task in 7m 17s —— View job Review: test hygiene for crypto and RLP tests
No product code is touched; test-only. The direction is right and the mutation-testing notes in the description are the kind of evidence that makes a "strengthen the asserts" PR reviewable. Three things below; one is worth acting on. Verification I could actually do
Field order also matches
The keccak vector I could not re-derive, but a wrong constant fails both asserts in CI, so it can't land silently. FindingsMedium — Low — Low — uncles and withdrawals are compared by count only. The scenarios build two uncles that differ deliberately (alternating Low (nits). Things that are right
Marking not-mergeable only on the Medium field-coverage point — it's a completeness gap in a test-hardening PR, not a product-correctness risk. If you'd rather scope the header tail to a later chunk in the series, say so and it's settled. |
There was a problem hiding this comment.
Pull request overview
Strengthens Nethermind.Core.Test coverage by replacing self-referential roundtrip/cross-overload assertions with independently derived expectations, so these tests can fail when the product code is wrong in a coordinated way.
Changes:
- Anchor
KeccakTests.Spanto an independent keccak-256 vector for a deterministic 1024-byte pattern. - Replace
Rlp.LengthOfcross-overload equality with a spec-derived expected length helper (Yellow Paper appendix B rule shape). - Strengthen
BlockDecoderTestsby adding decoded-vs-original field assertions for scenario roundtrips and spec-anchored assertions for a fixed canonical-wire regression case; remove a permanently ignored debug utility test.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Nethermind/Nethermind.Core.Test/RlpTests.cs | Replaces overload-vs-overload equality with a spec-anchored expected-length helper and asserts both overloads against it. |
| src/Nethermind/Nethermind.Core.Test/KeccakTests.cs | Anchors Span and array overload outputs to an independent keccak-256 expected hash. |
| src/Nethermind/Nethermind.Core.Test/Encoding/BlockDecoderTests.cs | Adds decoded-field assertions to scenario roundtrips, adds independent-value assertions for canonical-wire regression decoding, and deletes an ignored debug-only test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // A re-encode comparison alone cannot find a symmetric encode/decode error. The hash values also derive | ||
| // from the encoded bytes. Only direct field comparisons can find such an error. |
| Assert.That(actual.WithdrawalsRoot, Is.EqualTo(expected.WithdrawalsRoot)); | ||
| Assert.That(actual.BlobGasUsed, Is.EqualTo(expected.BlobGasUsed)); | ||
| Assert.That(actual.ExcessBlobGas, Is.EqualTo(expected.ExcessBlobGas)); | ||
| Assert.That(actual.MixHash, Is.EqualTo(expected.MixHash)); |
There was a problem hiding this comment.
Medium — the field list stops short of the newest header fields.
HeaderDecoder round-trips 23 header items; this list covers 18. Missing: Bloom, ParentBeaconBlockRoot, RequestsHash, BlockAccessListHash, SlotNumber (see HeaderDecoder.cs:52 and :60-63). Since a symmetric encode/decode error is invisible to the re-encode comparison, those five fields keep exactly the blindness this PR is removing — and they're the newest, i.e. the likeliest to grow a bug.
Bloom is set in every scenario (BlockHeaderBuilder.cs:34 → Bloom.Empty) and Bloom implements IEquatable<Bloom>, so it's a free one-liner. The four trailing optional fields are never set by any scenario in BuildScenarios(), so they're vacuous until a scenario sets them — BlockBuilder already has WithParentBeaconBlockRoot/WithRequestsHash/WithBlockAccessListHash, so one extra scenario would close the whole tail.
| Assert.That(actual.MixHash, Is.EqualTo(expected.MixHash)); | |
| Assert.That(actual.MixHash, Is.EqualTo(expected.MixHash)); | |
| Assert.That(actual.Bloom, Is.EqualTo(expected.Bloom)); | |
| Assert.That(actual.ParentBeaconBlockRoot, Is.EqualTo(expected.ParentBeaconBlockRoot)); | |
| Assert.That(actual.RequestsHash, Is.EqualTo(expected.RequestsHash)); | |
| Assert.That(actual.BlockAccessListHash, Is.EqualTo(expected.BlockAccessListHash)); | |
| Assert.That(actual.SlotNumber, Is.EqualTo(expected.SlotNumber)); |
| using (Assert.EnterMultipleScope()) | ||
| { | ||
| Assert.That(encoded2.Bytes.ToHexString(), Is.EqualTo(encoded.Bytes.ToHexString())); | ||
| Assert.That(decoded.Hash, Is.EqualTo(block.Hash)); |
There was a problem hiding this comment.
Low — this assert can't fail, and it reads like it can.
HeaderDecoder.cs:54 sets Hash = Keccak.Compute(headerRlp) from the raw incoming span, and the builder sets the original's Hash via CalculateHash() over the same encoding (BlockHeaderBuilder.cs:19). So both sides hash the same bytes: no decode defect — symmetric or asymmetric — can move this assert. Its only real content is that the header bytes embedded by BlockDecoder match what the standalone header encoder produces.
The comment two lines up gestures at this ("The hash values also derive from the encoded bytes"), but the assert is then placed first, where a reader will take it as the strongest check in the block. Either drop it or say explicitly that it cross-checks the block-embedded header encoding against the standalone one — otherwise it's the same self-consistency pattern the PR is removing elsewhere.
The same caveat applies to the transaction loop below: BaseTxDecoder.cs:68 sets Hash from the tx wire bytes, so comparing hashes catches ordering and asymmetric errors (good — that's real coverage) but not a symmetric field swap inside TxDecoder.
|
|
||
| Assert.That(Keccak.Compute(byteArray.AsSpan()), Is.EqualTo(Keccak.Compute(byteArray))); | ||
| // An independent keccak-256 implementation (pycryptodome) produced the expected hash. | ||
| Hash256 expected = new("0x5902e53903be0d0f9656bdbd5b9f0d8c2d815f865645d629eef77f5185f6cd7f"); |
There was a problem hiding this comment.
Low (nit) — the test name Span described the old assertion (span overload vs array overload); now that both overloads assert a fixed vector, something like Computes_known_hash_for_span_and_array would say what it checks. Optional, and it does grow the diff.
I couldn't re-derive 0x5902e5… in this environment (no Python/dotnet execution available here), but a wrong constant fails both asserts in CI rather than passing silently, so the risk of a bad vector landing is nil.
|
@claude re-review |
|
Claude finished @benaadams's task in 4m 53s —— View job Re-review: test hygiene for crypto and RLP tests
Two commits landed since my last pass ( Prior findings, re-checked
The new scenario is genuinely discriminatingThe risk with "add a scenario that sets the tail fields" is that the values collide and a field swap still passes. They don't — every tail field gets a distinct value, so any pairwise swap between same-typed fields is caught:
Setting all eight tail fields also drives Re-verified from the previous round
Remaining Low notes
Things that are right
Mergeable. The one Medium from the prior round is closed with a fix I could verify is discriminating rather than nominal; everything left is Low and explicitly scoped to later chunks in the series. |
| Assert.That(decoded.Uncles.Length, Is.EqualTo(block.Uncles.Length)); | ||
| Assert.That(decoded.Withdrawals?.Length, Is.EqualTo(block.Withdrawals?.Length)); |
There was a problem hiding this comment.
Low (carry-over, optional) — the body is still compared by count only, one level below the now-complete header comparison.
Uncles are the cheap half and they're already discriminating: BuildScenarios() builds uncles[0] with WithdrawalsRoot == null and uncles[1] with Keccak.Compute("1") (line 39), so decoded.Uncles[i].Hash vs block.Uncles[i].Hash would catch an order or content error the count assert can't see. If you add that loop, the length guard has to move outside EnterMultipleScope() — the way the transaction guard sits at line 180 — otherwise a count mismatch throws IndexOutOfRangeException inside the scope instead of reporting.
Withdrawals are weaker: WithWithdrawals(8) fills the array with eight default new Withdrawal() instances, so a content comparison would be vacuous until a scenario uses distinct ones (TestItem.WithdrawalA_1Eth…WithdrawalF_6Eth exist).
Fine to leave for a later chunk in the series — this PR's stated scope is the header and the crypto/RLP anchors.
The body compared uncles by count only. The scenarios build two uncles with distinct headers, so the hash comparison catches an order or content error the count cannot see. The count guard moves outside the multiple-assert scope like the transaction guard.
Changes
Part of the test-hygiene series (#12689, #12690 - merged, #12693, #12694, #12696 - merged, #12699, #12705, #12710, #12711): tests that cannot meaningfully fail get real expectations. Rule for this chunk: an expectation must not come from the code under test. Three files in
Nethermind.Core.Test; no product code changed (verified).KeccakTests.Span: compared the Span overload against the array overload of the same function - both could be wrong identically. Both overloads now assert an independent vector: keccak-256 of the 1024-bytei % 256pattern, derived with pycryptodome.RlpTests:Length_of_ulong_same_as_uint256compared twoRlp.LengthOfoverloads against each other over a pow2-boundary sweep - a shared wrong boundary passes. RenamedLength_of_ulong_matches_spec: both overloads now assert a spec-derived expected (Yellow Paper appendix B: one byte below0x80, else one prefix byte plus the minimal big-endian bytes), keeping the sweep and the cross-overload agreement transitively.BlockDecoderTests:Can_do_roundtrip_scenarios(encode-decode-encode) never looked at the original block; it now also compares the decoded block to the original: block hash, fourteen header fields, the transaction-hash sequence, and uncle/withdrawal counts.Can_do_roundtrip_regressionis the only test that decodes canonical fixed wire, which makes it the only place a self-canceling encode/decode error is observable. It now asserts field values derived from the fixed hex with an independent RLP decoder (pyrlp): number, gas limit, gas used, timestamp, state root, the single transaction's nonce and value, and the empty uncle list.[Ignore]dWrite_rlp_of_blocks_to_fileis deleted: a permanently ignored, assert-free hive-debugging utility (resurrectable from git history).Types of changes
What types of changes does your code introduce?
Testing
Requires testing
If yes, did you write tests?
Notes on testing
Every strengthened assertion was mutation-checked (deliberate product break, confirm red, revert). The two headline mutations target exactly the blindness this chunk removes:
Rlp.LengthOfoverloads (< 128to< 127in theulongandUInt256paths) keeps the overloads agreeing - the old cross-overload test stays logically satisfied - but fails the spec anchor at exactly value 127.GasLimit/GasUsedswap inHeaderDecoder(decode and encode) passes every byte-roundtrip, hash, and scenario-field assert in the file - the decoded object is correct because the two swaps cancel; only the wire layout is wrong - and fails exactly the two pyrlp-anchored asserts in the canonical-wire regression test.Full local suite green (windows-x64, release): Core.Test 5984 total, 0 failed (the skip count drops by one - the deleted
[Ignore]d case).Golden derivations: the keccak vector and the regression-block field values were derived with pycryptodome and pyrlp respectively; the spec-length helper transcribes Yellow Paper appendix B and is structurally different from the product implementation (loop vs
LeadingZeroCount).Documentation
Requires documentation update
Requires explanation in Release Notes