EIP-8141: in-pool validation-prefix simulation for opaque frame transactions (Phase 2) - #12624
Conversation
|
Claude finished @Marchhill's task in 11m 29s —— View job Review: EIP-8141 Phase 2 — in-pool validation-prefix simulation
The architecture here is sound and the hard parts are done well. Reusing The findings below are about the trace rules being incompletely enforced relative to what the code claims, plus diagnostics and cancellation. High1. The Medium2. 3. 4. The post-frame 5. Low6. 7. 8. 9. An under-funded payer is reported as 10. Test coverage of the trace rules — the security surface of this PR — is thin: one banned opcode of ~20, and no coverage of the Notes (no action)
I could not verify compilation — |
EVM Opcode Benchmark DiffAggregated runs: base=3, pr=3 Improvements (1)
|
|
Addressed the review findings (commit 3811261). High — Medium — Medium — Medium — post-frame Medium — Build clean ( |
|
Claude finished @Marchhill's task in 7m 40s —— View job Re-review: commit
|
| # | Finding | Status |
|---|---|---|
| 1 (High) | CALL*/EXTCODE* silently admitted |
Fixed |
| 2 (Med) | TIMESTAMP exemption ignored the code |
Fixed |
| 3 (Med) | _pendingGasRequiresCall cross-frame leak |
Fixed |
| 4 (Med) | over-budget prefix misreported | Partially — misreport now inverted (see below) |
| 5 (Med) | no CancellationToken |
Signature added; enforcement is entry-only |
| 6–10 (Low) | duplicate Resolve, SupportsFrames guard / head source, IsTracingReceipt, under-funded-payer diagnostics, trace-rule test coverage |
Open, explicitly deferred by the author |
High #1 is genuinely closed. I checked the mechanics rather than the description:
- The stack indices are right —
_targetStackIndex = 1forCALL/CALLCODE/DELEGATECALL/STATICCALL([gas, address, …]) and0forEXTCODESIZE/EXTCODEHASH/EXTCODECOPY, matchingNativePrestateTracer.cs:137,149andTraceStack.Peek's top-down indexing. - The
StartOperation→SetOperationStackhandoff is safe:StartInstructionTrace(VirtualMachine.cs:1331) is the sole caller of both and always invokes them together withIsTracingStacktrue, so_targetStackIndexcannot survive into a different instruction. Thestack.Count <= indexguard is correct — a stack underflow halts the frame exceptionally and rejects the prefix anyway. IsContractbeforeIsDelegatedCodeis the right order for EIP-7702 (a delegated account has code), andtx.senderis exempted per the spec's default-code carve-out.- Both new tests are meaningful:
Simulate_PrefixCallsCodelessTarget_RecordsViolation(never-deployedAddressC) andSimulate_PrefixCallsExistingContract_Allowed, which pins the permissive side so the rule can't over-reject.
Medium #2 and #3 are closed, and I verified the two things that could have gone wrong:
- The
TIMESTAMPaddress+code-hash pair closes the residual hole completely. ADELEGATECALLinto the verifier's code leavesExecutingAccount ≠ expiryVerifier→ banned; aDELEGATECALLout of the verifier would keepExecutingAccountbut is impossible once the code hash matches, sinceExpiryVerifierCodecontains noDELEGATECALL.GetCodeHashresolves throughIAccountStateProvider's default member (ValueHash256), so the comparison is well-typed. - The
pc + 1peek is sound, which was not obvious: Nethermind has a second, pre-decoded dispatch loop (VirtualMachine.Stream.cs), and if it were used thepchanded toStartOperationwould not be a raw code offset.VirtualMachine.cs:1287gates the stream on!TTracingInst.IsActive, so an instruction-tracing run always takesRunByteCodeCore, whereprogramCounterindexesstack.Codedirectly. EOF is not in the tree, so there is no section-relative-pccase either.GAShas no immediate, sopc + 1is the next executed opcode.
New findings
Medium
capped inverts the misreport rather than removing it, because the loop bounds actual gas while the spec bound is on declared gas_limits — TransactionProcessorBase.FrameTx.cs:429-458
capped is derived from the declared limit but selects a reason about consumption, so the comment's invariant ("a frame capped to the remaining budget that then failed exhausted that cap") does not hold. An unrecognized-shape verify frame declaring 5M gas that REVERTs at 3k on a signature mismatch — the ordinary rejection — is now reported as MAX_VERIFY_GAS exceeded. An explicit ShouldRevert should never be attributed to the budget, and IsError isn't a reliable signal either since every exceptional halt consumes all remaining gas.
The root cause is worth fixing rather than patching the discriminator: MEMPOOL-RULES-DESIGN.md:46 records the bound as the sum of declared prefix gas_limit plus signature cost, which is what TryGetValidationPrefixVerifyGas / FrameTxVerifyGasFilter implement — but this loop accumulates actual frameGasUsed under a cap. Since TryGetValidationPrefixVerifyGas returns false for unrecognized shapes (precisely the shapes that reach the simulator), a prefix declaring 5M of verify gas but consuming 40k is admitted here while Direct Evaluation would reject it — the reverse of "Direct evaluation MUST apply the same limits as simulation." Accumulating declared limits and rejecting before execution makes the check exact, reachable, spec-aligned, and drops capped entirely. Suggested diff in the inline comment.
Also: the over-budget test uses SelfVerifyFrame, a recognized shape that FrameTxVerifyGasFilter rejects with VerifyGasExceeded before the simulator runs, so it doesn't cover the path this code actually serves.
Low
Per-frame cancellation is already available for ~4 lines, and would also stop burning budget after a violation — IFrameTxPrefixSimulator.cs:26-31
Honoring the token only at entry covers the moment the simulation is free. The interpreter already polls _txTracer.IsCancelled every 1024 opcodes (VirtualMachine.DispatchSpecialized.cs:62, gated by IsCancelable at VirtualMachine.cs:1256), so overriding IsCancelable/IsCancelled on FrameTxValidationTracer closes the deferred gap outright. Doing it as Violated || token.IsCancellationRequested also fixes a DoS-shaped detail that stands on its own: today a prefix that violates a rule on its first opcode still runs to completion, so each rejected tx costs a full MAX_VERIFY_GAS instead of ~0. The OperationCanceledException already has a handler and the processor's finally restores state, so nothing else moves.
tx.sender exemption and EIP-7702. Exempting sender from the target rules is right per the default-code carve-out, but when the sender is 7702-delegated the prefix's validity depends on the delegation target's code, which the dependency set does not appear to track (only the sender's own code hash, i.e. the designation). Harmless while re-simulation on head change is deferred, worth a line in the EIP8141: follow-up list.
Minor perf note (no action). IsDelegatedCode(Address) loads the target's full code, so an EXTCODESIZE on a 24 KiB contract pulls the whole code during admission. Bounded by MAX_VERIFY_GAS and code-cached; noting only because it's a per-opcode state read on a new path.
Still open from the prior review
Findings 6–10 are unaddressed by design, per the author's note. Of these, #10 is the one I'd still push on: the CALL*/EXTCODE* rule now has both a positive and a negative test, but the SLOAD-restricted-to-tx.sender rule (the only thing standing between a code-carrying paymaster and admission), the GAS; *CALL allowance, the positive TIMESTAMP-in-verifier exemption, and cumulative-across-frames gas remain untested, and the banned list still has one opcode of ~20 covered. A [TestCase]-parameterized body over the ban list is roughly the size of the current single test.
Not verified by execution — dotnet build/dotnet test aren't in my allowed tools here, so all of the above is from reading. The PR states the build is clean and the suites are green; CI will confirm.
• branch eip8141-mempool-phase2
ff8d854 to
6dff6ab
Compare
146f435 to
2c953dd
Compare
2c953dd to
fcd4cbe
Compare
Both conflicts were additive. AcceptTxResult and TxPoolErrorMessages keep the new blob-sidecar verdict alongside the simulation-failure one. FrameTxMissingSidecar moves off id 24, which KeyedNonceUnmet already holds on the base branch: AcceptTxResult equality is by id alone, so the two verdicts compared equal. It takes 27 rather than 26, which the simulation-guards branch uses.
…ting SLOTNUM is on the EIP's banned list and was missing from the tracer. It is reachable: EIP-7843 registers it from Amsterdam, which the frame prototype spec derives from. Unlike NUMBER or TIMESTAMP the slot cannot coincide between the simulated head and an including block, so a prefix gating approval on it can be admitted and then rejected at execution while still holding a payer reservation. The simulation filter now names the accepted outcome instead of reaching it by fall-through, so a future outcome cannot silently record a payer it does not carry. Undecided verdicts get their own counter: they mean payer exposure has stopped being accounted for, which was previously visible only in debug logs. Repeated node-side faults log once at warn rather than once per submission. The pool config description no longer calls its limit the consensus MAX_VERIFY_GAS; it bounds the declared-gas check, and the constant caps a simulated prefix per frame regardless of it.
|
Claude finished @Marchhill's task in 7m 38s —— View job Review: EIP-8141 Phase 2 — in-pool validation-prefix simulation
Last round's High is genuinely closed, and I checked the mechanics rather than the commit message.
Things I re-verified that hold: Three findings, all Low. No Critical, High or Medium. Low1. 2. The node-fault warn latch is never released — 3. The new counter is a property, so it can only be bumped with a racy Notes (no action)
Not verified by execution — |
…lt warning Undecided becomes the zero value, so a default-constructed FrameTxSimulationResult defers instead of reading as an accept with a null payer and admitting the transaction with no exposure reservation and no counter. The node-fault warn latch now clears once the EVM runs again, so a transient fault no longer downgrades every later fault to debug for the rest of the process lifetime. The two simulation counters become fields bumped with Interlocked: the filter runs under the pool head read lock alongside concurrent submitters, and the undecided counter is the signal that payer exposure has stopped being accounted for.
Renumber FrameSimulationFailed to 28: the base branch's FrameTxVerifyAfterPrefix already claims 25, and AcceptTxResult equality is by id alone. Order the two new placement filters ahead of the simulation filter, and keep the simulation counters as fields so they stay Interlocked-incrementable.
wurdum
left a comment
There was a problem hiding this comment.
The finding identified out of the scope of the changed files:
FrameTxVerifyGasFilter sits at TxPool.cs:193, several filters ahead of FrameTxSimulationFilter at TxPool.cs:218, and prices admission with ValidationWorkGas, whose fallback counts every frame when no grammar matches. A transaction of [verify gas=100k, execution gas=500k] sums to 600k and is dropped as FrameTxVerifyGasTooHigh before the simulator is consulted, although the simulation would price only the leading verify frame and resolve a payer. The unrecognized-shape class is therefore admissible only when its entire frame list fits under FrameTxMaxVerifyGas, which excludes anything carrying a normal execution body.
int counted = RecognizedPrefixLength(frames, transaction.SenderAddress)
?? LeadingVerifyFrameCount(frames);
private static int LeadingVerifyFrameCount(TxFrame[] frames)
{
int i = 0;
while (i < frames.Length && frames[i].Mode == TxFrame.ModeVerify) i++;
return i;
}7831ad9
into
eip8141-frame-txs-devnet7
…IP-8272 recent roots, blob support) (#12526) * docs: drop devnet divergence note from ExecuteDefaultVerifyCode (#12880) Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * feat(txpool): enforce the EIP-8141 VERIFY-after-prefix and expiry-frame placement rules (#12855) * feat(txpool): reject a frame transaction whose VERIFY frame follows its validation prefix EIP-8141 "Structural Rules" rule 8 bars a VERIFY frame from sitting behind the validation prefix: a VERIFY frame that reverts invalidates the whole transaction, so one placed past the prefix lets state the pool never validated invalidate a pooled transaction. This is a public-mempool rule rather than a validity rule, so it lands as an incoming filter next to the other frame mempool bounds and leaves consensus validation untouched. The prefix boundary is the recognized-prefix grammar FrameTxValidation already prices admission with, so the two cannot drift. The blob frame-transaction test helper appended its expiry verifier frame behind the self_verify frame, a placement the spec allows only as the leading frame and which the new rule correctly rejects; it now leads, which also brings its gas inside the prefix that MAX_VERIFY_GAS bounds. * feat(txpool): require an expiry verifier frame to lead the frame list EIP-8141 "Expiry Verifier Frame" permits an expiry_verify frame only as the first frame in the list. Like the structural rules above it, this sits under the Mempool heading and the reference implementation validates such a frame's shape and uniqueness but never its position, so it lands as an incoming filter rather than in consensus validation. TryGetExpiryDeadline read the deadline from whichever frame happened to carry it, which was looser than the rule it serves. It now reads the leading frame alone, so the accessor and the placement rule agree on where the deadline lives. The two must ship together: tightening the accessor on its own would leave a misplaced frame carrying a deadline the expiry sweep can never see, letting the transaction outlive its own expiry. The frames-absent branch is untouched, so a reloaded light record still recovers its deadline from storage. * test(txpool): share the frame layout builders and cover the new placement rules Extract the duplicated frame builders into one helper, add the expiry and POST_TX cases plus rejection-counter assertions, and give the blob and light record fixtures a leading expiry frame with headroom under the verify ceiling. * test(txpool): pin the filter wiring and the result id uniqueness Rename the placement filter to the FrameTx prefix its siblings use, guard AcceptTxResult id collisions by reflection, and submit both rejected layouts through the pool so the filter order stays load-bearing. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * test: load the frame transaction fixtures by mapping their fork name (#12854) * test: load the frame transaction fixtures by mapping their fork name The EIP-8141 fixture suites declare their network as Bogota, which SpecNameParser did not map, so every file failed to load. The failure surfaced as an unrelated Hash256 conversion error because ConvertToBlockchainTests wrapped both deserialization and conversion in the HalfBlockchainTestJson shape fallback: the unmapped fork name threw during conversion, and the retry against the trimmed shape then failed on its differently typed postState. Narrow the fallback to deserialization and name the fork in the exception. * test: chain both shape errors and name the resolved fork Bind both deserialization failures so a fixture matching neither shape reports both causes, and include the substituted fork name in the parser error. * test: forward the string fixture overload to the span one Both overloads carried the same shape-fallback block verbatim; the string overload has a single caller, so it can transcode and forward instead. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * EIP-8141: in-pool validation-prefix simulation for opaque frame transactions (Phase 2) (#12624) * EIP-8141: charge a frame's entry gas and its target's delegation access (#12856) * fix(core): do not price recent-root references before their fork (#12882) * fix(eip8250): round-trip nonce keys through the frame tx RPC view (#12883) * fix(consensus): install the expiry verifier predeploy without a nonce (#12887) * fix(txpool): carry nonce_keys on the light blob tx record (#12884) * fix(evm): gate POST_TX frames on the assertion fork in the processor (#12881) * fix(evm): gate POST_TX frames on the assertion fork in the processor The frame processor already re-checks the keyed-nonce and recent-root fork flags so that entry points which skip static validation cannot run either extension early. The POST_TX frame mode had no such check: eth_call, eth_estimateGas, eth_simulate and debug_traceCall reach the processor without running the transaction validator, so on a chain scheduling frame transactions without the assertion fork they executed a POST_TX frame with full assertion semantics for a transaction the chain itself rejects. * fix(evm): reject undefined frame modes in the processor The processor only gated POST_TX, so a mode above the defined range fell through to DEFAULT semantics and executed on the paths that skip static validation. Refuse it alongside the POST_TX gate, reusing the validator's message constant. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * fix: reconcile keyed-nonce signatures with master so the merge tree builds (#12908) * perf(core): accelerate Keccak with AVX-512VL and batched hashes (#12844) * perf(core): tune the AVX-512 Keccak-f[1600] permutation - Restructure into a shared inlined Round helper with shared lane-index vectors for Theta/Chi and pre-broadcast round constants (removes the per-round scalar broadcast) - Start Pi permute chains from their own column, saving a register copy per row - Guard the state length with Debug.Assert, matching the scalar path, and access all lanes unchecked - Rename the portable path to KeccakF1600Scalar (internal) and add an AVX-512-vs-scalar equivalence test * Make KeccakF1600Avx512F internal The method skips bounds checks under a Debug.Assert length contract, so restrict callers to the assembly and friend test assemblies, matching KeccakF1600Scalar. * Drop dead lane masks and hoist the round-constant ref - Lanes 5-7 of the over-read row loads never reach result lanes 0-4 (Theta/Rho/Pi/Chi map lanes 0-4 only from lanes 0-4) and the stores overwrite them, so the vpandq masking was dead - Pass the pre-broadcast round constant into Round via GetArrayDataReference, eliminating the round + 1 bounds check Tier-1 body shrinks 1562 -> 1528 bytes with no range checks left. * Assert the round-constant table matches ROUNDS Unsafe.Add dropped the latent bounds check tying RoundConstantVec to the loop, so assert the length and evenness next to the state assert. Bounding the loop by RoundConstantVec.Length instead was measured with JitAsm and rejected: the non-const bound stops the JIT hoisting the 28 vector constants, re-loading all of them from rodata every iteration (loop grows 143 -> 165 instructions per 2 rounds). * Clean up the outer Keccak methods - Take the Keccak-f[1600] state as `ref ulong` in the internal AVX-512 and scalar permutations; the KeccakF dispatch extracts the ref once, so call sites stop materializing a Span for every permutation call - Replace the stackalloc state in ComputeHash with an [InlineArray] struct local: localloc pinned the method at Tier0-FullOpts (no tiering, dynamic PGO, or inlining) and added GS-cookie and stack-probe overhead per call; as a struct local it tiers to Tier1 and inlines into ValueKeccak.Compute, folding the round size, padding index, and output copy for 32-byte outputs - Add [SkipLocalsInit] to the hot wrappers (ValueKeccak.Compute, InternalCompute, ComputeHash, Update, UpdateFinalTo, GenerateValueHash), removing frame zero-init that Unsafe.SkipInit alone does not skip - Drop `checked` from GetRoundSize: the entry guard bounds the output length to [1, 200], so the arithmetic cannot overflow Verified with DOTNET_JitDisasm at Tier-1: ComputeHash now reaches Tier1 (was permanently Tier0-FullOpts) with an rsp frame, no GS cookie check, no stack probe, and no overflow branches; permutation call sites pass the state pointer directly with no span stores. Wall-clock is unchanged within noise for 20-532 byte inputs (the permutation dominates at ~240 ns per block). Tests: KeccakTests 1051/1051 on x64 with AVX-512, including the AVX-512-vs-scalar equivalence test; Nethermind.Core also builds with -p:EnableZkEvm=true against the unchanged zkevm KeccakF partial. * Balance AVX-512 Keccak Pi merges Arrange each Pi gather as two independent source-pair merges followed by a pair merge and the final c4 insertion. This keeps the instruction count unchanged while shortening the serial vpermt2q dependency chain from four levels to three. Restrict the existing benchmark inputs to the production-relevant 20, 32, and 64 byte sizes. BenchmarkDotNet --quick, ValueKeccak, AMD Ryzen 9 9950X, Windows 11, .NET 10.0.11: - 20 B: 249.1 ns -> 234.1 ns (-6.0%) - 32 B: 248.8 ns -> 233.3 ns (-6.2%) - 64 B: 262.5 ns -> 238.5 ns (-9.1%) A separate scalar run with COMPlus_EnableAVX512=0 measured 176.4, 175.2, and 174.1 ns respectively. FullOpts JitAsm keeps 40 two-source permutes per two rounds and reduces generated code from 1,535 to 1,487 bytes. Validated against the scalar permutation oracle and with the Nethermind.Core.Test suite (1,051 passed). * Batch keyed-nonce slot hashes with AVX-512 Add an internal eight-way Keccak-256 kernel for consecutive 64-byte inputs. It transposes independent states across ZMM lanes so Rho/Pi becomes register renaming and all eight permutations share each vector instruction. Use it for EIP-8250 nonce sets of 8-16 keys; smaller sets, oversized primitive calls, and machines without AVX-512 retain the existing per-key path. The kernel uses one outer input pin, scalar-memory round-constant broadcasts, immediate rotates, and no allocations. FullOpts JitAsm emits no calls or variable rotates, keeps the round state in registers, uses a 696-byte frame, and totals 2,105 bytes. BenchmarkDotNet --quick, AMD Ryzen 9 9950X, Windows 11, .NET 10.0.11: - Eight raw 64-byte hashes: 1,916.8 ns -> 183.3 ns (10.45x) - 8 keyed-nonce slot indices: 2,012.1 ns -> 241.9 ns (8.32x) - 16 keyed-nonce slot indices: 3,986.2 ns -> 422.9 ns (9.43x) - All paths allocate 0 B The caller benchmarks include padded sender/key preimage construction and hash-to-UInt256 conversion. Correctness is checked against independent scalar hashes and individual StorageSlot calculations. Nethermind.Core.Test passed 1,052/1,052; KeyedNonceManagerTests passed 33/33 both with AVX-512 enabled and with COMPlus_EnableAVX512=0. * Use lane-per-register AVX-512VL for Keccak Replace the row-oriented AVX-512F permutation with a lane-per-register AVX-512VL kernel, retain a fused path for 20, 32, and 64-byte Keccak-256 inputs, and fall back to scalar when VL is unavailable. BenchmarkDotNet on Ryzen 9 9950X, .NET 10.0.11: Direct Keccak-f[1600]: AVX-512F 516.9 ns; scalar 194.0 ns; AVX-512VL 164.3 ns. VL is 68.2% faster than the old AVX-512F implementation and 15.3% faster than scalar. ValueKeccak generic VL vs fused VL: 20 bytes 171.1 vs 161.6 ns (-5.5%); 32 bytes 169.1 vs 162.2 ns (-4.1%); 64 bytes 171.9 vs 162.3 ns (-5.6%). JIT disassembly shows all 25 state lanes remain in registers through the VL round loop with no algorithmic spills. Verified by all 1,055 Keccak tests, including 64 full-state comparisons against the scalar permutation and independent known vectors for the specialized input sizes. * Tidy the AVX-512 Keccak kernels for review One statement per line in the rho-pi cycle, named vpternlog immediates (Xor3, Chi), spec-step comments (theta, rho+pi, chi, iota), and FIPS 202 padding notes. Rename Gather64 to GatherLane and document the batch helpers. Use explicit input sizes in the ComputeHash fast-path check. Add the zero-key debug assert to the batched consume loop. The in-place rho-pi swap chain and ChiRow helpers are kept as the data flow: rewriting them with a fresh local per lane made the JIT spill and measured ~2.6x slower, so only the formatting and names changed. * Batch full trie branch hashes with AVX-512VL Full branch nodes whose 16 children are hashes have a fixed 532-byte RLP. Defer those sibling hashes during serial branch encoding and process them in pairs with a lane-per-message AVX-512VL Keccak kernel. A 16-bit child mask avoids carrying a reference buffer through the recursive traversal; an odd final candidate keeps the scalar path. Benchmarked on an AMD Ryzen 9 9950X. The focused 532-byte benchmark improved two hashes from 1485.6 ns scalar to 724.5 ns batched (-51.2%), and an eight-full-child synthetic trie improved from 8517.4 ns to 6430.6 ns (-24.5%). A 65,536-entry trie workload used the median of three independent process runs; each run took the median of nine batches of 16 pre-built updates. Default-parallel account hashing for 300 updates improved from 184.3 us to 175.6 us (-4.7%). Storage hashing improved from 7.8 to 7.6 us for 2 updates (-2.6%), 13.6 to 12.7 us for 4 (-6.6%), 23.4 to 20.4 us for 8 (-12.8%), 43.6 to 39.2 us for 16 (-10.1%), 148.9 to 134.1 us for 64 (-9.9%), and 170.4 to 160.1 us for 300 parallel updates (-6.0%). JitAsm confirms the x2 permutation reaches Tier 1 normally and keeps all 25 state lanes in registers, with only the required callee-saved XMM save/restore traffic. Tests: 1056 Keccak tests passed; the full trie suite passed 471 with 7 existing skips; TrieNodeTests passed 75 with AVX-512 disabled and the intrinsic-specific test skipped. * Fix AVX-512 ZK EVM build and lint * Address AVX-512 review hardening Reuse prepared branch RLP values, fail loudly before a fixed-size pair read if their length changes, document the keyed-nonce zero-key invariant, and broaden the dispatch and trie batch coverage. Benchmark sanity check on Ryzen 9 9950X / .NET 10.0.11: the dense eight-eligible-child trie measured 6,489.7 ns batched versus 7,181.7 ns scalar. Three-process medians for the 65,536-entry workload were 150.4 us for 300 parallel account updates and 6.7/11.4/18.2/35.2/116.8/131.0 us for 2/4/8/16/64/300 storage updates, with no observed regression against the PR measurements. * perf(rpc): apply eth_call state overrides without merkleizing them (#12904) * perf(rpc): apply eth_call state overrides without merkleizing them Every overridden eth_call paid a full merkleization of its overrides - a trie path load and rehash per touched account and slot - only to produce a state root nothing on the call path reads: the call executes against the scope's world state, not through the root. The bridge's own overridable-env adapter now applies the override to that world state and commits the journal with commitRoots: false, which makes it visible to block-level reads without touching the trie. The header keeps its original state root, so the sender nonce is read from the executing world state rather than through a root-resolved IStateReader - including in FillAddressesToOptimize, where a nonce override would otherwise resolve the wrong CREATE address. Tracers, receipt regeneration and block re-processing keep going through IOverridableEnv.BuildAndOverride unchanged, so no public interface moves. The one consumer of the committed root here is the EIP-7610 CREATE collision check: IsStorageEmpty consulted only the committed storage root. It now also reports non-empty when an uncommitted block-level write leaves a slot at a non-zero value. The uncommitted check must precede the clear marker, because a full `state` override clears the storage before writing and the marker survives until the trie flush; it is gated on the per-contract write flag so reads, which can never hide from the committed root, leave the block-processing path a single bool test. Measured on the private 497-record eth_call corpus, 100 rps, order-balanced pairs on both architectures: avg -1.9% (x64) and -2.4% (arm64), with 497/497 response parity against master in both sweep orders. * address review: block-level EIP-7610 test, drop dead StateReader, correct docs - Add Eip1014Tests.Test_existing_account_with_unmerkleized_storage_is_not_empty: block processing merkleizes once per block, so a slot written by an earlier transaction is invisible to the storage root; the collision check has to consult block-level changes. Fails without the PersistentStorageProvider change. - Drop BlockProcessingComponents.StateReader: every consumer now reads the world state, so the member (and its per-request DI resolution) is unused. - Correct the IsStorageEmpty comment - the uncommitted writes it now sees are not only state overrides but every pre-merkleization block write - and note that the executing transaction's own journal is still not visible here. - Correct the adapter remarks for the blockOverride case, and document on IShareableOverridableEnvSource<T> that an implementation may leave the override unmerkleized. --------- Co-authored-by: Ben {chmark} Adams <thundercat@illyriad.co.uk> Co-authored-by: Kamil Chodoła <43241881+kamilchodola@users.noreply.github.com> Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * Assert the frame-tx mempool bookkeeping invariants in DEBUG (#12885) * test(txpool): assert the frame-tx mempool bookkeeping invariants in DEBUG Nothing outside this repository's own tests exercises EIP-8141 mempool admission, so drift in the bookkeeping the pool maintains incrementally goes unnoticed. Add DEBUG-only assertions over the two ledgers, and fix a verdict-id collision they surfaced. - The payer-exposure ledger must equal what the pool holds. A reservation taken at admission has to be released on every removal path; a leak rejects the payer's later transactions until restart. - _expiringFrameTxCount must equal the number of pooled transactions carrying a deadline, and never go negative. A negative count arms the expiry sweep's zero-count fast path for good, after which nothing expires again. - AcceptTxResult ids must be unique. KeyedNonceUnmet and FrameTxMissingSidecar both held 24, and equality is by id alone, so the two verdicts compared equal and any test asserting on either passed for both. FrameTxMissingSidecar moves to 27. The pool walk runs per head, not per operation: it is O(pool size) and insert and removal are hot. Admission is excluded by the head write lock, but removal from block production is not, so a mutation observed across the walk retries rather than asserting on the race. Release is unaffected: every pre-existing method compiles to byte-identical IL, and the three conditional methods are empty. * test(txpool): let the bookkeeping check meet a live payer reservation Instrumenting the check showed it ran 86 times across the suite and never once saw a non-empty exposure ledger: every frame transaction was already gone by the time the head pass reached it, so the ledger was only ever verified empty against empty. Surviving a head first puts a live reservation in front of it. * refactor(txpool): price the payer reservation in one place Merging the check into a later branch fired it: that branch reprices the reservation through a shared helper on both the reserve and release sides, while the check still carried its own copy of the old formula. Reading the release side's own pricing instead means the check follows whatever the pool releases and cannot drift from it again. * refactor(txpool): keep the unique-id check in the test alone The DEBUG type initialiser duplicated AcceptTxResultTests, aborted the process on failure, and cost the type its beforefieldinit. Also bump the mutation counter before the bookkeeping either side of it moves, so the check cannot read a half-applied removal as drift. * test(txpool): reach the bookkeeping check from the persistent blob pool The exposure invariant only sees a live reservation when a resolved payer's transaction is still pooled at a head change, and no blob fixture produced that: the persistent pool's own test removes the transaction without ever raising a head. Add the blob-pool counterpart of the expiry test, so the walk meets a light record rather than the transaction admission priced. Also assert no filter is registered twice. A duplicate runs its side effect twice — a second payer reservation, a double-counted rejection — and no filter fixture can see it, since each exercises one filter in isolation. Trim the comments on the checks to the failure each one guards. * refactor(txpool): drop the filter-registration check and record what the walk cannot see The filter check is unrelated to the frame-tx bookkeeping this change is about and can only restate what reading the two literal arrays already shows. In its place, name the ordering the retry rests on and the payer the walk is blind to. * test(txpool): share the expiry-eviction path between the two pools The standard-pool and blob-pool cases ran the same six steps, differing only in the transaction factory and the handling options. * fix(txpool): judge the expiring-count guard on the decrement's own result --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * Pin the recent-root reference intrinsic gas and correct its docs EIP-8272 prices recent_root_reference_intrinsic_gas at ACCESS_LIST_ADDRESS_COST and ACCESS_LIST_STORAGE_KEY_COST, which EIP-8038 defines as cold access less the warm charge. The charge is therefore a pre-warm and the validation reads that follow are uncharged, which the two docstrings stated the opposite of. The existing test re-derived its expectation from the same two constants, so it stayed green across the redefinition. Pin literal totals for both fork states so a reprice of the access-list entry costs cannot move this charge silently. * fix(evm): dispatch a precompile a VERIFY frame targets (#12922) * feat(frames): two-dimensional gas limits per EIP-8141 (devnet7) (#12942) * feat(frames): two-dimensional gas limits per EIP-8141 (devnet7) Split each frame's single gas_limit into limits = [execution, state] end to end, and the frame receipt's gas_used into [execution, state], per EIP-8141 (ethereum/EIPs#12062), which adopts the EIP-8037 two-dimensional gas model. Core: - TxFrame carries ExecutionGasLimit and StateGasLimit; the combined GasLimit is their sum. TxFrameReceipt carries ExecutionGasUsed and StateGasUsed. - Per-frame independent gas pools: a state charge exceeding limits.state halts the frame and is never paid from execution gas. - State-gas attribution and cross-frame refills are journaled at call, frame and atomic-batch boundaries; a refill reduces the owning frame's receipt. - Static validity: per-dimension overflow, expiry frame limits.state == 0, value_transfer_cost (EIP-2780 TX_VALUE_COST) folded into the intrinsic, and the EIP-7825 cap enforced on intrinsic + sum(limits.execution). - The Bogota devnet fork now derives from Amsterdam so EIP-8037 is active under frame transactions. Wire and settlement: - RLP: frame limits = [execution, state]; receipt gas_used = [execution, state]. - Settlement binds the calldata floor to the execution dimension only; state gas is charged on top. Block inclusion, mempool admission and block production check the execution and state dimensions separately. Introspection: - FRAMEPARAM exposes limits.state and per-dimension gas_used. TXPARAM state_gas_left is assigned 0x11 to avoid the EIP-8250 legacy-nonce 0x0C collision (a spec update will follow separately). Payment approval that must create an absent sender charges NEW_ACCOUNT from the approving frame's state budget, atomically before the nonce increment; a frame that cannot afford it halts with no approval effects. * feat(frames): give EIP-8141 its canonical TXPARAM/opcode indices EIP-8141 now owns its spec-canonical introspection indices; the sibling extensions move off the collisions so a pure-8141 fork matches the spec. - TXPARAM 0x0C = STATE_GAS_LEFT (8141), was 0x11 - opcode 0xb5 = SIGDATACOPY (8141) as a distinct opcode; SIGPARAM (0xb4) is read-only again (no copy overload) - EIP-8250 legacy nonce TXPARAM 0x0C -> 0x11 (gated on 8250) - EIP-8272 RECENTROOTREFLOAD opcode 0xb5 -> 0xb6 - wire: fees is a nested [max_priority_fee, max_fee, max_fee_per_blob_gas] list; intrinsic FRAME_TX_INTRINSIC_COST 15000 -> 12000 Verified against execution-spec fixtures (EELS #3396) at fork Bogota: 201/201 pass. Unit suites green (Evm/Core/TxPool/Blockchain frame tests). The matching EIP-8250 / EIP-8272 index moves ship as separate spec PRs. * Address review: 2D producer headroom, cast clamps, EIP-2780 gate, warm/cold cite; track post-refund block gas pending EIP-8141 spec fix * Address review round 2: Low findings + wurdum + Marchhill nits Reviewer findings addressed in this commit: claude-bot (Low): - Separate MAX_VERIFY_STATE_GAS rejection (AcceptTxResult, error message, metric) so a state-bound mempool reject is distinguishable from an execution-bound one. - TxParam 0x11 activation guard already present (gated on TEip8250). wurdum: - TxValidator: split overflow (FrameGasOverflow) from over-cap (FrameExecutionGasExceedsCap now carries the reservation and cap), add the IsEip8037Enabled gate mirroring IntrinsicGasTxValidator. - EvmInstructions.Storage: gate RecordStateChargeOwner on TEip8037.IsActive, matching the refill side; otherwise the ownership map/journal grow on a spec with 8141 but not 8037. - FrameTxContext: single dictionary probe on the SSTORE path (GetValueRefOrAddDefault / Remove(key, out)); early-exit + AsSpan + ref read in RestoreStateGasJournal. - Remove the now-unused TotalStateGasCorrection; accumulate the per-frame correction total in the existing settlement loop instead of a second pass. Marchhill (nits): trim member docs to summary + one-line cite per repo style. Deliberately kept (explained in review threads): the three-decoder gas_used wire framing and the receipt-format decoder are consensus-wire shapes, not mechanically deduped under pressure; the three block-gas tests are not identical (success+nonzero vs two halt+zero paths); the hardcoded operands at EvmInstructions.FrameTx 221/279 are pre-existing. * Strip added inline // comments (rationale lives in the PR/threads) * Extract block-production gas reservation helper (review: Marchhill) * refactor(evm): drop the unused EIP-8272 native recent-root write path (#12940) * fix(state): omit no-op nonce change from block access list (#12830) * fix(state): omit no-op nonce change from block access list EIP-7928 records nonce changes from actual state transitions, so a SetNonce that writes the account's current nonce must not appear as a nonce_change. TracedAccessWorldState.SetNonce now records only when the value differs, matching the existing no-op guard for code changes. * test(state): pin the no-op nonce BAL shape and drop dependent assertions The unchanged-nonce case asserted only that no nonce change was recorded, which held both for an absent account entry and an empty one; assert the account is absent to pin the intended BAL shape. Split the recorded/not-recorded branches so the changed case no longer dereferences a possibly-null entry inside a multiple-assert scope, keeping a regression diagnostic instead of throwing. * Reject legacy scalar-nonce frame transaction after EIP-8250 (#12829) * fix(consensus): reject legacy scalar-nonce frame transaction after EIP-8250 * fix(consensus): evict a pre-fork scalar-nonce frame tx at EIP-8250 activation The EIP-8250 nonce gate lived only in the type-dispatched TxValidator, so a scalar-nonce frame tx admitted before the fork stayed in the pool at activation and could enter the first post-fork payload, which peers reject. Run FrameTxNonceKeysTxValidator in HeadTxValidator too, so txpool head updates and the next-block producer filter evict it; it guards on the frame type because HeadTxValidator is not type-dispatched. Adds head-revalidation regression tests plus the two remaining post-fork gate cases. * test(txpool): use the account-domain nonce key after EIP-8250 The payer-exposure keyed-domain test represented the account domain with a legacy scalar nonce, which this branch now rejects post-EIP-8250. Express it as nonce key [0], the canonical account-nonce domain, keeping the intent. * feat(evm): add EIP-7906 TXTRACE/TXDIFF/EVENTDATACOPY opcodes (#12759) * feat(evm): add EIP-7906 TXTRACE/TXDIFF/EVENTDATACOPY opcodes Implements the three EIP-7906 transaction-assertion opcodes on top of the already-merged POST_TX frame mode (#12661). All three are valid only inside a POST_TX frame and exceptional-halt in any other context. - TXTRACE (0xb5): enumerate the transaction's state diff and events by index - TXDIFF (0xb6): keyed per-account diff access, EIP-2929 warm/cold priced - EVENTDATACOPY (0xb7): copy a log's non-indexed data (CALLDATACOPY semantics) The state diff is read from the in-flight BAL slice, which already collapses intermediate writes and captures the transaction-prestate ("before") values; events come from the shared frame-transaction log buffer. Registered under IsEip7906Enabled. TXTRACE_GAS_COST is a documented placeholder pending the spec. * refactor(evm): route EIP-7906 opcodes through per-category helpers Keep the param switch as a thin router and move each category (balance / storage / deployment / event; slot / account / address-view) into a focused helper, mirroring how FrameParam delegates to FrameStatus. * style: fix whitespace formatting * fix(evm): address review — enforce reserved-zero inputs, O(1) TXDIFF views, cache pre-tx code hash - Guard the spec's 'must be 0' in2/in3 operands as an exceptional halt (documented interpretation) and cover it with a test - Precompute per-address slot runs and event indices so TXDIFF 0x06-0x09 are O(1) instead of scanning the whole diff per call - Memoize the pre-tx code hash so repeated TXDIFF(0x04) calls don't re-keccak the code - Filter to changed accounts before sorting the diff view - Document the BAL-recording-path dependency (RPC re-execution halts), the TXDIFF live-read BAL interaction, and the nonce-flag 'touched vs net' divergence * refactor(evm): name EIP-7906 files by feature, drop unused using Rename EvmInstructions.Eip7906.cs -> EvmInstructions.TxAssertions.cs and Eip7906DiffView -> TransactionDiffView, matching the feature-named convention of the sibling opcode files (FrameTx, Storage, CodeCopy...). Drop the now-unused Nethermind.Core.Specs using (IDE0005). * style: trim EIP-7906 opcode comments to essentials * fix(eip-7906): exclude EIP-7702 designators from contracts_deployed The spec enumerates an address in contracts_deployed only when its code hash moves from the empty-code hash to a non-empty hash that is not an EIP-7702 delegation designator, so authorising a fresh EOA no longer surfaces as a deployed contract. Add TransactionDiffView tests for the classification and diff filtering, a TXDIFF reserved-operand (in3 must be 0) case, and a test pinning that a TXDIFF live read is recorded in the EIP-7928 block access list, which the spec now requires. Correct the stale comments that called that recording unspecified and the nonce change flag never-nulled. * test(eip-7906): cover the pre-tx code hash and the parallel BAL read path TXDIFF 0x04/0x05 had no coverage, so neither the empty-code-hash result for an undeployed contract nor the memoization that bounds the hashing work was pinned. The traced-processor helper also only ever built the sequential world state, leaving the read path a validating node takes untested. Also give TXDIFF 0x06 the same cached slot-run count 0x07 indexes into, so the per-address slot count has one source, and name the count-push helper for what it does. * test(eip-7906): make the memo assertion observable and widen the frame gate The second GetPreTxCodeHash call returned the same value with or without the cache, so deleting the memo left the test green. Clearing the source the hash derives from between the calls means only a memoized lookup still returns it — verified by removing the memo, which now fails. The out-of-frame gate was also only covered for TXTRACE, and the null frame-context branch not at all, though the opcodes are in the jump table for every transaction once the fork is on. Both branches now run for all three opcodes. * chore(eip-7906): trim test comments to what the test names do not say * style: drop unused usings flagged by the code-style build * feat(evm): read the EIP-7906 transaction diff under simulation The assertion opcodes source their diff from the in-flight EIP-7928 slice, which only block processing was recording. Under eth_call, eth_estimateGas and eth_createAccessList a POST_TX assertion therefore halted where real execution succeeds, so a wallet simulating a valid frame transaction saw a spurious failure. The recorder is a plain world-state decorator with nothing block-specific about it, so wire it into the read-only and overridable simulation scopes. It records only while a slice is installed, and the frame-tx processor installs one per transaction that carries a POST_TX frame, so an ordinary eth_call keeps both the cost and the semantics it has today. Decorating at scope construction rather than mid-transaction is what keeps the code repository on the same state, without which contract deployments would go unrecorded and drop out of the diff. eth_simulateV1, debug_trace* and trace_* already ran through the block processor and its block access list manager, so they were unaffected. * chore(eip-7906): trim comments that restate the code * fix(evm): only carry the EIP-7906 diff recorder where a diff is recorded Review follow-ups on the simulation wiring. The decorator was added to both simulation env factories unconditionally, so every chain paid a permanent world-state layer for a fork it may never schedule - including mempool admission and the parallel block-access-list parent readers, and stacked idle beneath the recorder that eth_simulateV1 and the tracers already bring. It is now installed only when the final spec both enables EIP-7906 and records block access lists. The runtime install mirrors the same EIP-7928 condition, which matters in the other direction: on a chain scheduling EIP-7906 without it, blocks halt, so a simulation that quietly succeeded would hand a wallet a green result for a transaction no block can include. Also: swapping the slice now drops the single-slot read cache, which points into the outgoing slice; the opcode doc summaries carry the shifted bytes; and tests pin the guard that keeps a per-transaction install from displacing the block's own slice, plus the scope-shares-one-recorder property the wiring depends on. * chore(eip-7906): cut the commentary back to the why * fix(eip-7906): give the simulation paths a diff recorder and re-check frame shape Four review findings, all on the entry points that reach the assertion opcodes without a block-processing recorder or a validator: - proof_call resolved an undecorated WitnessGeneratingWorldState, so no IBlockAccessListSource was in the stack and the first assertion opcode halted with BadInstruction while eth_call succeeded. Decorate IWorldState the way the other simulation factories do; block witness generation keeps the undecorated state, since it brings its own recorder. - The processor's frame loop re-checks the rules that unvalidated entry points would otherwise skip, but not the trailing-POST_TX and value rules the cached diff view depends on. A simulated [POST_TX, DEFAULT, POST_TX] would read a diff predating the middle frame. - TXDIFF 0x01 read live storage without reporting it, so debug_traceCall showed gas charged against no storage access. - EVENTDATACOPY repeated RETURNDATACOPY's copy loop; both halt on an out-of-range read rather than zero-extending it, so they now share one core. Also release the cached diff view during teardown: the VM keeps its last TxExecutionContext and RPC processors are pooled, so the view's diff and log payload stayed rooted while idle. * test(eip-7906): cover the prestate branches and default the new BAL source member The balance, deployment and payer TXTRACE branches and TXDIFF 0x02/0x04/0x05 had no coverage, so the PreTxBalance and PreTxCode captures they read were only unit-tested in isolation. Drive them through the handlers, including the two cases that lose a capture if it is not held: a net-zero move, which collapses the balance change and must fall back to the live value, and a change reverted by an inner call, which must keep the capture across the rollback. SetGeneratingBlockAccessList also gets a default implementation. It was added as an abstract member of a public interface, so a source built against the single-property contract had no implementation for the new slot and could fail at type load. The default keeps such a source reporting no slice, which callers already read as offering no diff. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * Integrate EIP-7805 (FOCIL) into the frame-transaction branch (#12977) * fix(eip8141): sweep expired frame txs from the persistent broadcast pool (#12947) * test(frames): pin the block state gas a discarded POST_TX body gives back (#12934) * ci: add the EIP-8141 frame-fixture nethtest lane (#12995) * fix(frames): decode pre-2D scalar gas_used in stored frame-tx receipts (#12957) * fix(frames): decode pre-2D scalar gas_used in stored frame-tx receipts A frame-tx receipt persisted under eip8141-frame-txs-devnet7 stored gas_used as a scalar. After PR #12942 changed it to an [execution, state] list, a node reading its own receipts DB would throw RlpException (eth_getTransactionReceipt /eth_getLogs 500s) with no fallback. The two storage decoders (ReceiptStorageDecoder, CompactReceiptStorageDecoder) now detect the shape at gas_used via a shared FrameReceiptGasRlp.DecodeGasUsed: a sequence decodes as [execution, state]; a scalar decodes as execution with state = 0. The scalar path preserves the per-frame total but is not wire-faithful when re-encoded for GetReceipts; that trade-off is documented on the helper's remarks. The network ReceiptMessageDecoder is intentionally left strict list-only. Regression fixtures are golden blobs produced by the base-branch encoder (c265e33482): a standalone pre-2D receipt, and an array [legacy, pre-2D frame, legacy] exercised through both full decode and DecodeStructRef iteration. * docs(frames): trim FrameReceiptGasRlp remarks * Charge the EIP-8141 frame-entry account access before dispatch (#12997) * fix(rpc): keep frame receipt fields when a receipt is read back from JSON (#12923) * fix(rpc): keep frame receipt fields when a receipt is read back from JSON ReceiptForRpc surfaces Payer and FrameReceipts but ToReceipt() dropped both, so a frame transaction receipt deserialized through TxReceiptConverter (debug_insertReceipts) came back without them. * fix(rpc): derive frame receipt logs and status from the frames ToReceipt took Logs and StatusCode from the top-level JSON fields while FrameReceipts came from the payload, so a debug_insertReceipts payload carrying frames but contradicting top-level fields broke the union TxReceipt.FrameReceipts documents, leaving eth_getLogs and the bloom disagreeing with the frame receipts on the same stored receipt. Derive both from the frames as the wire decoder does, but only when the payload actually carries frames — an empty set aggregates to success and would otherwise stamp that onto a receipt the caller marked failed. Annotate FrameReceiptForRpc.Logs nullable to match what the deserializer can produce, and cover the JSON layer with a serializer round trip. * fix(rpc): carry the frame receipt fields through TxReceiptConverter Write emitted neither payer nor frameReceipts, so a receipt serialized by the registered converter and read back through Read lost both, leaving the DTO fix to help only debug_insertReceipts. Emitted under the frame type so every other receipt keeps its existing shape. Logs is also nullable now: Write emits "logs": null for an empty log set, which the deserializer honours, so ToReceipt threw on the converter's own output. * fix(rpc): derive a frame receipt's bloom from its frames too Deriving Logs from the frames while keeping the caller's LogsBloom only moved the contradiction from one half of the pair to the other. The EIP-8141 wire receipt carries no bloom at all, so DecodeFrameTxReceipt leaves TxReceipt to derive it from Logs; clearing it here does the same. * fix(rpc): reject malformed receipt payloads instead of failing internally ToReceipt projected the caller-controlled arrays with LINQ, so a debug_insertReceipts payload carrying "frameReceipts": [null] or "logs": [null] dereferenced null and JsonRpcService answered -32603 Internal error. Bind both with indexed loops that reject null entries with JsonException, which maps to invalid params, and enforce EIP-8141's MAX_FRAMES bound on the way in. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * fix(opt): stop the Optimism receipts tracer from bypassing shared receipt population (#12925) * fix(opt): stop the Optimism receipts tracer from bypassing shared receipt population The Optimism tracer overrode BuildReceipt outright, so it never ran the shared population: a frame transaction got no payer, no per-frame receipts and no aggregated status, and the tracer's per-transaction frame state was never cleared, leaving a later failing transaction free to inherit the previous frame transaction's logs. Chain-specific receipt types now come from a CreateReceipt factory the base fills in, so the two cannot drift apart again. * fix(opt): make CreateReceipt the only receipt extension point BuildReceipt stayed overridable after the Optimism tracer stopped overriding it, so nothing but convention kept a future override from bypassing the shared population again. No in-tree tracer overrides it, so seal it: the extension surface is now exactly CreateReceipt, and its remark states that anything BuildReceipt assigns is overwritten. Cover the deposit-field population that moved into CreateReceipt. It runs on every deposit today but no test reached it through the tracer - the existing ones hand-build receipts with DepositNonce already set - so nothing pinned the nonce read happening after cumulative gas tracking and before the world state moves on. * test(opt): pin the receipt fields the tracer refactor recovered EffectiveGasPrice, ExecutionGasUsed and the CreatesTopLevelContract-keyed ContractAddress were asserted nowhere, so an Optimism-side shortcut that dropped them again would leave the suite green. Dispose each tracer. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * docs(eip8141): correct the pre-activation gap list (#12994) Validation-prefix simulation is listed as missing but has landed, and the list omits the paymaster rules, which are absent from this branch entirely. Audited each item against EIP-8141 and the branch: the failed-APPROVE rule is enforced (a prefix that sets no payer is rejected) but unbounded, the per-payer exposure bound reserves the frame-gas sum rather than max_cost, nothing revalidates a pending prefix on a new head, and no canonical paymaster instance can be recognised because the EIP pins no runtime code. Reorg re-admission is dropped from the list: the cap is on the pending set, so re-admitting one transaction per sponsor is what the spec asks for. Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * chore(frames): drop unused frame transaction helpers (#12927) - ConcatFrameLogs in the frame transaction processor: only the declaration survived, the live call site uses TxFrameReceipt.ConcatLogs - CountingAdapter.ExecutedPerAttempt: written, never read - TxFrameDecoder.EncodeArray's rlpBehaviors parameter: the sole caller passes the default and Encode ignores it Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * EIP-8141: stop re-verifying frame signatures inside the validation-prefix simulator (#12903) * perf(txpool): stop re-verifying frame signatures inside the prefix simulator Every opaque EIP-8141 frame transaction had its frame_signatures verified twice on the pool path: once in FrameTxSignatureFilter, then again inside FrameTxPrefixSimulator's lock, which serialises all admission simulations behind one resettable world state. ExecutionOptions.FrameSignaturesPreValidated lets the caller assert the check has already run. Only the validation-prefix path reads it, and the pool's simulation filter is its only setter — every other entry point into the processor verifies unconditionally, so a stray flag there is inert. FrameTxSignatureDuplicationMeasurement sizes what this removes from the lock: ~25 us per SECP256K1 entry and ~40 us per P256 entry, against 1.4 us for a trivial prefix, 22 us for a 5k-gas one and 207 us for a 50k-gas one. At the MAX_VERIFY_GAS bound on entries it is 2.7 ms. * refactor(txpool): narrow the pre-validated flag's docs and drop the measurement harness State that the flag is read only for signature verification rather than being inert elsewhere -- options are compared by exact equality in places -- and that the filter and the simulator read the head separately. Remove the explicit measurement harness now its numbers are recorded, and keep the flag guard's scan out of build output. * refactor(txpool): carry frame-signature verification on the filtering state signaturesPreValidated: true was backed only by FrameTxSignatureFilter sitting earlier in _postHashFilters, and nothing pinned that order: swapping the two kept every test green while the prefix resolved a payer from an unverified signature and the exposure gate reserved that stranger's budget. The signature filter now records the fact on TxFilteringState and the simulation filter reads it, so a reorder degrades to re-verifying rather than trusting. * refactor(txpool): put the simulator's cancellation token last CA1068: the pre-validated flag was sandwiching the token, which also forced a filler Arg.Any<CancellationToken>() through every mock setup. * fix(txpool): keep the pre-validated signal writable only inside the pool IIncomingTxFilter is public and implemented outside Nethermind.TxPool, so any such filter could assert verification it never did. Also repoints the new test at the fork that still enables frame transactions after the Bogota split. --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * fix(optimism): guard the null log set when deriving user deposits (#13021) ReceiptForRpc.Logs became nullable in #12923, which turned the unguarded foreach in BuildUserDepositTransactions into CS8602 and broke the build for the whole solution, not just the docs build. The null is reachable rather than theoretical: these receipts are deserialized from an L1 node's eth_getBlockReceipts response, and TxReceiptConverter.Write emits "logs": null for a receipt with no logs. The sibling SystemConfigDeriver on the same derivation path already guards this way. Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> * test(frames): share the frame transaction builders and collapse duplicated fixtures (#12930) * test(eip8141): map the frame-transaction exceptions for engine tests (#13009) * perf(frames): cut repeated work on the frame receipt and RPC paths (#12929) * EIP-8141: price per-payer exposure with the shared max_cost helper (#12776) * docs: EIP-8141 public-mempool rules design note * EIP-8141: per-payer mempool exposure accounting for frame transactions Bounds each resolved frame-tx payer's summed pending maximum cost to the payer's balance at admission, per the merged public-mempool rules (ethereum/EIPs#12007). Reuses the DelegationCache event-counter precedent: a PayerExposureCache maintained from the pool insert/remove events, read by a new FrameTxPayerExposureFilter placed after the payer-resolution filter. Enforcement is scoped to natively-resolved payers (Transaction.PayerAddress); unresolved and non-frame txs pass through. Canonical/non-canonical paymaster reservation, replacement payer-switch atomicity, eviction ordering, dependency-indexed revalidation, and the MAX_VERIFY_GAS bound remain documented follow-ups (see MEMPOOL-RULES-DESIGN.md). * EIP-8141: close exposure-bound gaps from mempool-rules review - Reject blob fields on frame txs at validation (NonBlobFieldsTxValidator in the frame composite) so a blob-carrying frame tx can no longer bypass the gas-only per-payer exposure bound while frame-blob support is off. - Make the reservation atomic: PayerExposureCache.TryReserve compare-and-sets the check and reserve in the filter, closing the check-then-act race where concurrent same-payer submissions could all pass a stale total. Release moves to the Removed event plus the non-insert (ReplacementNotAllowed) path. - Saturate the reservation counter and drop per-call closures in the cache. - Add a TxPool-level accounting test (two frame txs sharing a sponsor payer, second rejected, released on removal) and parameterize the filter tests. - Correct the max-cost wording (gas-only for frame txs) in the filter comment and MEMPOOL-RULES-DESIGN.md. * EIP-8141: reject only blob-carrying frame txs, fix exposure leak Replace the presence-based NonBlobFieldsTxValidator in the frame-tx composite with a value check in FrameTxValidation: the decoder always populates max_fee_per_blob_gas and blob_versioned_hashes, so the presence check rejected every frame tx. Now only frame txs with a non-empty blob hash list are rejected; the no-hashes zero-fee shape is admitted. Release the per-payer exposure reservation from a finally in AddCore so a throw before insertion cannot leak it, and document the deliberate under-count for a persistent-broadcast-retained frame tx (no release hook on the broadcaster). TryReserve now returns the observed reservation so the trace reports the total the decision was made on. Remove the design note from the repo tree. * EIP-8141: relocate end-to-end payer-exposure test to simulation layer The per-payer exposure gate only fires for a resolved third-party payer. Native admission now resolves only self-payers, which the sender balance filter already bounds, so the shared-sponsor end-to-end case moves to the layer where simulation resolves the payer. The filter-level unit tests still prove the gate at this layer. * EIP-8141: share the validation-prefix grammar between pricing and payer resolution FrameTxPayerResolver restated the prefix-shape predicates that Core's FrameTxValidation already owns and the verify-gas admission filter prices against, and the two had drifted: Core recognised a leading deploy frame in the prefix, the resolver did not, so [deploy, self_verify] got recognised- prefix pricing yet never resolved natively. Expose the shape predicates from FrameTxValidation and consume them in the resolver, skipping an optional leading deploy frame like the expiry frame so pricing and the payer verdict classify a prefix the same way. * EIP-8141: collapse redundant only_verify branch in payer resolution Both the only_verify branch and the fall-through returned RequiresSimulation, so the branch was inert. Collapse to a single return, preserving behaviour. * EIP-8141: in-pool validation-prefix simulation for opaque frame transactions Admit the opaque frame-tx prefixes the native resolver defers (RequiresSimulation: deployed-code sender, code-carrying paymaster, unrecognized shape) by simulating their validation prefix in a bounded, read-only EVM, resolving the payer and enforcing the trace/opcode rules. The standard, natively-resolvable prefixes keep the EVM-free fast path: FrameTxSimulationFilter returns immediately for a payer already resolved by FrameTxPayerFilter, so the simulator is never consulted for them. - IFrameTxPrefixSimulator abstraction in Nethermind.TxPool (no reference to Consensus's processing env, which would cycle); implemented by FrameTxPrefixSimulator at the composition root wrapping IReadOnlyTxProcessingEnvFactory, injected optionally into the pool. - ExecutionOptions.FrameValidationPrefixOnly + a validation-prefix simulation path in the frame processor: halts once the payer is set, bounds cumulative work by MAX_VERIFY_GAS, always restores state. - FrameTxValidationTracer enforces the banned-opcode list, the GAS-before-call and TIMESTAMP-in-expiry-verifier caveats, and SLOAD-restricted-to-sender; violations reject the transaction. When no simulator is wired, RequiresSimulation stays deferred as in Phase 1. Deferred (design note §4): dependency-set-keyed caching, head-change re-simulation indexing, wall-clock cancellation, and a multi-simulation admission budget. * EIP-8141: enforce CALL*/EXTCODE* target rules in validation-prefix tracer Address review findings on the Phase 2 in-pool simulation: - High: FrameTxValidationTracer now rejects CALL*/EXTCODE* whose target is neither an existing contract nor a precompile, or that uses an EIP-7702 delegation (spec §Validation Trace Rules, L816/L853), exempting tx.sender. Previously these prefixes were silently admitted despite the doc claim. - TIMESTAMP exemption now checks the EXPIRY_VERIFIER code hash as well as the address (L788), via a new Eip8141Constants.ExpiryVerifierCodeHash. - GAS-before-call is validated by peeking the next opcode instead of a cross-frame mutable flag, so a trailing GAS is no longer missed or mis-attributed across frame boundaries. - The post-frame MAX_VERIFY_GAS bound is now reachable: a prefix frame capped to the remaining budget that then exhausts it is rejected as over-budget, distinct from a within-budget revert. - IFrameTxPrefixSimulator.Simulate takes a CancellationToken (honored at entry; OperationCanceledException propagates). Adds tests for the CALL* codeless-target rejection, the existing-contract allow case, and the over-budget rejection reason. * EIP-8141: reconcile opaque-payer simulation with updated payer resolution The payer resolver now takes the sender account and defers all only_verify|pay prefixes to simulation, so the simulation filter passes the sender account when re-resolving, and its tests drive the resolver through the same account state. Also stop attributing an explicit within-budget REVERT to the MAX_VERIFY_GAS cap: only a capped frame that ran out of gas is reported as over-budget. * EIP-8141: add end-to-end payer-exposure test at the simulation layer Relocated from the mempool-rules PR, where native admission no longer resolves a third-party payer. Here the opaque only_verify|pay sponsor prefix is resolved by validation-prefix simulation, so the exposure gate bounds the sponsor's summed pending cost to its balance end-to-end and releases the reservation on removal. * reconcile phase-2 simulation with configurable verify-gas bound Re-add the consensus MAX_VERIFY_GAS constant the prefix simulator needs; isolate the exposure test from the configurable ingress bound. * EIP-8141: price per-payer exposure with the shared max_cost helper * EIP-8141: keep blob-carrying frame transactions out of the pool * EIP-8141: correct the frame-tx blob validator comment * EIP-8141: name the deploy-frame decline and meter prefix simulation Reject a validation prefix containing a deploy frame with its own reason rather than falling through to "never set a payer", correct the docs that attributed the decline to the tracer's opcode bans, and add a rejection counter for the simulating filter. * EIP-8141: assert the gas-budget helper in the exposure fixtures Also reuse the shared blob-gas helper in TryCalculateMaxCost instead of re-deriving the blob count. * chore: drop unused usings from the frame-transaction measurement fixtures * EIP-8141: make the shared expiry-frame predicate self-guarding and meter the exposure gate Restore the value and data-length checks the resolver's private copy had, so reading the deadline out of a matching frame needs no separate precondition; add the sibling rejection counter; correct the exposure comment, which described a bound the branch no longer has. * EIP-8141: keep the exposure reserve and release symmetric on a zero cost A zero-cost reservation inserted an entry the matching release never reclaimed. Also read the payer's balance from the account the pipeline already cached when the payer is the sender, and pin the overflow and concurrency properties the reservation exists to provide. * EIP-8141: cover the payer reservation lifecycle through the pool A same-nonce replacement is the one shape that reaches the exposure gate at this layer, so it pins both the reservation taken through the filter chain and its release on the pool event. Also trim the gate comment's inventory of what is still missing. * EIP-8141: name the exposure rejection like its siblings and make the expiry read total Rename the admission result and its message to the FrameTx prefix the other frame-tx outcomes and metrics use, mark the under-reserved max cost as a deviation rather than a gap, restore the qualified payer-exposure entry to the gate inventory, and route TryGetExpiryDeadline through the self-guarding predicate so it can no longer throw on a malformed frame. * EIP-8141: stop a payerless frame transaction from disconnecting the peer AcceptTxResult compares by id, so returning Invalid for a structurally payerless prefix reached the flood controller's immediate-disconnect arm. Give it its own result, as the expired and verify-gas verdicts already have, and cover the refused-replacement release path. * chore: trim the frame-tx exposure comments to the non-obvious why * chore: trim the prefix-simulation comments to the non-obvious why * EIP-8141: make a leaked payer reservation observable Gauge the number of payers holding a reservation, tracked on the two dictionary transitions, so an idle pool reading non-zero is the leak itself. Also merge the duplicated remarks on the expiry predicate, derive the pricing assertion from its frames, and vary the refused replacement by a field the reservation is not computed from. * EIP-8141: publish the reservation gauge atomically and keep its pair symmetric Interlocked on the metric itself, so a racing transition cannot leave a stale count standing, and release now compare-and-sets like reserve so a double release touches nothing. Narrow the pay-frame predicate back to private, and vary the refused replacement by its target. * EIP-8141: cover the self-paying exposure path and make its counter atomic The fixture only ever built third-party payers, so the branch every real admission takes was untested. Also match the rejection counter to the gauge beside it, select the replacement's frame by shape rather than index, and extend the payerless summary to the rule it now states. * EIP-8141: pin the blob term in the payer exposure bound Nothing asserted that a blob-carrying frame tx reserves its blob leg, so the bound was gas-only by accident of every fixture leaving the hash list null. * chore: reattach the exposure fixture doc and guard its frame lookup * EIP-8141: pin the blob exposure term by magnitude and the deploy-prefix edges The blob case asserted only that the reserved amount grew, so a bound that dropped the GasPerBlob or blob-count factor stayed green; it now pins the inclusive boundary and the reserved total for one and two blobs. Adds the two unpinned edges of the widened validation-prefix grammar: a second deploy frame is not skipped (RequiresSimulation, matching the pricing grammar), and an expiry frame followed by a deploy frame still records the expiry dependency. * chore: sort the payer exposure cache usings * EIP-8141: widen the blob exposure arithmetic and regroup the fixture The blob term was computed in int, which wraps once the blob count times the blob fee exceeds 16383; it is now long, pinned by a six-blob case that fails on the int version. Tests and private helpers are no longer interleaved, so a new test cannot orphan a helper's doc comment again. * chore: trim the frame-transaction comments to the non-obvious why Also states the exposure deviation's floor honestly: the reserved amount is zero, not a fraction, when every frame gas limit is zero. * EIP-8141: trim frame-tx mempool comments * chore: keep the comment trim to code this change owns Reverts the trim on FrameTxValidation and TxValidator, whose bodies this change leaves untouched: the param tags and memoization contract on TryCalculateGasBudget and the sidecar-validator note were documentation, not commentary. Restores the EIP8141 deferred-work marker on the expiry pass, which is the only record of the broadcaster-held expired frame txs this sweep does not reach. Also drops a using left unnecessary by the blob fixture helper. * EIP-8141: restore comments on code this change does not touch * chore: correct two stale comments on the payerless and reserve paths A payerless frame tx is no longer rejected as Invalid, and TryReserve can also report a zero reservation when it refuses the very first one. * EIP-8141: drain the payer reservations when the pool is disposed DisposeAsync unsubscribed the Removed handler without releasing what was still reserved, so those payers stayed counted by a process-wide gauge no pool could decrement — making a non-zero reading the norm rather than the leak signal the gauge exists to give. * EIP-8141: keep the exposure gauge pairing in one place Both removal paths now go through RemoveTracked, so the decrement cannot drift from the entry count as sites are added. Clear no longer claims to close the teardown window: a submission already in flight can still reserve after it. * EIP-8141: make the teardown drain total again Routing Clear through the value-comparing removal let a reservation updated during enumeration survive with its gauge increment, permanently — the floor the drain exists to remove. Clear removes unconditionally; TryAdd is the only increment, so retiring an entry still retires exactly one. * EIP-8141: price a replacement without the reservation it displaces The exposure gate runs before AddCore resolves a same-sender/same-nonce replacement, so the incumbent was still reserved and the bump was charged for both. Any self-paying frame tx costing more than half its payer's balance could therefore never be fee-bumped, while non-frame txs of the same account stayed replaceable. The bound is on the pending set the pool would hold, and EIP-8141 decrements on replacement, so the displaced tx is excluded from it — held, not settled, since its own release still runs when Removed fires. * Restore the exposure gate's independent test coverage and skip its bucket walk when unneeded Adds a pool-level case pinning the payer bound over nonces above the replaced one, which BalanceTooLowFilter does not sum; verified it fails with the filter removed. The replacement discount is now computed only when the payer holds a reservation, since TryReserve ignores it otherwise. * Size the exposure test's balance off the reservation rather than a fixed constant The frame gas the builder emits is not TxGasLimit, so the hardcoded balance left the bound slack and the case stopped discriminating once max_cost was repriced. * Validate recent-root references before the prefix simulation runs RECENTROOTREFLOAD is legal in a VERIFY frame and reads the declared references on the strength of the pre-state check the main path performs, which the simulation path skipped. Anchored to the earliest slot the transaction could execute in, so a reference to the head slot is not rejected for being one slot early. Also tightens the deploy-frame decline to RecognizedPrefixLength's actual precondition, drops the calldata-stat guard GasLimitTxFilter already makes redundant, and pins AcceptTxResult id uniqueness, which the compiler cannot see. * Do not disconnect the relaying peer over an unpriceable max cost AcceptTxResult.Invalid is the one result TxFloodController maps to a disconnect, and an unpriceable max_cost is unincludable rather than malformed - MalformedTxFilter has already passed the transaction. Matches the sibling balance filters, which return Int256Overflow for the same arithmetic condition. * EIP-8141: pin that an unpriceable max cost does not disconnect the peer Revert-checked: restoring AcceptTxResult.Invalid on the unpriceable path fails the assertion, so the result the flood controller reads is now pinned. * EIP-8141: raise MAX_VERIFY_GAS to 300k and trim review comments Consensus MaxVerifyGas and …
Changes
RequiresSimulation: a deployed-code or EIP-7702 sender, a code-carrying paymaster, an unrecognized shape) by simulating the prefix in a bounded read-only EVM, resolving the payer at admission.IFrameTxPrefixSimulatorinNethermind.TxPool, implemented byFrameTxPrefixSimulatorinNethermind.ConsensusoverIReadOnlyTxProcessingEnvFactory. The narrow abstraction avoids a Consensus→TxPool cycle; it is injected optionally, so when unwiredRequiresSimulationtransactions stay deferred as before.ExecutionOptions.FrameValidationPrefixOnlyand a prefix-simulation path inTransactionProcessorBase.FrameTx.csthat reuses the existing frame machinery: halts once the payer is set, bounds cumulative work byMAX_VERIFY_GAS, and always restores state.FrameTxValidationTracerenforcing the banned-opcode list, theGAS-before-call and expiry-verifierTIMESTAMPcaveats, andSLOADrestricted totx.sender.FrameSimulationFailed.Natively-resolvable prefixes are unaffected:
FrameTxSimulationFilterreturns immediately whenFrameTxPayerFilteralready resolved the payer, so the standard shapes never enter the EVM.Types of changes
What types of changes does your code introduce?
Testing
Requires testing
If yes, did you write tests?
Notes on testing
FrameTxSimulationFilterTestscovers the fast path not invoking the simulator, the opaque path resolving and admitting, a failed simulation rejecting, and the unwired case deferring.FrameTxValidationPrefixSimulationTestscovers payer resolution for a deployed-code sender and sponsor, theMAX_VERIFY_GASbound, banned-opcode violations, revert and never-approves rejection, and that simulation leaves canonical state unchanged.Documentation
Requires documentation update
Requires explanation in Release Notes
Remarks
Deferred, tracked as
EIP8141:follow-ups: dependency-set-keyed result caching, and the first-deploy-frame carve-outs together with theCALL*/EXTCODE*target-existence and EIP-7702 trace checks — those prefixes are conservatively rejected for now, which stays spec-compliant. Simulations are serialized on one resettable world state, which bounds concurrent admission work.