Skip to content

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

Draft
Marchhill wants to merge 4 commits into
eip8141-mempool-validationfrom
eip8141-mempool-phase2
Draft

EIP-8141: in-pool validation-prefix simulation for opaque frame transactions (Phase 2)#12624
Marchhill wants to merge 4 commits into
eip8141-mempool-validationfrom
eip8141-mempool-phase2

Conversation

@Marchhill

Copy link
Copy Markdown
Contributor

Summary

Phase 2 of the EIP-8141 mempool stack: admit the opaque frame-tx validation prefixes the native resolver defers (RequiresSimulation — a deployed-code / EIP-7702 sender, a code-carrying paymaster, or an unrecognized shape) by simulating their validation prefix in a bounded, read-only EVM, resolving the payer and enforcing the trace/opcode rules at admission.

Design basis: frames-handoff/IN-POOL-SIMULATION-DESIGN.md (Alternative C). Spec: ethereum/EIPs#12007 (EIP-8141), "Validation Trace Rules" / "Acceptance Algorithm".

Stacks on #12620 / #12617 / #12610 (base branch eip8141-mempool-validation). Those have in-flight review fixes on their own branches; this PR will need a rebase once they land — expected, not pulled here.

The standard pattern keeps the EVM-free fast path

The common, natively-resolvable prefixes (default-code self_verify / only_verify → pay verified by a standard ECDSA signature) are not executed in the EVM. FrameTxSimulationFilter runs after FrameTxPayerFilter and returns immediately when the payer was already resolved natively (non-null PayerAddress), so the simulator is never consulted for them. Only a frame tx whose payer is still unresolved is re-classified, and only a RequiresSimulation outcome reaches the simulator. A unit test asserts the simulator is not called on the fast path (NSubstitute spy).

What's implemented

  • IFrameTxPrefixSimulator — narrow abstraction in Nethermind.TxPool (no reference to Consensus's processing env, which would cycle: Consensus already references TxPool). Injected optionally into TxPool, mirroring the existing optional incomingTxFilter.
  • FrameTxPrefixSimulator (composition root, Nethermind.Consensus) — wraps IReadOnlyTxProcessingEnvFactory; registered in BlockProcessingModule and resolved optionally in InitializeBlockchain.CreateTxPool. Reuses the read-only processing env rather than a pool-specific EVM.
  • ExecutionOptions.FrameValidationPrefixOnly + a validation-prefix simulation path in the frame processor (TransactionProcessorBase.FrameTx.cs) that reuses the existing ExecuteFrame / ApplyApproval machinery: halts once the payer is set (rule 7), bounds cumulative validation work by MAX_VERIFY_GAS (the execution bound, complementing EIP-8141: validation-prefix simulation admission for frame transactions (MAX_VERIFY_GAS bound) #12620's arithmetic pre-check), and always restores state.
  • FrameTxValidationTracer (Nethermind.Evm) — enforces the banned-opcode list, the GAS-before-call and TIMESTAMP-in-expiry-verifier caveats, and SLOAD restricted to tx.sender; a violation rejects the tx.
  • Resolver/filter integration — a successful simulation records the resolved payer, feeding the existing exposure / PayerAddress path; a failing / over-budget / no-payer one rejects (AcceptTxResult.FrameSimulationFailed).

Additive: Phase-1 behavior preserved when unwired

When no simulator is registered, RequiresSimulation frame txs stay deferred exactly as in Phase 1 (the filter is a no-op). A unit test covers this.

Deferred (documented EIP8141: follow-ups, per design note §4)

  • Dependency-set-keyed result caching.
  • Head-change re-simulation indexing (revalidation).
  • Wall-clock cancellation guard (belt-and-braces; the per-tx MAX_VERIFY_GAS gas bound is the DoS bound implemented now).
  • Multi-simulation admission budget (per-peer / global).
  • First-deploy-frame carve-outs (CREATE/CREATE2/SETDELEGATE/SSTORE-to-sender) and CALL*/EXTCODE* target-existence + EIP-7702 checks: such prefixes are conservatively rejected for now (declining is always spec-compliant, EIP L684), rather than admitted incorrectly.

Concurrency note: simulations are serialized (they share one resettable world state), which also bounds concurrent admission work until the budget lands.

Tests

  • FrameTxSimulationFilterTests (TxPool): fast path does not invoke the simulator; opaque path simulates → payer recorded → admitted; failed simulation → rejected; no simulator wired → deferred.
  • FrameTxValidationPrefixSimulationTests (Evm): deployed-code sender / sponsor resolves payer; over-MAX_VERIFY_GAS rejected; banned opcode records a violation; revert / never-sets-payer rejected; simulation does not mutate canonical state.

Full Nethermind.TxPool.Test suite green (688); frame-tx Evm suites green; full Runner build 0 errors.

Types of changes

  • New feature (non-breaking change which adds functionality)

@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 codeFrameTxValidationTracer.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 boundaryFrameTxValidationTracer.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 misreportedTransactionProcessorBase.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 CancellationTokenIFrameTxPrefixSimulator.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 compilationdotnet 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 on lines +21 to +24
/// EIP8141 follow-ups (design note §4 "Alternative C"): the first-<c>deploy</c>-frame carve-outs for
/// <c>CREATE</c>/<c>CREATE2</c>/<c>SETDELEGATE</c> and <c>SSTORE</c>-to-sender, and the
/// <c>CALL*</c>/<c>EXTCODE*</c> target-existence and EIP-7702 checks, are not yet enforced — prefixes
/// that need them are conservatively rejected here (declining is always spec-compliant, L684).

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.

High — the doc claim is inverted for CALL*/EXTCODE*: those prefixes are silently admitted, not rejected.

The CREATE/CREATE2/SETDELEGATE/SSTORE half of this claim holds — those are banned unconditionally below, so a first-deploy-frame prefix is genuinely declined. But nothing in this tracer inspects CALL/CALLCODE/DELEGATECALL/STATICCALL or EXTCODESIZE/EXTCODEHASH/EXTCODECOPY targets at all, so a prefix that needs the deferred existence / EIP-7702 checks passes the tracer clean and the tx is accepted.

The spec rule is: "CALL* and EXTCODE* may target existing contracts or precompiles, provided the resulting trace respects storage, opcode, and EIP-7702 restrictions." So skipping it is a deviation in the permissive direction, not the conservative one — the "declining is always spec-compliant (L684)" justification doesn't apply here. Concretely: an opaque prefix that STATICCALLs an empty account (or EXTCODEHASHes one) is admitted, and its validity then depends on that account staying codeless — an unindexed mempool dependency, which is exactly what the rule guards against. With head-change re-simulation also deferred, that entry sits in the pool with a payer that can silently go stale.

Either add the existence check (WorldState.GetCodeHash(target) != Keccak.OfAnEmptyString || target is a precompile → else Violate, which needs the tracer to see call targets — ReportAction gives you ExecutionEnvironment.CodeSource), or correct the comment to say these prefixes are currently admitted unchecked and mark it an EIP8141: gap. As written the comment tells a reviewer a security-relevant rule is enforced conservatively when it is not enforced at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed: the tracer now enforces the CALL*/EXTCODE* target rule (StartOperation flags the target stack slot, SetOperationStack classifies it via IsForbiddenCallTarget — codeless or EIP-7702-delegated non-precompile, non-sender targets are rejected). The class doc no longer claims these are declined unconditionally.

Comment on lines +73 to +76
case Instruction.TIMESTAMP:
// Permitted only inside the canonical expiry verifier frame (L779).
if (env.ExecutingAccount != expiryVerifier) Violate("banned opcode TIMESTAMP in validation prefix");
break;

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.

Medium — the TIMESTAMP exemption checks the address but not the code, while the spec pins both.

EIP-8141: "TIMESTAMP (0x42) — except in expiry verifier frames executing canonical runtime code." This only compares env.ExecutingAccount to the predeploy address, so if 0x…8141 carries anything other than Eip8141Constants.ExpiryVerifierCode (a devnet where the predeploy was never installed, or a chain that deployed a modified verifier), arbitrary TIMESTAMP-dependent code at that address is exempted from the ban.

The check is cheap and the constant already exists — Eip8141Constants.ExpiryVerifierCode — and FrameTxPayerResolver already treats the verifier's code hash as part of the dependency set (expiryCodeHash), so the possibility of non-canonical code is already acknowledged elsewhere in the stack. Passing the expected code hash into the tracer and comparing env.CodeInfo / the code source's hash would close it.

Comment on lines +50 to +60
// GAS is permitted only when immediately followed by a *CALL (the standard gas-forwarding
// idiom, which adds no public-mempool dependency); otherwise it is banned.
if (_pendingGasRequiresCall)
{
_pendingGasRequiresCall = false;
if (!IsCall(opcode))
{
Violate("GAS not immediately followed by a call");
return;
}
}

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.

Medium — _pendingGasRequiresCall is never flushed at a frame/execution boundary, so the GAS rule is both mis-attributed and under-enforced.

The flag is only consulted on the next StartOperation, and one tracer instance spans every frame of the prefix. Two consequences:

  1. Missed violation. If GAS is the last instruction actually executed — code ends (implicit STOP), or the following instruction faults before StartOperation fires (out-of-gas / stack underflow on a *CALL) — no further StartOperation arrives and the violation is never recorded. A prefix that ends … GAS is admitted despite GAS not being followed by a *CALL.
  2. Mis-attribution. If frame i ends with a pending GAS, the flag survives into frame i+1 and the violation is reported against that frame's first opcode. The accept/reject verdict is still correct here, but ViolationReason — which is surfaced verbatim in the AcceptTxResult message — points at the wrong frame.

Both go away if the pending check is resolved at the source instead of a state flag: the code is available on env.CodeInfo at StartOperation, so GAS can be validated by peeking the byte at pc + 1 and requiring it to be a *CALL. That also removes the mutable field.

Comment on lines +429 to +449
// Cap the frame's gas so cumulative validation work cannot exceed MAX_VERIFY_GAS even
// for an opaque prefix whose declared gas_limits were never structurally bounded.
ulong remainingVerifyGas = Eip8141Constants.MaxVerifyGas - verifyGasUsed;
ulong frameGasLimit = Math.Min(frame.GasLimit, remainingVerifyGas);
TxFrame boundedFrame = frameGasLimit == frame.GasLimit
? frame
: new TxFrame(frame.Mode, frame.Flags, frame.Target, frameGasLimit, frame.Value, frame.Data);

Address resolvedTarget = frame.Target ?? sender;
Address caller = Eip8141Constants.EntryPointAddress;

VirtualMachine.SetTxExecutionContext(new TxExecutionContext(
caller, _codeInfoRepository, tx.BlobVersionedHashes, in effectiveGasPrice, frameContext));

TransactionSubstate substate = ExecuteFrame(boundedFrame, resolvedTarget, caller, isStatic: true, frameContext, in accessTracker, spec, tracer, out ulong frameGasUsed);

verifyGasUsed += frameGasUsed;
if (verifyGasUsed > Eip8141Constants.MaxVerifyGas)
{
return TransactionResult.ErrorType.MalformedTransaction.WithDetail("frame transaction validation prefix exceeds MAX_VERIFY_GAS");
}

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.

Medium — the post-frame MAX_VERIFY_GAS check is unreachable, so an over-budget prefix is reported as a revert.

frameGasLimit = Math.Min(frame.GasLimit, remainingVerifyGas) and ExecuteFrame computes gasUsed = frame.GasLimit - remainingGas off the bounded frame, so frameGasUsed ≤ frameGasLimit ≤ MaxVerifyGas - verifyGasUsed. The invariant verifyGasUsed ≤ MaxVerifyGas therefore holds unconditionally after line 445, and the if (verifyGasUsed > Eip8141Constants.MaxVerifyGas) on 446-449 can never fire.

The observable effect is that an over-budget prefix always surfaces through the OOG path below as "validation prefix frame reverted" / AcceptTxResult.FrameSimulationFailed, never as the MAX_VERIFY_GAS reason — so AcceptTxResult.VerifyGasExceeded is unreachable from the simulated path even though it exists for the arithmetic pre-check in #12620. FrameTxValidationPrefixSimulationTests.Simulate_PrefixExceedsMaxVerifyGas_Rejected documents the symptom in its own comment ("the gas cap forces an out-of-gas revert") and asserts only TransactionExecuted is false, so it passes either way.

Two reasonable resolutions: keep the cap and report exhaustion explicitly (bool capped = frameGasLimit < frame.GasLimit; → on substate.IsError && capped, return the MAX_VERIFY_GAS error and have the filter map it to AcceptTxResult.VerifyGasExceeded), or drop the dead check and say in the remarks that budget exhaustion is deliberately surfaced as a revert. Either way an operator debugging why a frame tx was dropped currently gets the wrong reason.

}

/// <summary>
/// Outcome of an <see cref="IFrameTxPrefixSimulator.Simulate"/> call: whether the prefix is admissible

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.

Medium — Simulate should take a CancellationToken now, while the interface is still new.

Incoming P2P transactions reach TxPool.SubmitTx from Eth62ProtocolHandler.HandleSlow, which runs on the BackgroundTaskScheduler and is handed a CancellationToken it checks between transactions. Simulate is a synchronous call that runs up to MAX_VERIFY_GAS of EVM work and — because FrameTxPrefixSimulator serializes every call on one lock (necessarily, the read-only env is not thread-safe) — can also sit blocked behind other peers' simulations for an unbounded time. None of that is observable to the scheduler's token, so a shutdown or a scheduler-level cancel cannot interrupt it, and the deferred "wall-clock cancellation guard" has nowhere to attach.

Adding the token now costs one parameter; adding it later is a breaking signature change on a public interface (.agents/rules/robustness.md: long-running work must be cooperative with graceful shutdown). Threading the scheduler's token through IIncomingTxFilter is the larger part, so CancellationToken token = default on this method plus a token.ThrowIfCancellationRequested() between prefix frames would be a reasonable first step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed: Simulate now takes a CancellationToken (default) and throws on entry if cancelled; FrameTxPrefixSimulator re-throws OperationCanceledException rather than masking it.

Comment on lines +39 to +45
// A null payer is either a provably-invalid legible prefix (NoPayer) or an opaque one that
// needs simulation; only the latter is simulated. Re-resolving is cheap (native, ≤2 reads)
// and reached only for the rare unresolved frame tx, not the common resolved fast path.
if (FrameTxPayerResolver.Resolve(tx, stateProvider).Outcome != FrameTxPayerOutcome.RequiresSimulation)
{
return AcceptTxResult.Accepted;
}

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.

Low — FrameTxPayerResolver.Resolve now runs twice for every frame transaction.

FrameTxPayerFilter is an annotation step that never rejects, so it has already called Resolve (and computed the full FrameTxDependencySet) a moment earlier in the same filter chain; both results are discarded except for Payer. This re-resolve repeats TryGetAccount on the sender, GetCodeHash(ExpiryVerifierAddress), and TryGetAccount on the pay target.

The comment says it is "reached only for the rare unresolved frame tx", which is true, but the dependency set is exactly what the deferred result-caching and head-change-reindexing follow-ups will need, so it will have to be carried through anyway. Stashing FrameTxPayerResolution on TxFilteringState in FrameTxPayerFilter (it is already a ref struct threaded through the chain) and reading state.PayerResolution.Outcome here avoids the duplicate work and pre-positions the caching phase — and it removes the need for this filter to take IReadOnlyStateProvider at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deferred. The re-resolve is only reached for the rare unresolved (opaque) frame tx, and stashing FrameTxPayerResolution on TxFilteringState is exactly the groundwork the deferred dependency-set caching phase needs (design note section 4), so I would rather land it with that phase than add the field now.

Comment on lines +48 to +59

BlockHeader? head = blockFinder.Head?.Header;
if (head is null)
{
return FrameTxSimulationResult.Reject("no chain head to simulate against");
}

lock (_lock)
{
if (_disposed)
{
return FrameTxSimulationResult.Reject("simulator disposed");

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.

Low — two issues in the guard block.

  1. Missing tx-type guard. TransactionProcessor.Execute only routes to SimulateFrameValidationPrefix when tx.Type == TxType.FrameTx; any other type falls through to the ordinary transaction path with opts = FrameValidationPrefixOnly, which carries neither Commit nor Restore — i.e. a full transaction executed against the read-only world state with its mutations left in place. Today that is unreachable because FrameTxSimulationFilter gates on tx.SupportsFrames and the scope Reset()s on dispose, but IFrameTxPrefixSimulator is public API, so a tx.SupportsFrames check next to the SenderAddress check is worth having (.agents/rules/robustness.md: validate inputs at the boundary).

  2. Head source diverges from the pool's. The pool's filters read state through IChainHeadInfoProvider.ReadOnlyStateProvider, while this reads IBlockFinder.Head. The two can disagree across a head change, so the RequiresSimulation classification and the simulation itself may be taken at different heads — with the result cached onto tx.PayerAddress. Since IChainHeadInfoProvider also exposes the head header, using it would keep one head notion across the whole admission path and drop the new IBlockFinder dependency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deferred both. Keeping one head notion across resolve+simulate (switching the simulator off IBlockFinder onto IChainHeadInfoProvider) belongs with the explicitly-deferred head-change re-simulation work in the design note. The tx-type guard is unreachable today (the filter gates on SupportsFrames); noted for the boundary-hardening pass.


public override bool IsTracingInstructions => true;
public override bool IsTracingOpLevelStorage => true;
public override bool IsTracingReceipt => true;

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.

Low — IsTracingReceipt => true looks unnecessary and is asymmetric with the main path.

SimulateFrameValidationPrefix calls receiptTracer.ReportFrameTxReceipt(...) unconditionally (only pattern-matching on IFrameTxReceiptTracer), unlike the main path which gates the same call on tracer.IsTracingReceipt. MarkAsSuccess/MarkAsFailed are never reached on the simulation path either. So the only effect of this flag is to force TxTracer._isTracing and pay IsTracingReceipt-conditioned work elsewhere in the VM for no benefit.

Either drop it, or gate the ReportFrameTxReceipt call in the simulation path on it so the two paths agree on what the flag means.

Comment on lines +124 to +136

[Test]
public void Simulate_PrefixUsesBannedOpcode_RecordsViolation()
{
// TIMESTAMP is banned during the validation prefix outside the expiry verifier frame, even
// though the frame still calls APPROVE and would otherwise resolve a payer.
byte[] code = Prepare.EvmCode
.Op(Instruction.TIMESTAMP).Op(Instruction.POP)
.PushData(TxFrame.ApproveExecutionAndPayment).PushData(0).PushData(0).Op(Instruction.APPROVE).Done;
DeployContract(Sender, code, 1.Ether);
Transaction tx = FrameTx(nonce: 0, SelfVerifyFrame());

(_, FrameTxValidationTracer tracer) = Simulate(tx);

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.

Low — the trace rules are the security surface of this PR and are the least-covered part of it.

Simulate_PrefixUsesBannedOpcode_RecordsViolation covers exactly one opcode (TIMESTAMP) out of ~20 bans, and the remaining rules have no coverage at all:

  • SLOAD scopeLoadOperationStorage is the only enforcement of the spec's "SLOAD may access only tx.sender storage" rule, and neither the allowed case (SLOAD while ExecutingAccount == sender) nor the rejected case (a CALLed contract reading its own storage) is tested. Worth noting that the rejected case is the canonical only_verify → pay code-carrying paymaster shape — the headline RequiresSimulation case in the PR description — so any paymaster that reads its own storage is rejected at admission. That is spec-conformant as written, but it is a significant behavioural consequence to pin with a test.
  • GAS — neither the allowed GAS; CALL form nor a bare GAS is tested.
  • TIMESTAMP exemption — the positive case (TIMESTAMP inside the expiry-verifier frame is allowed) is untested, so a regression that bans it everywhere would go unnoticed.
  • Cumulative MAX_VERIFY_GAS — the existing test uses a single 10M-gas frame. The interesting path is two prefix frames each individually under 100k whose sum is over, which is what the remainingVerifyGas accumulation exists for.

Per .agents/rules/test-infrastructure.md and AGENTS.md ("parameterize a single test with [TestCase(...)] rather than copy-pasting the body"), the banned list is a natural [TestCase(Instruction.ORIGIN)] [TestCase(Instruction.BALANCE)] … over one body that deploys Op(op).Op(POP) + APPROVE and asserts Violated, which would cover the whole list for roughly the size of the current single test.

Also minor: this fixture has non-nullable fields assigned only in [SetUp] without #nullable enable (or = null!), while the sibling FrameTxSimulationFilterTests does enable it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agree the trace rules are the security surface; the CALL*/EXTCODE* codeless/existing-contract cases now have Evm coverage, and the end-to-end sponsor path is covered in TxPoolTests. Expanding to a parameterized sweep of the full banned-opcode list plus the SLOAD/GAS/expiry-exemption cases is a reasonable test-only follow-up.

@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: EXTCODESIZE

Regressions (1)

Opcode Base Median (ns) PR Median (ns) Delta Abs Δ (ns) Base CV PR CV Threshold Uncertainty Effective
EXTCODESIZE 103.100 746.100 +623.67% 643.000 83.5% 1.8% ±5.0% ±240.6% ±240.6%

@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 StartOperationSetOperationStack 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_limitsTransactionProcessorBase.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 violationIFrameTxPrefixSimulator.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 executiondotnet 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 on lines +429 to +458
// Cap the frame's gas so cumulative validation work cannot exceed MAX_VERIFY_GAS even
// for an opaque prefix whose declared gas_limits were never structurally bounded.
ulong remainingVerifyGas = Eip8141Constants.MaxVerifyGas - verifyGasUsed;
ulong frameGasLimit = Math.Min(frame.GasLimit, remainingVerifyGas);
// When the frame's declared gas_limit exceeds the remaining budget, cumulative prefix
// gas already tops MAX_VERIFY_GAS; running under the cap tells whether that budget is hit.
bool capped = frameGasLimit < frame.GasLimit;
TxFrame boundedFrame = capped
? new TxFrame(frame.Mode, frame.Flags, frame.Target, frameGasLimit, frame.Value, frame.Data)
: frame;

Address resolvedTarget = frame.Target ?? sender;
Address caller = Eip8141Constants.EntryPointAddress;

VirtualMachine.SetTxExecutionContext(new TxExecutionContext(
caller, _codeInfoRepository, tx.BlobVersionedHashes, in effectiveGasPrice, frameContext));

TransactionSubstate substate = ExecuteFrame(boundedFrame, resolvedTarget, caller, isStatic: true, frameContext, in accessTracker, spec, tracer, out ulong frameGasUsed);

verifyGasUsed += frameGasUsed;

if (substate.ShouldRevert || substate.IsError)
{
// A frame capped to the remaining budget that then failed exhausted that cap, so the
// prefix's validation work exceeds MAX_VERIFY_GAS: reject it as over-budget, distinct
// from a genuine revert of a within-budget frame (spec §Structural Rules 6, L812).
return capped
? TransactionResult.ErrorType.MalformedTransaction.WithDetail("frame transaction validation prefix exceeds MAX_VERIFY_GAS")
: TransactionResult.ErrorType.MalformedTransaction.WithDetail("validation prefix frame reverted");
}

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.

Medium — the new capped discriminator inverts the previous misreport: a within-budget revert is now reported as MAX_VERIFY_GAS exceeded. The underlying issue is that this loop bounds actual gas while the spec bound is on declared gas_limits.

capped is computed from the frame's declared limit (frameGasLimit < frame.GasLimit), but the reason it selects is about consumption. The comment asserts an invariant that does not hold:

A frame capped to the remaining budget that then failed exhausted that cap

A capped frame can fail without going anywhere near the cap. Concretely, with MaxVerifyGas = 100_000:

  • Prefix is a single unrecognized-shape verify frame declaring gas_limit = 5_000_000 (generous limits are the norm for paymasters). capped = true from the first iteration.
  • The verifier REVERTs at ~3k gas because the signature does not match — the ordinary rejection path.
  • substate.ShouldRevert is true → reported as "frame transaction validation prefix exceeds MAX_VERIFY_GAS".

So the operator-visible reason is wrong for the most likely rejection cause, which is the same class of problem as before, just mirrored. At minimum, an explicit REVERT should never be attributed to the budget (substate.ShouldRevert ⇒ not over-budget); even substate.IsError is not a reliable signal, since any exceptional halt (BadInstruction, stack underflow) consumes all remaining gas and would look identical.

The cleaner fix is to stop capping and bound the declared limits instead, which also removes a conformance divergence. MEMPOOL-RULES-DESIGN.md:46 records the rule as "Sum of validation-prefix gas_limit plus signature-verification intrinsic cost ≤ MAX_VERIFY_GAS" — declared, not consumed — and that is exactly what FrameTxPayerResolver.TryGetValidationPrefixVerifyGas / FrameTxVerifyGasFilter implement for the recognized shapes. This loop instead accumulates actual frameGasUsed, so for the unrecognized shapes that actually reach the simulator (where TryGetValidationPrefixVerifyGas returns false and the filter passes the tx through) a prefix declaring 5M of prefix gas but consuming 40k is admitted here, while Direct Evaluation would reject it — the opposite of "Direct evaluation MUST apply the same limits as simulation".

Accumulating the declared limits makes the check exact, reachable, and consistent with #12620:

// Spec §Structural Rules 6 (L812): the sum of the validation-prefix frames' declared gas_limit plus
// signature-verification cost is bounded by MAX_VERIFY_GAS. Same bound as the Direct Evaluation form
// in FrameTxPayerResolver.TryGetValidationPrefixVerifyGas, which cannot analyze unrecognized shapes.
verifyGasDeclared += frame.GasLimit;
if (verifyGasDeclared < frame.GasLimit || verifyGasDeclared > Eip8141Constants.MaxVerifyGas)
{
    return TransactionResult.ErrorType.MalformedTransaction.WithDetail("frame transaction validation prefix exceeds MAX_VERIFY_GAS");
}

with frame then executed at its declared limit and the revert branch restored to the single "validation prefix frame reverted" reason. That check is genuinely reachable (unrecognized shapes are not pre-checked), fires before burning EVM work, and needs no capped flag.

Note also that Simulate_PrefixExceedsMaxVerifyGas_RejectedAsOverBudget uses SelfVerifyFrame, a recognized shape — in production FrameTxVerifyGasFilter rejects that with AcceptTxResult.VerifyGasExceeded before the simulator is consulted. A test over an unrecognized shape would exercise the path this code actually serves.

Fix this →

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed the misreport: an explicit within-budget REVERT (e.g. signature mismatch) is no longer attributed to MAX_VERIFY_GAS — only a capped frame that runs out of gas is reported as over-budget (capped && !substate.ShouldRevert). I kept the cap rather than switching to a declared-gas-limit bound: for recognized shapes the declared bound is already enforced upstream by FrameTxVerifyGasFilter (the Direct Evaluation form), and the simulator is reached for opaque/unrecognized shapes where capping runs bounded work; the declared-bound rewrite would also change admission semantics and the 200k-gas Evm fixtures. Left the declared-vs-consumed alignment as a noted follow-up.

Comment on lines +26 to +31
/// <param name="token">
/// Cancels the (up to <c>MAX_VERIFY_GAS</c>) simulation, which may also block behind other peers'
/// serialized simulations. Honored at entry; per-frame cooperative cancellation is a deferred
/// follow-up (design note §4). An <see cref="System.OperationCanceledException"/> propagates.
/// </param>
FrameTxSimulationResult Simulate(Transaction tx, CancellationToken token = default);

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.

Low — the deferred "per-frame cooperative cancellation" is already available for ~4 lines via ITxTracer.IsCancelable, and it would also stop burning budget after the first violation.

The token being honored only at entry is the weak part of this: entry is exactly where the simulation costs nothing. The interpreter already polls the tracer for cancellation every 1024 opcodes — VirtualMachine.DispatchSpecialized.cs:62 checks _txTracer.IsCancelled under the TCancelable flag, which VirtualMachine.cs:1256 selects from _txTracer.IsCancelable (both are default-false members on ITxTracer). So FrameTxValidationTracer can opt in directly:

public sealed class FrameTxValidationTracer(Address sender, Address expiryVerifier, IReadOnlyStateProvider state, IReleaseSpec spec, CancellationToken token = default)
    : TxTracer, IFrameTxReceiptTracer
{
    // The interpreter polls this every 1024 opcodes, giving the simulation cooperative cancellation
    // and letting it abort as soon as a trace rule is violated instead of burning the rest of MAX_VERIFY_GAS.
    public override bool IsCancelable => true;
    public override bool IsCancelled => Violated || token.IsCancellationRequested;

That closes the gap the <param> documents as deferred, and the resulting OperationCanceledException already has a handler in FrameTxPrefixSimulator (line 90) plus WorldState.Restore in the processor's finally, so nothing else changes. The abort-on-violation half matters independently: today a prefix that violates a rule on its first opcode still runs to completion, so a spammer gets the full MAX_VERIFY_GAS of work per rejected tx rather than ~0.

The remaining, larger piece — threading the BackgroundTaskScheduler token from Eth62ProtocolHandler.HandleSlow through IIncomingTxFilter — is fairly noted as a follow-up; the queueing behind FrameTxPrefixSimulator._lock is not interruptible until then. Worth saying so explicitly in the <param> text, which currently reads as if the token covers that wait.

Fix this →

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deferred. Wiring IsCancelled => Violated would surface a rule violation as OperationCanceledException, which FrameTxPrefixSimulator re-throws (for shutdown) rather than converting to a Reject carrying the violation reason — so abort-on-violation needs an accompanying exception-flow change. Kept as the follow-up the interface doc already notes (design note section 4); the interpreter still bounds the work by MAX_VERIFY_GAS.

…actions

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.
…acer

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.
…tion

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.
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.
@Marchhill
Marchhill force-pushed the eip8141-mempool-phase2 branch from 3811261 to 146f435 Compare August 7, 2026 00:39
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.

1 participant