Skip to content

EIP-8141: in-pool validation-prefix simulation for opaque frame transactions (Phase 2) - #12624

Merged
Marchhill merged 78 commits into
eip8141-frame-txs-devnet7from
eip8141-mempool-phase2
Aug 19, 2026
Merged

Marchhill merged 78 commits into
eip8141-frame-txs-devnet7from
eip8141-mempool-phase2

Conversation

@Marchhill

@Marchhill Marchhill commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Changes

  • Admit the opaque frame-tx validation prefixes the native resolver defers (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.
  • Add IFrameTxPrefixSimulator in Nethermind.TxPool, implemented by FrameTxPrefixSimulator in Nethermind.Consensus over IReadOnlyTxProcessingEnvFactory. The narrow abstraction avoids a Consensus→TxPool cycle; it is injected optionally, so when unwired RequiresSimulation transactions stay deferred as before.
  • Add ExecutionOptions.FrameValidationPrefixOnly and a prefix-simulation path in TransactionProcessorBase.FrameTx.cs that reuses the existing frame machinery: halts once the payer is set, bounds cumulative work by MAX_VERIFY_GAS, and always restores state.
  • Add FrameTxValidationTracer enforcing the banned-opcode list, the GAS-before-call and expiry-verifier TIMESTAMP caveats, and SLOAD restricted to tx.sender.
  • A successful simulation records the resolved payer and feeds the existing exposure path; a failing, over-budget or payerless one rejects with FrameSimulationFailed.

Natively-resolvable prefixes are unaffected: FrameTxSimulationFilter returns immediately when FrameTxPayerFilter already resolved the payer, so the standard shapes never enter the EVM.

Types of changes

What types of changes does your code introduce?

  • New feature (a non-breaking change that adds functionality)

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

FrameTxSimulationFilterTests covers the fast path not invoking the simulator, the opaque path resolving and admitting, a failed simulation rejecting, and the unwired case deferring. FrameTxValidationPrefixSimulationTests covers payer resolution for a deployed-code sender and sponsor, the MAX_VERIFY_GAS bound, banned-opcode violations, revert and never-approves rejection, and that simulation leaves canonical state unchanged.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • No

Remarks

Deferred, tracked as EIP8141: follow-ups: dependency-set-keyed result caching, and the first-deploy-frame carve-outs together with the CALL*/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.

@claude

claude Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 11m 29s —— View job


Review: EIP-8141 Phase 2 — in-pool validation-prefix simulation

  • Gather context (diff vs origin/eip8141-mempool-validation, Phase-1 base code)
  • Review the simulation path in TransactionProcessorBase.FrameTx.cs
  • Review FrameTxValidationTracer against the spec's Validation Trace Rules
  • Review TxPool filter / DI wiring / threading
  • Review tests
  • Post findings (8 inline comments)

The architecture here is sound and the hard parts are done well. Reusing ExecuteFrame/ApplyApproval instead of reimplementing opcode semantics is the right call, the IFrameTxPrefixSimulator seam avoids the TxPool→Consensus cycle cleanly, isStatic: true for VERIFY frames matches the main path exactly, state is restored in a finally, and I verified the fast-path claim holds — FrameTxSimulationFilter returns before touching the EVM whenever PayerAddress is set. I also confirmed the arithmetic can't underflow (remainingVerifyGas), the TxFrame re-construction preserves argument order, and that a throwing simulation can't poison later ones (WorldState.EndScope calls Reset() on scope dispose). I cross-checked the banned-opcode list against the published EIP-8141 trace rules and it matches on every entry.

The findings below are about the trace rules being incompletely enforced relative to what the code claims, plus diagnostics and cancellation.

High

1. CALL*/EXTCODE* prefixes are silently admitted, not "conservatively rejected" — FrameTxValidationTracer.cs:21-24

The <remarks> states that prefixes needing the deferred CALL*/EXTCODE* target-existence and EIP-7702 checks "are conservatively rejected here (declining is always spec-compliant)". The CREATE/CREATE2/SETDELEGATE/SSTORE half of that is true — those are banned outright. The CALL*/EXTCODE* half is not: nothing inspects call or extcode targets, so such prefixes pass the tracer clean and are accepted. The spec's rule is permissive-with-a-condition ("may target existing contracts or precompiles"), so omitting it admits too much rather than too little — a prefix that STATICCALLs an empty account is admitted and its validity then depends on that account staying codeless, an unindexed dependency, with head-change re-simulation also deferred. Fix the check or correct the comment; as written it tells reviewers a security-relevant rule is enforced when it isn't. Fix this →

Medium

2. TIMESTAMP exemption checks the address but not the code — FrameTxValidationTracer.cs:73-76. Spec: "except in expiry verifier frames executing canonical runtime code". If 0x…8141 holds non-canonical code, arbitrary TIMESTAMP use there is exempted. Eip8141Constants.ExpiryVerifierCode exists and FrameTxPayerResolver already tracks expiryCodeHash, so the possibility is already acknowledged in the stack.

3. _pendingGasRequiresCall is never flushed at a frame boundary — FrameTxValidationTracer.cs:50-60. A prefix ending in GAS (implicit STOP, or a following instruction that faults before StartOperation) never records the violation — under-enforcement. And a pending flag surviving into the next frame mis-attributes the violation reason, which is surfaced verbatim in the AcceptTxResult message. Peeking pc + 1 in the code removes both cases and the mutable field.

4. The post-frame MAX_VERIFY_GAS check is unreachable, so over-budget prefixes are misreported — TransactionProcessorBase.FrameTx.cs:429-449. Because frameGasLimit is pre-capped to remainingVerifyGas and gasUsed is computed off the bounded frame, verifyGasUsed ≤ MaxVerifyGas holds unconditionally and lines 446-449 can never fire. Budget exhaustion always surfaces as "validation prefix frame reverted" / FrameSimulationFailed, so AcceptTxResult.VerifyGasExceeded is unreachable from the simulated path. The existing test's own comment documents the symptom and asserts only TransactionExecuted is false, so it passes either way.

5. Simulate takes no CancellationToken — IFrameTxPrefixSimulator.cs:28. P2P txs reach SubmitTx from Eth62ProtocolHandler.HandleSlow on the BackgroundTaskScheduler, which does hold a token and check it between transactions. A simulation runs up to MAX_VERIFY_GAS of EVM work and can additionally block behind other peers on FrameTxPrefixSimulator._lock (correctly serialized — the env isn't thread-safe — but unbounded in queue depth). None of that is cancellable, and the deferred wall-clock guard has nowhere to attach. Adding the parameter now is one line; later it's a breaking change on public API.

Low

6. FrameTxPayerResolver.Resolve runs twice per frame tx — FrameTxPayerFilter never rejects, so its resolution (including the FrameTxDependencySet the deferred caching phase will need) is computed and discarded. Carrying it on TxFilteringState also lets this filter drop its IReadOnlyStateProvider dependency. FrameTxSimulationFilter.cs:39-45

7. FrameTxPrefixSimulator — missing tx.SupportsFrames guard (a non-frame tx passed to the public interface would run a full transaction with neither Commit nor Restore; currently unreachable, and bounded by the scope reset, but worth validating at the boundary), and it reads the head from IBlockFinder while the pool's filters read state via IChainHeadInfoProvider — two head notions across one admission decision. FrameTxPrefixSimulator.cs:48-59

8. IsTracingReceipt => true on the tracer appears unnecessary — the simulation path calls ReportFrameTxReceipt unconditionally (unlike the main path, which gates on the flag) and never reaches MarkAsSuccess/MarkAsFailed. FrameTxValidationTracer.cs:35

9. An under-funded payer is reported as "frame transaction validation prefix never set a payer". ApplyApproval silently declines to set the payer when balance < MaxCost, so insufficient funds is indistinguishable from a prefix that genuinely never approves payment — and the natively-resolved path reports the same condition as PayerExposureExceeded.

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 SLOAD scope rule, the GAS/*CALL allowance, the positive TIMESTAMP exemption, or cumulative-across-frames MAX_VERIFY_GAS. Details and a suggested [TestCase] shape are in the inline comment.

Notes (no action)

  • The SLOAD-restricted-to-tx.sender rule is spec-correct as implemented — I verified it against the EIP. Worth being explicit that it means a code-carrying paymaster that reads its own storage is rejected at admission, i.e. the headline RequiresSimulation shape only works for storage-free paymasters. Spec-conformant, but a notable consequence to record.
  • The 64-frame Eip8141Constants.MaxFrames cap bounds the prefix loop, so zero-gas frames (ExecuteDefaultCode returns gasUsed = 0) can't spin it — the gas bound isn't load-bearing for loop termination.
  • Switching ExecuteFrame to ExecuteTransaction<OnFlag> when tracer.IsTracingInstructions is behaviour-preserving for block processing (the non-generic overload is <OffFlag>) and incidentally makes instruction tracing work on frame txs for the first time.
  • Skipping the nonce-equality pre-flight is correctly reasoned and documented; CalculateEffectiveGasPrice does not throw for maxFee < baseFee, so low-fee frame txs aren't spuriously rejected.
  • _api.Context.ResolveOptional has precedent (StartBlockProducer.cs:29), so the service-location shape is consistent with the codebase.

I could not verify compilation — dotnet build isn't in my allowed tools in this environment, so findings are from reading only. The PR states the Runner builds clean and the suites are green; CI will confirm.
• branch eip8141-mempool-phase2

Comment thread src/Nethermind/Nethermind.Evm/Tracing/FrameTxValidationTracer.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/Tracing/FrameTxValidationTracer.cs
Comment thread src/Nethermind/Nethermind.Evm/Tracing/FrameTxValidationTracer.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/IFrameTxPrefixSimulator.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/Filters/FrameTxSimulationFilter.cs Outdated
Comment thread src/Nethermind/Nethermind.Consensus/Processing/FrameTxPrefixSimulator.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/Tracing/FrameTxValidationTracer.cs
@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=3, pr=3
Noisy rerun opcodes: PUSH28

Improvements (1)

Opcode Base Median (ns) PR Median (ns) Delta Abs Δ (ns) Base CV PR CV Threshold Uncertainty Effective
MULMOD 933.181 181.550 -80.55% 751.631 2.2% 0.9% ±5.0% ±1.6% ±5.0%

@Marchhill

Copy link
Copy Markdown
Contributor Author

Addressed the review findings (commit 3811261).

High — CALL*/EXTCODE* prefixes were silently admitted. Confirmed against the live spec: §Validation Trace Rules L816 rejects CALL*/EXTCODE* "to an address that is neither an existing contract nor a precompile, or to an address that uses an EIP-7702 delegation, except for tx.sender default-code behavior", and L853 permits existing contracts/precompiles. The tracer inspected no call targets, so those prefixes passed clean — the permissive deviation the finding described, not the conservative one the comment claimed. FrameTxValidationTracer now classifies each CALL/CALLCODE/DELEGATECALL/STATICCALL/EXTCODESIZE/EXTCODEHASH/EXTCODECOPY target (read from the operation stack) and rejects it when it is codeless-and-non-precompile or EIP-7702-delegated, exempting tx.sender (already a tracked dependency, covering its default-code behavior). New tests pin both the codeless-target rejection and the existing-contract allow case.

Medium — TIMESTAMP exemption ignored the code. Now checks the EXPIRY_VERIFIER code hash as well as the address (L788), via a new Eip8141Constants.ExpiryVerifierCodeHash; a non-canonical predeploy no longer exempts arbitrary TIMESTAMP code.

Medium — GAS rule leaked across frames. Replaced the cross-frame _pendingGasRequiresCall flag with a peek of the next opcode byte at pc + 1 (GAS has no immediate). A trailing GAS (implicit STOP / following fault) is now caught, and violations are attributed to the correct frame. The mutable field is gone.

Medium — post-frame MAX_VERIFY_GAS check was unreachable. A prefix frame capped to the remaining budget that then exhausts it is now rejected as over-budget (distinct reason) rather than surfacing as a revert; test updated to assert the reason.

Medium — Simulate cancellation. IFrameTxPrefixSimulator.Simulate now takes a CancellationToken (honored at entry; OperationCanceledException propagates rather than being masked as a rejection). Per-frame cooperative cancellation and threading the scheduler token through IIncomingTxFilter remain the documented follow-up.

Build clean (--no-restore -m:1 -tl:off, 0 errors/warnings). Nethermind.TxPool.Test (687 passed, 1 pre-existing skip) and the frame-tx/Eip8141 Evm suites green. The lower-severity findings (duplicate Resolve, IsTracingReceipt asymmetry, head-source divergence, broader banned-opcode coverage) are not in this commit.

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude please re-review the latest changes (High CALL*/EXTCODE* tracer fix at 3811261).

@claude

claude Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 7m 40s —— View job


Re-review: commit 3811261 (CALL*/EXTCODE* tracer fix + Medium follow-ups)

  • Read the new tracer and verify the CALL*/EXTCODE* target rules against the spec
  • Verify the TIMESTAMP code-hash check, GAS peek, and MAX_VERIFY_GAS reachability fixes
  • Verify the CancellationToken addition
  • Review the new tests
  • Post findings (2 inline comments)

Verdict on the previous findings

# 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 = 1 for CALL/CALLCODE/DELEGATECALL/STATICCALL ([gas, address, …]) and 0 for EXTCODESIZE/EXTCODEHASH/EXTCODECOPY, matching NativePrestateTracer.cs:137,149 and TraceStack.Peek's top-down indexing.
  • The StartOperation → SetOperationStack handoff is safe: StartInstructionTrace (VirtualMachine.cs:1331) is the sole caller of both and always invokes them together with IsTracingStack true, so _targetStackIndex cannot survive into a different instruction. The stack.Count <= index guard is correct — a stack underflow halts the frame exceptionally and rejects the prefix anyway.
  • IsContract before IsDelegatedCode is the right order for EIP-7702 (a delegated account has code), and tx.sender is exempted per the spec's default-code carve-out.
  • Both new tests are meaningful: Simulate_PrefixCallsCodelessTarget_RecordsViolation (never-deployed AddressC) and Simulate_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 TIMESTAMP address+code-hash pair closes the residual hole completely. A DELEGATECALL into the verifier's code leaves ExecutingAccount ≠ expiryVerifier → banned; a DELEGATECALL out of the verifier would keep ExecutingAccount but is impossible once the code hash matches, since ExpiryVerifierCode contains no DELEGATECALL. GetCodeHash resolves through IAccountStateProvider's default member (ValueHash256), so the comparison is well-typed.
  • The pc + 1 peek is sound, which was not obvious: Nethermind has a second, pre-decoded dispatch loop (VirtualMachine.Stream.cs), and if it were used the pc handed to StartOperation would not be a raw code offset. VirtualMachine.cs:1287 gates the stream on !TTracingInst.IsActive, so an instruction-tracing run always takes RunByteCodeCore, where programCounter indexes stack.Code directly. EOF is not in the tree, so there is no section-relative-pc case either. GAS has no immediate, so pc + 1 is 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

Comment thread src/Nethermind/Nethermind.TxPool/IFrameTxPrefixSimulator.cs Outdated
@Marchhill
Marchhill force-pushed the eip8141-mempool-validation branch from ff8d854 to 6dff6ab Compare August 7, 2026 00:16
@Marchhill
Marchhill force-pushed the eip8141-mempool-phase2 branch 2 times, most recently from 146f435 to 2c953dd Compare August 10, 2026 10:27
@Marchhill
Marchhill changed the base branch from eip8141-mempool-validation to eip8141-verify-gas-converged August 10, 2026 12:38
@Marchhill
Marchhill force-pushed the eip8141-mempool-phase2 branch from 2c953dd to fcd4cbe Compare August 10, 2026 17:48
Comment thread src/Nethermind/Nethermind.Evm/Tracing/FrameTxValidationTracer.cs
Comment thread src/Nethermind/Nethermind.TxPool/Filters/FrameTxSimulationFilter.cs
Comment thread src/Nethermind/Nethermind.TxPool/ITxPoolConfig.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/Filters/FrameTxSimulationFilter.cs Outdated
Comment thread src/Nethermind/Nethermind.Consensus/Processing/FrameTxPrefixSimulator.cs Outdated
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.
@Marchhill
Marchhill marked this pull request as ready for review August 18, 2026 16:55
@claude

claude Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 7m 38s —— View job


Review: EIP-8141 Phase 2 — in-pool validation-prefix simulation

  • Gather context (diff vs origin/eip8141-frame-txs-devnet7, prior review threads)
  • Verify the SLOTNUM ban and the reporting changes in 58960e4
  • Cross-check the tracer's ban list against the live EIP text, item by item
  • Re-check the simulation path in TransactionProcessorBase.FrameTx.cs against the main path
  • Review the TxPool filter, simulator, exposure accounting and wiring
  • Post findings (3 inline comments)

Last round's High is genuinely closed, and I checked the mechanics rather than the commit message.

  • The ban list is now complete. I fetched the published EIP and diffed it against the switch, entry by entry: GASPRICE, BLOCKHASH, COINBASE, TIMESTAMP(cond), NUMBER, PREVRANDAO, GASLIMIT, BASEFEE, BLOBBASEFEE, SLOTNUM, GAS(cond), CREATE, CREATE2, SETDELEGATE, INVALID, SELFDESTRUCT, BALANCE, SELFBALANCE, SSTORE. Every one is covered, and the two deliberate absences hold up: SETDELEGATE is 0xF6, which has no Instruction member and falls to InstructionBadInstruction, pinned by Simulate_PrefixUsesAnUndefinedOpcode_RejectedByTheBadInstructionHalt; the deploy-frame carve-outs are unreachable while the prefix is declined outright. The SLOAD, GAS and CALL*/EXTCODE* rules match the spec wording exactly, including the EIP-7702 clause.
  • SLOTNUM is genuinely wired. Instruction.cs:70 defines 0x4B, and the new [TestCase(Instruction.SLOTNUM)] shares the TIMESTAMP body, so the ban is enforced whether or not EIP-7843 is active on the fork under test.
  • The default: split is correct as far as the enum goes — Accepted is now reached by name, and both non-accepting arms increment. See Low Feature/jsonrpc #1 for the one way the fall-through can still be reached.
  • The AcceptTxResult id shuffle is safe and self-pinning. FrameTxMissingSidecar 24 → 27 dodges KeyedNonceUnmet(24), FrameSimulationFailed(25) and the downstack 26, and AcceptTxResultTests reflects over every declared field and asserts pairwise inequality, so a future collision fails a test instead of silently aliasing two rejections.

Things I re-verified that hold: verifyGasUsed cannot underflow MaxVerifyGas - verifyGasUsed (CapFrameGas bounds the frame, ExecuteFrame computes gasUsed off the bounded one); FrameTxContext is built identically to the main path down to WorldState.GetNonce(sender) and the RecentRootReferences/NonceKeys tail; ORIGIN's relaxation is sound here because both paths set the frame caller to EntryPointAddress for VERIFY, and TLOAD's because ResetTransient() runs at every frame entry on both paths; TraceStack wraps the VM's stack memory without copying, so IsTracingStack => true costs a struct per instruction rather than a stack clone; the payer reservation has no leak path — AddCore settles it in a finally and on the !inserted branch; and the EnterReadLock → Undecided → Accepted chain keeps a node fault out of TxFloodController's disconnect path.

Three findings, all Low. No Critical, High or Medium.

Low

1. Accepted is the enum's zero value, so default(FrameTxSimulationResult) is the exact shape the new default: arm guards against — IFrameTxPrefixSimulator.cs:24. The struct's default carries Outcome == Accepted, Payer == null, which the filter turns into an admit with a null payer, no reservation and no counter — silent in exactly the way 58960e4 set out to prevent. Reordering so Undecided is zero closes the class rather than the instance.

2. The node-fault warn latch is never released — FrameTxPrefixSimulator.cs:92. Once one transient fault fires, every later fault logs at Debug for the rest of the process, so a genuinely systemic outage hours later is invisible at Warn. Clearing the flag on a successful simulation makes it once-per-episode, which is what the comment describes. One line, already inside the lock.

3. The new counter is a property, so it can only be bumped with a racy ++ — Metrics.cs:73. FrameTxPayerExposureFilter, in this same diff and the same filter chain, is declared as a field specifically so it can use Interlocked.Increment, with a comment saying the head read lock does not serialize submitters. That reasoning applies here verbatim, and this is the counter whose own description calls it the signal that payer exposure has stopped being accounted for.

Notes (no action)

  • Still open, previously acknowledged with rationale and not re-raised as findings: the plain lock in FrameTxPrefixSimulator.cs:50 (the Monitor.TryEnter bound lands in the follow-up), the deploy-frame carve-out gap, the duplicate FrameTxPayerResolver.Resolve, the IBlockFinder vs IChainHeadInfoProvider head source, the missing tx.SupportsFrames guard on the public interface, and the token-less Simulate(tx) call site. On the lock: I read "so the simulator has to bound its own wait" as an obligation on the simulator rather than a claim that it already does, so I'm not re-filing it as a contradicting comment — but that reading is doing some work, and a reader who takes it as a statement of fact would be misled.
  • PrepareValidationPrefixSimulation skips the main path's IsEip8250Enabled / IsEip8272Enabled / references.Length gates. Not reachable: FrameTxNonceKeysTxValidator and the 8272 reference validator (TxValidator.cs:246,277) both run pre-hash, before any filter can call the simulator. Worth knowing the guard lives upstream rather than here.
  • The atomic-batch approval-scope sweep the main path runs over all frames is also absent, but the prefix loop rejects any IsAtomicBatch prefix frame and breaks at the first non-VERIFY one, so the "approval scope after a batch frame" shape can never be entered.
  • Simulate_PricesTheApproveGateOnTheSameBudgetExecutionEscrows gets its -1 delta via maxGas + (ulong)(long)(-1) wrapping. Correct, and the two-case shape (floor covered / one wei short) is a good pin on the EIP-7623 fix — just an unusual way to spell maxGas - 1.
  • InitializeBlockchainAuRa.CreateTxPool still builds its own pool with no simulator, so AuRa keeps the unwired path.

Not verified by execution — dotnet build/dotnet test need approval I don't have in this environment, so everything above is from reading plus fetching the EIP text. The PR reports the suites green; CI will confirm.
• branch eip8141-mempool-phase2

Comment thread src/Nethermind/Nethermind.TxPool/IFrameTxPrefixSimulator.cs
Comment thread src/Nethermind/Nethermind.TxPool/Metrics.cs Outdated
…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 wurdum left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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;
}

@Marchhill
Marchhill merged commit 7831ad9 into eip8141-frame-txs-devnet7 Aug 19, 2026
452 of 456 checks passed
@Marchhill
Marchhill deleted the eip8141-mempool-phase2 branch August 19, 2026 14:02
Marchhill added a commit that referenced this pull request Sep 24, 2026
…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 …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants