Skip to content

fix(rlp): derive the receipt log limit from the block gas ceiling - #13288

Merged
Marchhill merged 8 commits into
masterfrom
fix/derive-receipt-log-rlp-limit
Sep 11, 2026
Merged

Marchhill merged 8 commits into
masterfrom
fix/derive-receipt-log-rlp-limit

Conversation

@Marchhill

@Marchhill Marchhill commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Changes

The receipt decoders capped a receipt's log list at a hardcoded 270_000 — a 100M block gas ceiling divided by GasCostOf.Log. Every comparable RLP bound derives from RlpLimit.MaxBlockGas instead (BlockBodyDecoder, SetCodeTxDecoder), and that ceiling defaults to 1 GGas.

The 100M premise is not slack on every chain we ship a config for:

shipped config block gas cheapest-log ceiling (gas / 375)
joc-mainnet / joc-testnet chainspec genesis 470,000,000 1,253,333 logs
xdc / xdc-testnet TargetBlockGasLimit 420,000,000 1,120,000 logs
RlpLimit.MaxBlockGas default 1,000,000,000 2,666,666 logs

A topic-free, data-free LOG0 costs 375 gas and encodes in 24 bytes, so on those chains one transaction can emit several times the 270,000 the decoder accepts — a 270,001-log receipt is ~6.5 MB, well inside the 16 MiB SnappyParameters.MaxSnappyLength ceiling. A peer serving that block has its receipt rejected with RlpLimitException and sync stalls.

Raising the count alone would widen an existing allocation exposure, so the byte arm of the guard is tightened at the same time. Rlp.GuardLimit rejects count > bytesLeft, and the callers passed the whole remaining buffer — one byte per entry — while new LogEntry[logCount] is pre-sized from the declared count before any entry is validated. Dividing the logs sequence's own span by the true per-entry floor fixes that.

Both arms now live in one place. The count-and-guard incantation had been copy-pasted into four decoders across four assemblies, each silently depending on the caller reaching entries through DecodeGuardNotNull for the divisor to be sound:

public static LogEntry[] DecodeLogs(ref RlpReader ctx, int logsEnd)
{
    RlpLimit logsRlpLimit = RlpLimit.ReceiptLogs;
    int maxLogs = Math.Min(logsRlpLimit.Limit, (logsEnd - ctx.Position) / MinNonNullEncodedLength);
    int logCount = ctx.PeekNumberOfItemsRemaining(logsEnd, maxLogs + 1);
    Rlp.GuardLimit(logCount, maxLogs, logsRlpLimit);
    ...
}

DecodeLogs counts, guards, allocates and rejects nulls itself, so the precondition the divisor rests on holds by construction rather than by remark — a caller cannot get the bound without the null rejection. Taking the tighter of the two bounds as maxSearch also stops the count scan as soon as the answer is decided: a 12 MiB frame of one-byte items stops at 524,289 rather than walking ~12.6M.

This cannot reject a genuinely encoded list — every entry the decoder accepts is at least 24 bytes — and it bounds the array at 1/24 of the bytes carrying it, tighter than the 8x the old count bound permitted.

  • Add RlpLimit.ReceiptLogs = MaxBlockGas / GasCostOf.Log + 1, clamped one below int.MaxValue so a Limit + 1 peek cannot wrap; Blocks.MaxGasLimit is an unvalidated ulong, so int.MaxValue was reachable by configuration. Recomputed in InitMaxBlockGas, its only mutator, so reads stay free on a path that decodes receipts by the million.
  • Add LogEntryDecoder.MinNonNullEncodedLength (24: a 21-byte address, 0xC0 topics, 0x80 data, one-byte prefix), internal and reachable only through DecodeLogsCompactReceiptStorageDecoder and OptimismCompactReceiptStorageDecoder accept nulls, for which it is not a valid divisor.
  • Apply to ReceiptMessageDecoder, ReceiptMessageDecoder69 and the rollup receipt decoder, which had the same untightened byte arm against a 4 MiB count — ~4 MB of 0xC0 bought a 33.5 MB LogEntry[]. Pre-existing rather than a regression here, but the constant it needs is introduced here.
  • Apply to EraSlimReceiptDecoder, which sized its LogEntry[] straight from a peeked count with no guard at all. Era archives are third-party downloads, so ~N bytes of one-byte placeholders bought an 8N-byte array before any entry was validated.
  • Bound the sibling TxReceipt[] in the same decoder, which had the same shape: PeekNumberOfItemsRemaining(outerEnd) with no maxSearch and no guard, on a buffer whose decompressed size is not capped (ReadEntryValueAsSnappy caps only the compressed entry). A slim receipt is ["", "", "", []] = 5 bytes, so that is the divisor; the count arm stays at the existing 4 MiB default, since a per-tx gas minimum is not safely assumable for every archive.

One visible consequence: a log list holding a one-byte null entry is rejected with RlpLimitException rather than RlpException. The decoders already rejected that input; only the exception type changes.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

Red/green pairs, each verified by reverting only the change under test:

  • Byte arm. Decode_rejects_a_log_count_the_message_cannot_hold (eth/63, eth/69) and Optimism_receipt_message_decoding_rejects_a_log_count_the_message_cannot_hold — 1,000 one-byte placeholders, under the count limit but over what 1,000 bytes can hold. Before the guard change they fail with RlpException: LogEntry decoding returned null, i.e. the array had already been allocated and decoding had begun.
  • Count arm. Decode_rejects_a_log_count_above_the_gas_ceiling — four genuine 24-byte logs, so the byte arm allows the count and only the ceiling can reject it; asserts it decodes at the default ceiling, then rejects after InitMaxBlockGas(GasCostOf.Log * 2). Red when the guard's limit argument is replaced with RlpLimit.DefaultLimit.
  • Derivation. Log_count_limit_derives_from_the_block_gas_ceiling drives InitMaxBlockGas over 1 GGas → 2,666,667, 30M → 80,001, 750 → 3 and ulong.MaxValueint.MaxValue - 1, so dropping the recomputation, or the clamp, is caught. [SetUp]/[TearDown] restore the process-global static.
  • Slim archive receipt count. Decode_GethFormat_ReceiptCountTheArchiveCannotHold_Throws — 1,000 one-byte placeholders; before the fix the whole TxReceipt[1000] is allocated and it throws RlpException from DecodeSlimReceipt, after it is RlpLimitException at the guard. Decode_GethFormat_ReceiptListOfSmallestPossibleEntries_Decodes pins that 1,000 genuine 5-byte receipts still decode.
  • Decode_accepts_a_log_list_of_smallest_possible_entries (all decoders) and Decode_GethFormat_LogListOfSmallestPossibleEntries_Decodes — 1,000 real minimum-size logs still decode, pinning that the byte arm cannot reject a valid list.
  • Deserialize_Allows_Receipt_Log_Count_Above_The_Former_Fixed_Limit — a 270,001-log receipt round-trips; it failed with RlpLimitException before.
  • Min_encoded_log_length_matches_the_smallest_encodable_log — ties the 24 to Rlp.LengthOf, so it cannot drift from the encoding.
  • Decode_throws_on_null_log_entry pads the list so the count guard passes and DecodeGuardNotNull is what rejects the null.

Suites green: Nethermind.Core.Test (6286), Nethermind.Network.Test (1672), Nethermind.JsonRpc.Test (2087), Nethermind.Optimism.Test (726), Nethermind.Synchronization.Test (2650), Nethermind.EraE.Test (166). Full-solution lint per code-lint.yml clean and positive-controlled with an injected unused using.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Remarks

eip8141-frame-txs-devnet7 names this constant ReceiptMessageDecoder.MaxReceiptLogs and reuses it in ReceiptForRpc as a cross-frame total log cap on the debug_insertReceipts ingress. The decoder half flows forward on the next merge; the ReceiptForRpc copy is a separate literal needing its own re-pointing there. Its consequence is milder — a rejected local debug insertion rather than a peer's block — so it is not folded in here.

Both receipt decoders capped a receipt's logs at a hardcoded 270,000, the
bound implied by a 100M block gas limit. Shipped chain configs run well
above that - joc-mainnet's genesis gas limit is 470M and xdc targets 420M -
so a single transaction there can emit several times that many logs and the
decoder would reject a valid receipt from a peer.

Derive the bound from RlpLimit.MaxBlockGas the way the neighbouring decoders
do, clamped so the configured ceiling cannot overflow int.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 6m 10s —— View job


Review: fix(rlp): derive the receipt log limit from the block gas ceiling

  • Read PR diff
  • Verify the derivation and the arithmetic against config/gas constants
  • Inspect RlpLimit / Rlp.GuardLimit / PeekNumberOfItemsRemaining semantics
  • Check both changed decoders + the Optimism variant
  • Review the new and removed tests
  • Post findings

Verdict

The diagnosis is right and the fix is the correct shape. The 270_000 literal really was derived from a 100M premise that no longer holds for shipped configs, deriving from RlpLimit.MaxBlockGas matches how every neighbouring bound is expressed, and making it a property rather than a static readonly field is the right call given the ceiling arrives from config after type init.

One Medium finding: the PR body's safety argument for raising the bound is incorrect, and the allocation exposure does grow. That's worth correcting (and cheaply hardening) before merge. Nothing here threatens the core fix.

What I verified:

claim result
GasCostOf.Log == 375 GasCostOf.cs:46
MaxGasLimit default 1_000_000_000 IBlocksConfig.cs:104
derived limit == 2_666_667 ✅ matches the test constant
no shipped config overrides MaxGasLimit ✅ so all chains get the 1 GGas derivation, comfortably above the 470M/420M cases in the table
test constant not order-dependent InitMaxBlockGas has one caller (InitTxTypesAndRlp.cs:34) and Nethermind.Core.Test doesn't reference Nethermind.Init, so the process-global static stays at its default there
no 270_000 literals left in-tree
new test's RLP arithmetic RlpWriter/Encode/LengthOf overloads all exist and the written lengths match the computed contentLength; the (byte)1 lands as the status code and 0xC0 reaches LogEntryDecoder as a null

I could not compile or run the suites — dotnet build isn't permitted in this sandbox, so the "all green" claims in the description are taken at face value.

Findings

# Severity Finding
1 Medium ReceiptMessageDecoder.cs:57-59 — the byte arm of GuardLimit is 1 byte per entry, not 24, so raising the count bound does widen the LogEntry[] allocation: ~2.6 MB of 0xC0 placeholders now buys a 21.3 MB LOH allocation (was ~2.16 MB). The new "at limit" test expecting RlpException rather than RlpLimitException is the proof that the array lands before validation. Still tighter than the 4 MiB DefaultLimit count that OptimismReceiptMessageDecoder already accepts, which is why it's Medium and not High — and the hardening is two lines.
2 Low RlpLimit.cs:29-36 — recomputed per receipt (64-bit divide + typeof(T).Name + AsMemory inside For<T>). Recomputing in InitMaxBlockGas keeps the "config arrives late" property and makes reads free. Folds in two doc nits: the <summary> names the logs rather than the bound, and the block→receipt step in the derivation is the one non-obvious part and isn't stated.
3 Low ReceiptMessageDecoder69.cs:50 — the eth/69 decoder's half of the fix is untested; both new tests hit eth/63 only. Also reads asymmetrically from its eth/63 twin (inline property, no maxSearch).
4 Low ReceiptsMessageSerializerTests.cs:194-209 — the negative case was deleted rather than retargeted, leaving no rejection coverage through ReceiptsMessageSerializer. Defensible given cost; the placeholder trick would restore it cheaply.

No issues found in: the clamp (MaxBlockGas / 375 + 1 cannot overflow ulong; the int.MaxValue clamp is correct), the Optimism analysis in the description (4 MiB > 2,666,667, so it is indeed looser and unaffected), or comment quality — the three added comments in the tests each explain why (0xC0 decodes as null, why placeholders are safe at the over-limit boundary, how the constant is derived) and none merely restate the code.

--- • branch fix/derive-receipt-log-rlp-limit

Comment thread src/Nethermind/Nethermind.Serialization.Rlp/ReceiptMessageDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/RlpLimit.cs Outdated
The count limit alone does not cap the LogEntry[] allocation: Rlp.GuardLimit's
byte arm rejects only count > bytesLeft, one byte per entry, and the array is
pre-sized from the declared count before any entry is validated. Raising the
count bound to 2,666,667 therefore let ~2.6 MB of one-byte placeholders buy a
21.3 MB array.

Divide the logs sequence's own span by the smallest encoding LogEntryDecoder
accepts, so the byte arm binds at 24 bytes per entry. No genuinely encoded log
list is affected - every entry the decoder accepts is at least that long - and
the amplification drops below the 270,000 baseline this branch replaced.

Also recompute RlpLimit.ReceiptLogs in InitMaxBlockGas instead of deriving it
per read, and cover the eth/69 decoder and the ReceiptsMessageSerializer layer.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 5m 39s —— View job


Review: fix(rlp): derive the receipt log limit from the block gas ceiling

  • Read the updated diff (2 commits, 8 files)
  • Re-verify the four findings from the previous round
  • Audit the new guard arithmetic (MinEncodedLength, span division)
  • Check RlpLimit recompute, both decoders, and the Optimism sibling
  • Review the new tests and helper
  • Post findings

Verdict

The hardening commit is correct and the reasoning behind it is now accurate. All four findings from the previous round are properly resolved — including the one where the fix went further than I suggested and landed in a better place: dividing the logs sequence's own span by a MinEncodedLength that lives on the type owning the encoding, rather than a literal duplicated across two assemblies.

One Medium remains, and it is the mirror image of the correction made this round: the same 1-byte-per-entry amplification is still open in OptimismReceiptMessageDecoder, where the bound is looser, and the PR description currently reads that looseness as a reason it's fine.

Prior findings, re-verified:

# prior finding status
1 byte arm is 1 byte/entry, not 24 ✅ fixed — Rlp.GuardLimit(count, (lastCheck - ctx.Position) / LogEntryDecoder.MinEncodedLength, limit) on both decoders; description corrected
2 ReceiptLogs recomputed per read ✅ fixed — snapshot recomputed in InitMaxBlockGas, DefaultMaxBlockGas const shared with the seed; both doc nits taken
3 eth/69 half untested + asymmetric ✅ fixed — two new eth/69 tests; the two decoders now read identically
4 serializer-layer rejection deleted ✅ restored via ReceiptRlpBuilder + WrapInSequence, full Deserialize path, 1 KB fixture

What I verified this round:

claim result
MinEncodedLength == 24 is a hard floor DecodeAddress()ReadAddressPrefix(allowNull: false) (RlpReader.cs:360), so the address is always 0x94+20; topics floor at 0xC0, data at 0x80; content 23 → one-byte prefix
the guard cannot reject a valid list n entries occupy >= 24n bytes, so n <= bytes/24 holds for anything that would decode; Decode_accepts_a_log_list_of_smallest_possible_entries pins it at the exact boundary (1000 logs, 24000 bytes, 24000/24 == 1000)
lastCheck - ctx.Position is the logs span ✅ left-to-right evaluation in lastCheck = ctx.ReadSequenceLength() + ctx.Position puts Position past the prefix; Peek* doesn't advance
count arm now unreachable over the wire ✅ 2,666,667 × 24 = 64 MB > the 16 MiB message ceiling
ReceiptLogs snapshot is safe InitMaxBlockGas is the sole mutator (private setter, one caller at InitTxTypesAndRlp.cs:34)
2_666_667 test constant isn't order-dependent Nethermind.Core.Test references Blockchain/Core/Crypto/Facade/Merge.Plugin/Specs.Test — not Nethermind.Init, the only assembly that calls the mutator
no shipped config overrides MaxGasLimit ✅ only BlocksConfig.cs:121 = 1 GGas, so every chain gets the 2,666,667 derivation
RlpLimitException : RlpException ✅ so the retargeted Throws.InstanceOf<RlpException>() does pass
ReceiptRlpBuilder byte layout [0x01, 0x80, bloom, [logs]] decodes as status=1 / gasUsed=0; WrapInSequence is correct (writer.Position sits past the prefix after StartSequence); Nethermind.Network.Test does reference Nethermind.Core.Test
Nethermind.Core types resolve in ReceiptRlpBuilder without a using ✅ enclosing-namespace lookup from Nethermind.Core.Test.Encoding

I could not compile or run the suites — dotnet build isn't permitted in this sandbox, so the "all green" claims are taken at face value.

Findings

# Severity Finding
1 Medium ReceiptMessageDecoder.cs:59OptimismReceiptMessageDecoder still runs the unhardened shape with DefaultLimit as a count: ~4 MB of 0xC0 → a 33.5 MB LogEntry[], larger than either the 21.3 MB you closed or the 2.16 MB baseline. The description's "already guards with the 4 MiB default limit, which is looser" reads that as safety when looseness is the exposure. Same one-line fix with the constant this PR introduces, or correct the paragraph.
2 Low ReceiptMessageDecoder69Tests.cs:52Decode_throws_on_null_log_entry no longer reaches the null branch; it is now the same scenario as the new count-guard test with logCount == 1. The branch is not dead in production — one 0xC0 beside a ~100-byte log clears count <= bytes/24 and lands in DecodeGuardNotNull — so that case is now uncovered on both decoders.
3 Low ReceiptRlpBuilder.cs:32-42 — the if (log is null) StartSequence(0) branch is redundant (LogEntryDecoder.Encode already writes 0xC0 for null, which the eth/69 helper relies on), and ReceiptMessageDecoder69Tests.EncodeReceiptWithLogs is now a near-copy of this method.

No issues found in: the int.MaxValue clamp (ulong arithmetic cannot overflow, and the cast is guarded); the DefaultMaxBlockGas const tying the seed to the initializer; the eth/63 ↔ eth/69 symmetry; the maxSearch addition on the eth/69 PeekNumberOfItemsRemaining (CountItems caps rather than throws, and the guard rejects the capped value either way); Rlp.GuardLimit's negative-bound arm (lastCheck - ctx.Position is a sequence content length, never negative); or comment quality — the XML docs on MinEncodedLength, ReceiptLogs and ReceiptRlpBuilder each state a derivation or an invariant, and the four added test comments each explain why. None restate the code.

Worth noting for whoever picks up finding 1: the 1-byte-per-entry byte arm is a repo-wide property of RlpReader.GuardLimit, so the DecodeArray/DecodeArrayPool family shares the shape. Optimism is called out specifically because it decodes the same peer-supplied receipts this PR is about, and because the constant that closes it lands in this diff.
• branch fix/derive-receipt-log-rlp-limit

@Marchhill
Marchhill marked this pull request as ready for review September 8, 2026 19:29
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 5m 34s —— View job


Review: fix(rlp): derive the receipt log limit from the block gas ceiling

  • Fetch base and read the updated diff
  • Re-verify the four prior findings against the new commit
  • Audit the new guard arithmetic (MinEncodedLength, span division)
  • Review RlpLimit recompute + new tests
  • Post findings

Verdict

All four prior findings are addressed, and the Medium one is addressed better than my own suggestion — putting the divisor on LogEntryDecoder as MinEncodedLength keeps it next to the encoding it describes, and pinning it against Rlp.LengthOf stops it drifting. Two Low findings remain, both in tests/docs. Nothing blocks merge.

Guard audit

I went after the one case the previous round didn't cover: RlpReader.ReadSequenceLength (RlpReader.cs:104) does not bound the declared content length against the remaining buffer, so lastCheck can point past Data.Length — which would make (lastCheck - ctx.Position) / 24 far larger than the bytes actually present and reopen the amplification. It is not reachable:

PeekNumberOfItemsRemainingRlpHelpers.CountItems advances only over real items and loops while position < end. When it returns, either it exited on position >= lastCheck (forcing lastCheck <= Data.Length, so the byte arm is honest), or on numberOfItems == maxSearch == Limit + 1, which the count arm rejects. Everything else runs PeekNextRlpLength off the end of the span and throws before new LogEntry[numberOfReceipts]. No path reaches the allocation with a dishonest byte arm.

claim result
MinEncodedLength == 24 is a true floor DecodeInternalDecodeAddress()ReadAddressPrefix(allowNull: false), so 21 bytes of address are mandatory; 0xC0 topics + 0x80 data + one-byte prefix = 24. DecodeStructRef uses DecodeAddressStructRefNonNull, same floor
guard cannot reject a genuine list ✅ every accepted entry is ≥24 bytes inside the logs span, so count * 24 <= span. Exact-fit edge (N entries of exactly 24 in 24N bytes) passes; zero-log receipts pass (0 > 0 false)
amplification now bounded span/24 * 8 == span/3 of array per wire byte, below the pre-branch 2.16 MB/16 MiB baseline
PeekNumberOfItemsRemaining doesn't advance Position readonly method, so (lastCheck - ctx.Position) is the full logs span
derived limit == 2_666_667 GasCostOf.Log == 375 (ulong, so the whole expression is ulong), MaxGasLimit default 1_000_000_000 (IBlocksConfig.cs:104), no shipped config overrides it
RlpLimit recompute InitMaxBlockGas is the only mutator; DefaultMaxBlockGas const removes the seed/initializer drift; reads are now a field load
both decoders read identically ✅ same hoisted local, same maxSearch, same guard expression
test helpers compile against real APIs Rlp.LengthOf(LogEntry?) (Rlp.cs:868), Rlp.LengthOf(Bloom?) = 259, RlpWriter.Position, EncodeNullObject() writes one 0xC0 byte (so Rlp.OfEmptyList.Length sizing is right), and Nethermind.Network.Test already project-references Nethermind.Core.Test
RlpLimitException : RlpException ✅ — which is also why finding #1 below matters

I could not compile or run the suites: dotnet build is not permitted in this sandbox. The "all green" claims in the description are taken at face value.

Findings

# Severity Finding
1 Low ReceiptMessageDecoder69Tests.cs:45-52Decode_throws_on_null_log_entry no longer tests the null path. A 1-byte item in a 1-byte span trips the count guard first, so it is now the same scenario as Decode_rejects_a_log_count_the_message_cannot_hold with logCount: 1, and the DecodeGuardNotNull rejection is uncovered; Throws.InstanceOf<RlpException>() masks it because RlpLimitException derives from RlpException. Padding the list with one 47-byte log makes count == 2 in a 48-byte span, which clears the guard and reaches the null — restoring Throws.TypeOf.
2 Low (nit) LogEntryDecoder.cs:22 — "smallest RLP encoding this decoder accepts" isn't literally true: DecodeInternal accepts a one-byte 0xC0 and returns null. The floor is for a non-null entry, i.e. for callers using DecodeGuardNotNull. Both call sites qualify; the wording is just what a future reuser will read.

Adjacent, deliberately out of scope

Not findings against this PR — flagging so they aren't lost. Two other sites allocate LogEntry[] from a peeked count and keep the 8×-per-wire-byte shape this PR just removed:

  • OptimismReceiptMessageDecoder.cs:58-60ctx.GuardLimit(numberOfReceipts) with the default limit, so 4 MiB used as a count: ~33.5 MB of array from ~4 MB of wire. The PR body calls this "unaffected… looser than the derived value", which is true of the count bound but predates the byte-arm hardening and no longer covers the amplification. Same one-line change would apply.
  • EraSlimReceiptDecoder.cs:71-73 — no count guard at all. Era archives are lower-exposure than P2P, but they are third-party downloads.

Per AGENTS.md's minimal-diff rule, leaving both out of this PR is the right call; a follow-up would be worth opening.

No issues found in: the clamp arithmetic, the recompute placement, ReceiptRlpBuilder (the 0xC0-placeholder trick is correct and the buffer sizing matches Rlp.LengthOfSequence), the restored serializer rejection test, or comment quality — every added comment states why (the 0xC0 placeholder's role, why the fixture is hand-built rather than round-tripped, the block→receipt step in the derivation, why ReceiptLogs is recomputed rather than derived per read). None merely restate the code.
• branch fix/derive-receipt-log-rlp-limit

Comment thread src/Nethermind/Nethermind.Core.Test/Encoding/ReceiptRlpBuilder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/ReceiptMessageDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/LogEntryDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/ReceiptMessageDecoder.cs Outdated
The rollup receipt decoder kept the untightened guard: a declared log
count only had to be under the 4 MiB default and under one byte per
entry, so ~4 MB of 0xC0 placeholders bought a 33.5 MB LogEntry[] before
the first entry was validated. Give it the same byte arm as the other
two decoders so all three read identically.

Restore the eth/69 null-entry coverage the tightened guard had made
unreachable: a lone one-byte placeholder now trips the count guard, so
pad the list until the null reaches DecodeGuardNotNull.

Fold the eth/69 body into ReceiptRlpBuilder and drop its redundant null
branch - LogEntryDecoder.Encode already writes 0xC0 for null.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 4m 58s —— View job


Review: fix(rlp): derive the receipt log limit from the block gas ceiling

  • Read the updated diff (10 files, f1090ca..HEAD)
  • Re-verify every prior finding against the new commit
  • Audit the Optimism guard added this round
  • Review ReceiptRlpBuilder, the retargeted eth/69 null test, and the doc changes
  • Post findings

Verdict

Every finding from the previous rounds is resolved, including the Medium on OptimismReceiptMessageDecoder — and it was fixed rather than argued away, with the description's inverted "looser, therefore unaffected" paragraph corrected to say the opposite. All three receipt decoders now read identically, and the guard is sound on each.

No Critical, High or Medium findings. One Low nit (test duplication) and one adjacent item for a follow-up.

Prior findings, re-verified

# finding status
M OptimismReceiptMessageDecoder unhardened, DefaultLimit as a count → 33.5 MB from ~4 MB ✅ fixed at OptimismReceiptMessageDecoder.cs:58-60; count arm moved off DefaultLimit to ReceiptLogs, strictly tighter; two regression tests in Nethermind.Optimism.Test
L eth/69 Decode_throws_on_null_log_entry no longer reached DecodeGuardNotNull ✅ fixed — EncodeReceipt([null, PaddingLog]), and the assertion is back to Throws.TypeOf<RlpException>()
L ReceiptRlpBuilder null branch redundant + duplicated in the eth/69 fixture ✅ fixed — LogEntryDecoder.Encode/GetLength handle null on both halves, so the length and byte sides cannot drift; the eth/69 body moved into the builder as EncodeReceipt69
L MinEncodedLength doc overstated what the decoder accepts ✅ fixed — now "non-null log entry", with the 0xC0-as-null caveat spelled out
earlier rounds: byte arm, ReceiptLogs recompute, eth/69 coverage, serializer rejection test ✅ all still in place

What I verified this round

claim result
Optimism guard arithmetic logEntriesCheck - ctx.Position is the logs sequence content length (ReadSequenceLength advances past the prefix first), so the divisor input is non-negative and honest
Optimism entries go through DecodeGuardNotNull line 63 — so the 24-byte floor is a real precondition there, same as the other two
the Optimism tests actually exercise that decoder ✅ the eth/63 body ReceiptRlpBuilder.EncodeReceipt produces is exactly what OptimismReceiptMessageDecoder parses (sequence → status 0x01 → gas → bloom → logs), and Position == lastCheck after the logs so no deposit fields are read
all three guards are byte-for-byte identical ReceiptMessageDecoder.cs:57-59, ReceiptMessageDecoder69.cs:48-50, OptimismReceiptMessageDecoder.cs:58-60
null-log test now clears the count guard PaddingLog = 21 addr + 1 topics + 24 data = 46 content → 47 bytes; [null, PaddingLog] is 2 items in 48 bytes, 48 / 24 == 2, guard passes, item 0 decodes null → DecodeGuardNotNull throws
LogEntryDecoder.GetLength(null) == 1 line 78-83 — matches the one 0xC0 byte Encode writes, so the builder's sizing and its writes agree for null and non-null alike
ReceiptRlpBuilder resolves from Nethermind.Optimism.Test ✅ Optimism.Test → Network.Test → Core.Test
Rlp.GuardLimit(int, int, RlpLimit?) and PeekNumberOfItemsRemaining(int?, int) exist with those shapes Rlp.cs:889, RlpReader.cs:76
derived limit == 2_666_667 GasCostOf.Log is public const ulong Log = 375 (GasCostOf.cs:46), so the whole expression is ulong; MaxGasLimit default 1 GGas and no shipped config overrides it
InitMaxBlockGas still the sole mutator ✅ one caller, InitTxTypesAndRlp.cs:34; nothing in any test project calls it, so the 2_666_667 assertion is not order-dependent
no 270_000 literal left except the deliberately-named one ✅ only FormerFixedLogsLimit in ReceiptsMessageSerializerTests.cs:22

Publishing ReceiptLogs as a struct is worth one line since it's new: RlpLimit is ~28 bytes, so the static write in InitMaxBlockGas is not atomic and a concurrent reader could in principle tear it — a torn ReadOnlyMemory<char> is genuinely unsafe, since .Span doesn't re-validate bounds. It isn't reachable here: ForReceiptLogs always produces the same TypeName/PropertyName (the same interned literal, same range), so only the 4-byte Limit ever differs and that write is atomic. Any torn read is therefore a valid value. Fine as written; just don't let a future variant vary the name fields.

I could not compile or run the suites — dotnet build is not permitted in this sandbox, so the "all green" claims in the description are taken at face value.

Findings

# Severity Finding
1 Low (nit) ReceiptMessageDecoderTests.cs:16UnbackedLogCount = 1_000 and its identical comment now appear verbatim in four fixtures. ReceiptRlpBuilder is already the shared home; the constant only means anything in terms of its placeholders.

Adjacent, for a follow-up

Not a finding against this PR. With Optimism now fixed, EraSlimReceiptDecoder.cs:69-73 is the last new LogEntry[count] built from a peeked count, and it has no guard at alllogCount is bounded only by the buffer, so it keeps the full 8-bytes-of-array-per-wire-byte shape the other three just lost. Era archives are user-selected downloads rather than arbitrary peers, so scoping it out is defensible under the minimal-diff rule; the same one-liner with LogEntryDecoder.MinEncodedLength would close it.

Also noticed while checking precedent: BlockBodyDecoder.TransactionsCountLimit and SetCodeTxDecoder.AuthorizationListLimit derive from MaxBlockGas per read via checked(...), which would throw OverflowException at decode time on a very large configured ceiling where ReceiptLogs clamps. The clamp here is the better behaviour — noting only so the asymmetry isn't mistaken for an oversight in this PR.

No issues found in: the guard arithmetic on any of the three decoders; the int.MaxValue clamp; the DefaultMaxBlockGas const tying seed to initializer; the ReceiptRlpBuilder byte layout (Rlp.LengthOfSequence/writer.Position sizing checks out on both the eth/63 and eth/69 shapes, and WrapInSequence is correct); the restored serializer rejection test; or comment quality — the added comments each state a derivation, an invariant, or a why (the padding log's 47 bytes buying the null its budget, why the fixture is hand-built, the block→receipt step, why ReceiptLogs is a snapshot, why GasUsedTotal is arbitrary). None restate the code.
• branch fix/derive-receipt-log-rlp-limit

Comment thread src/Nethermind/Nethermind.Core.Test/Encoding/ReceiptMessageDecoderTests.cs Outdated
Era archives are third-party downloads, so a declared log count decided the
array size before any entry was validated: N bytes of one-byte 0xC0
placeholders bought an 8N-byte LogEntry[]. Guard the count the same way the
receipt message decoders do, against the minimum bytes a real log needs.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@Marchhill Marchhill left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed the derivation, the byte-derived bound and all four receipt decode paths. Notes inline; the PR is currently in a CONFLICTING merge state against master, so one comment covers the resolution.

Comment thread src/Nethermind/Nethermind.EraE/Archive/EraSlimReceiptDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/RlpLimit.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/ReceiptMessageDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/ReceiptMessageDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Serialization.Rlp/LogEntryDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Optimism/OptimismReceiptMessageDecoder.cs Outdated
Comment thread src/Nethermind/Nethermind.Core.Test/Encoding/ReceiptMessageDecoderTests.cs Outdated
Comment thread src/Nethermind/Nethermind.EraE.Test/Archive/EraSlimReceiptDecoderTests.cs Outdated
Comment thread src/Nethermind/Nethermind.EraE.Test/Archive/EraSlimReceiptDecoderTests.cs Outdated
…t-log-rlp-limit

Conflict in ReceiptMessageDecoder: master rewrote the log-count guard into the
span/position form while this branch was tightening it. Master's bound is looser
on both arms - the count is the hardcoded 270_000 this branch replaces, and the
byte arm is `rlp.Length - position`, the whole remaining buffer at one byte per
entry. Resolved by carrying this branch's derived limit and per-entry byte floor
onto master's span/position form.
The count-and-guard incantation had been copy-pasted into four decoders across
four assemblies, each silently depending on the caller reaching entries through
DecodeGuardNotNull for the per-entry byte floor to be sound. Fold all four into
LogEntryDecoder.DecodeLogs, which counts, guards, allocates and rejects nulls
itself, so the precondition holds by construction rather than by remark. The
divisor drops from public to internal and gains the precondition in its name.

Counting now stops at the tighter of the two bounds instead of at the count
limit, so a 12 MiB frame of one-byte items is no longer walked to its end
before rejection - which also puts the Limit + 1 peek out of overflow range.
Clamp ReceiptLogs one below int.MaxValue regardless, for the generic Limit + 1
in RlpDecoder.DecodeArray; Blocks.MaxGasLimit has no upper validation.

Bound the slim archive's TxReceipt[] the same way. It was sized straight from a
peeked count with no maxSearch and no guard, on a buffer whose decompressed
size is not capped, so one declared byte bought an 8-byte reference slot.
manusw7
manusw7 approved these changes Sep 10, 2026
@Marchhill
Marchhill merged commit e319c2e into master Sep 11, 2026
518 checks passed
@Marchhill
Marchhill deleted the fix/derive-receipt-log-rlp-limit branch September 11, 2026 00:14
Marchhill added a commit that referenced this pull request Sep 11, 2026
…ceipt decoders

Master's #13288 rewrote the receipt log-decode loop into LogEntryDecoder.DecodeLogs, which
performs no end-of-list check, and taking that block in full dropped this branch's
ctx.Check(lastCheck) from ReceiptMessageDecoder and the peer-facing ReceiptMessageDecoder69.
The logs are the payload's last item, so an over-run lands exactly on the receipt end and the
enclosing checkpoint still passes - an under-declared logs-list header decoded again.

Decode_LogsHeaderMustMatchItsContent stayed green only by coincidence: its minimal 24-byte log
let master's new byte arm reject the one-byte under-declaration on its own. Both header tests
now use a padded log the byte arm cannot reject, so only the restored check can, and
Padded_log_outlives_a_one_byte_under_declaration pins that property against the guard's floor.
Marchhill added a commit that referenced this pull request Sep 11, 2026
Master's #13288 replaced the hardcoded 270_000 receipt-log bound with RlpLimit.ReceiptLogs,
derived from the configured block gas. The frames branch carried its own copy of the constant
master deleted, leaving frame receipts - which both message decoders hand to
FrameReceiptRlp.DecodePayload before reaching DecodeLogs - the one receipt kind the new ceiling
did not govern.

No new bound: the frame read and write paths each already carried one, so only what they read
changes. It is read per use rather than captured in a static field, because the value is set
from client configuration after a type initializer may have run.
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.

3 participants