fix(rlp): derive the receipt log limit from the block gas ceiling - #13288
Conversation
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 finished @Marchhill's task in 6m 10s —— View job Review:
|
| 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
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.
|
@claude review |
|
Claude finished @Marchhill's task in 5m 39s —— View job Review:
|
| # | 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:59 — OptimismReceiptMessageDecoder 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:52 — Decode_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
|
Claude finished @Marchhill's task in 5m 34s —— View job Review:
|
| claim | result |
|---|---|
MinEncodedLength == 24 is a true floor |
✅ DecodeInternal → DecodeAddress() → 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-52 — Decode_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-60—ctx.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
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.
|
@claude review |
|
Claude finished @Marchhill's task in 4m 58s —— View job Review:
|
| # | 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:16 — UnbackedLogCount = 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 all — logCount 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
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.
|
@claude review |
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
Marchhill
left a comment
There was a problem hiding this comment.
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.
…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.
…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.
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.
Changes
The receipt decoders capped a receipt's log list at a hardcoded
270_000— a 100M block gas ceiling divided byGasCostOf.Log. Every comparable RLP bound derives fromRlpLimit.MaxBlockGasinstead (BlockBodyDecoder,SetCodeTxDecoder), and that ceiling defaults to 1 GGas.The 100M premise is not slack on every chain we ship a config for:
gas / 375)joc-mainnet/joc-testnetchainspec genesisxdc/xdc-testnetTargetBlockGasLimitRlpLimit.MaxBlockGasdefaultA topic-free, data-free
LOG0costs 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 MiBSnappyParameters.MaxSnappyLengthceiling. A peer serving that block has its receipt rejected withRlpLimitExceptionand 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.GuardLimitrejectscount > bytesLeft, and the callers passed the whole remaining buffer — one byte per entry — whilenew 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
DecodeGuardNotNullfor the divisor to be sound:DecodeLogscounts, 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 asmaxSearchalso 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.
RlpLimit.ReceiptLogs=MaxBlockGas / GasCostOf.Log + 1, clamped one belowint.MaxValueso aLimit + 1peek cannot wrap;Blocks.MaxGasLimitis an unvalidatedulong, soint.MaxValuewas reachable by configuration. Recomputed inInitMaxBlockGas, its only mutator, so reads stay free on a path that decodes receipts by the million.LogEntryDecoder.MinNonNullEncodedLength(24: a 21-byte address,0xC0topics,0x80data, one-byte prefix),internaland reachable only throughDecodeLogs—CompactReceiptStorageDecoderandOptimismCompactReceiptStorageDecoderaccept nulls, for which it is not a valid divisor.ReceiptMessageDecoder,ReceiptMessageDecoder69and the rollup receipt decoder, which had the same untightened byte arm against a 4 MiB count — ~4 MB of0xC0bought a 33.5 MBLogEntry[]. Pre-existing rather than a regression here, but the constant it needs is introduced here.EraSlimReceiptDecoder, which sized itsLogEntry[]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.TxReceipt[]in the same decoder, which had the same shape:PeekNumberOfItemsRemaining(outerEnd)with nomaxSearchand no guard, on a buffer whose decompressed size is not capped (ReadEntryValueAsSnappycaps 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
RlpLimitExceptionrather thanRlpException. The decoders already rejected that input; only the exception type changes.Types of changes
What types of changes does your code introduce?
Testing
Requires testing
If yes, did you write tests?
Notes on testing
Red/green pairs, each verified by reverting only the change under test:
Decode_rejects_a_log_count_the_message_cannot_hold(eth/63, eth/69) andOptimism_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 withRlpException: LogEntry decoding returned null, i.e. the array had already been allocated and decoding had begun.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 afterInitMaxBlockGas(GasCostOf.Log * 2). Red when the guard's limit argument is replaced withRlpLimit.DefaultLimit.Log_count_limit_derives_from_the_block_gas_ceilingdrivesInitMaxBlockGasover 1 GGas → 2,666,667, 30M → 80,001, 750 → 3 andulong.MaxValue→int.MaxValue - 1, so dropping the recomputation, or the clamp, is caught.[SetUp]/[TearDown]restore the process-global static.Decode_GethFormat_ReceiptCountTheArchiveCannotHold_Throws— 1,000 one-byte placeholders; before the fix the wholeTxReceipt[1000]is allocated and it throwsRlpExceptionfromDecodeSlimReceipt, after it isRlpLimitExceptionat the guard.Decode_GethFormat_ReceiptListOfSmallestPossibleEntries_Decodespins that 1,000 genuine 5-byte receipts still decode.Decode_accepts_a_log_list_of_smallest_possible_entries(all decoders) andDecode_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 withRlpLimitExceptionbefore.Min_encoded_log_length_matches_the_smallest_encodable_log— ties the 24 toRlp.LengthOf, so it cannot drift from the encoding.Decode_throws_on_null_log_entrypads the list so the count guard passes andDecodeGuardNotNullis 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 percode-lint.ymlclean and positive-controlled with an injected unusedusing.Documentation
Requires documentation update
Requires explanation in Release Notes
Remarks
eip8141-frame-txs-devnet7names this constantReceiptMessageDecoder.MaxReceiptLogsand reuses it inReceiptForRpcas a cross-frame total log cap on thedebug_insertReceiptsingress. The decoder half flows forward on the next merge; theReceiptForRpccopy 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.