fix(evm): address devnet-6 state-gas review findings#12369
Conversation
7d7f936 to
0392486
Compare
0392486 to
5d88469
Compare
EVM Opcode Benchmark DiffAggregated runs: base=3, pr=3 No significant regressions or improvements detected. |
5d88469 to
0e530bb
Compare
LukaszRozmej
left a comment
There was a problem hiding this comment.
Deep review of the state-gas changes. The core fixes check out under adversarial verification: the new clamp in RemoveStateGasRefundFromReservoir inverts the advance exactly (the old unclamped Math.Min zeroed a net-spill hole and could over-deduct StateGasUsed by up to twice the advance), on revert/halt paths the parent's resulting state is bit-identical to the "advance never happened" ground truth, the sticky OutOfGas flag is benign at every ConsumeStateGas caller, the DestroyAccount extraction is behavior-identical at both sites, and the over-cap intrinsic input to CreateAvailableFromIntrinsic is unreachable on all paths (ValidateStatic checks ExceedsCap unconditionally, not just under consensus validation), so the assert→SaturatingSub swap is pure hardening.
Five comments inline: one open design question on the spill-mark propagation rule, and four cleanups.
| { | ||
| _currentState.StateGasRefundPending += unappliedRefund; | ||
| _currentState.StateGasRefundAdvanced += unappliedRefund; | ||
| _currentState.StateGasSpillRefundAdvanced += Math.Min(childState.StateGasSpillRefundAdvanced, unappliedRefund); |
There was a problem hiding this comment.
The Math.Min(childState.StateGasSpillRefundAdvanced, unappliedRefund) combinator is an underived choice: when an advance is partially tracked and partially discharged (e.g. P=100, S=40, applied=30), it differs from the alternative Max(0, S - applied) in how much remains revocable on a later revert, and nothing in code or tests pins down which is spec-correct.
Related (pre-existing on master, not introduced here, but this is the code that would fix it): on success paths one refund can be spill-marked twice — DiscardStateGas(trackSpillRefund: true) marks the applied portion against the parent's own spill, while TGasPolicy.Refund also merges the child's marks for the same money. Traced scenario: child carries 100 unrefunded spill imported from a reverted grandchild, receives a 100 advance (marks 100), returns successfully into a parent with its own 100 unrefunded spill → parent ends with StateGasSpillRefunded=200 for one 100-gas refund, so a later halt computes childNetSpill=0 and under-adds StateGasSpillBurned.
A differential test against the EIP-8037 reference (nested revert-with-spill → cross-frame refund → outer halt) would settle both. Happy to defer to a follow-up since the double-marking predates this PR.
There was a problem hiding this comment.
Agreed the combinator is underived — I've documented it as a conservative bound at the site and would rather settle it (together with the pre-existing double-marking you traced, which is master-side) with the differential test against the EIP-8037 reference in a follow-up, as you offered. Filing that as a tracked follow-up rather than growing this PR; the 8037 fixture suite passes with the current bound.
| // State-gas refund already made spendable in this frame while its accounting correction | ||
| // still has to reach the ancestor frame that originally paid the state gas. | ||
| public long StateGasRefundAdvanced; | ||
| public long StateGasSpillRefundAdvanced; |
There was a problem hiding this comment.
Two notes on the counter set:
-
StateGasRefundPendingandStateGasRefundAdvancedare provably equal at every program point — every site increments both with the same delta and always zeroes them together (the one asymmetric reset inRemoveAdvancedStateGasRefundpreserves equality). This PR grows the lockstep set to three fields that must be maintained identically across five sites; a future frame-exit path that misses one silently corruptsStateGasSpillRefundedon a later revocation with no assert to catch it — the exact bug class this PR is fixing. Consider collapsing to a single amount plus its spill-tracked component. -
The new field could use a one-liner like its sibling's comment above, e.g.
// Portion of StateGasRefundAdvanced counted into StateGasSpillRefunded; un-marked exactly on revocation (invariant: <= StateGasRefundAdvanced).
There was a problem hiding this comment.
Both done in b9e493b: StateGasRefundPending is collapsed into StateGasRefundAdvanced (verified lockstep at every site), shrinking the set to two fields with the <= invariant now asserted at revocation, and the new field carries your suggested one-liner.
|
|
||
| // Revoke what is still parked in the reservoir (never fabricating spill debt), then | ||
| // usage the credit funded; any remainder refilled a net-spill hole, so restore the debt. | ||
| long fromReservoir = Math.Max(0, Math.Min(amount, gas.StateReservoir)); |
There was a problem hiding this comment.
The comment above is garbled — "then usage the credit funded" isn't a sentence (presumably "then revoke from usage what the credit funded").
Also Math.Max(0, Math.Min(amount, gas.StateReservoir)) is Math.Clamp(gas.StateReservoir, 0, amount) — the established idiom in the codebase. If you want to go further, the whole body reduces to a branch-free equivalent (verified against both new tests):
long fromUsed = Math.Min(Math.Max(0, amount - Math.Max(0, gas.StateReservoir)), gas.StateGasUsed);
gas.StateGasUsed -= fromUsed;
gas.StateReservoir -= amount - fromUsed;There was a problem hiding this comment.
Fixed the wording and switched to Math.Clamp(gas.StateReservoir, 0, amount). Kept the structured form over the branch-free equivalent — for consensus code I'd rather the three buckets stay legible.
| [MethodImpl(MethodImplOptions.AggressiveInlining)] | ||
| static virtual long DiscardStateGas(ref TSelf gas, long amount, long stateGasFloor, bool trackSpillRefund) => amount; | ||
|
|
||
| // Returns the spill-refund amount it marked so a later revocation can unmark it exactly. |
There was a problem hiding this comment.
This return contract belongs in an XML <returns> on the member (per AGENTS.md member-documentation guidance), and its other half — the trackedSpillRefund parameter on RemoveStateGasRefundFromReservoir below — is undocumented. The two are one protocol split across two members; an implementer of a future gas policy sees neither half in IntelliSense and could wire trackedSpillRefund to the wrong quantity (e.g. the full amount).
There was a problem hiding this comment.
Documented the protocol as a pair: <returns> on the add ("pass back verbatim as trackedSpillRefund") and <param> on the remove.
| vmState.StateGasRefundAdvanced = 0; | ||
| } | ||
|
|
||
| vmState.StateGasSpillRefundAdvanced = 0; |
There was a problem hiding this comment.
Since StateGasSpillRefundAdvanced <= StateGasRefundAdvanced holds at every site, this is a dead store whenever the guard above is false, and the asymmetric placement (sibling reset inside the if, this one outside) reads as if the invariant doesn't hold. Suggest moving it inside next to StateGasRefundAdvanced = 0, optionally with a Debug.Assert(vmState.StateGasSpillRefundAdvanced <= vmState.StateGasRefundAdvanced).
There was a problem hiding this comment.
Moved the reset inside the guard next to its sibling and added the Debug.Assert(StateGasSpillRefundAdvanced <= StateGasRefundAdvanced).
- Make advanced state-gas refund revocation exact: track how much spill refund each advance marked (VmState.StateGasSpillRefundAdvanced) and undo it on revert; the removal drains the reservoir only to zero, then recorded usage, and restores any net-spill debt the credit had filled. - Collapse StateGasRefundPending into StateGasRefundAdvanced (they were provably lockstep at every site). - Set the OutOfGas flag when ConsumeStateGas fails on the spill path. - Saturate the EIP-7825 cap subtraction in CreateAvailableFromIntrinsic (replacing the debug assert) so an over-cap intrinsic moves the excess to the reservoir; regression tests for all three. - Extract the shared destroy/recreate logic into DestroyAccount on the non-generic TransactionProcessorBase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0e530bb to
b9e493b
Compare
| _currentState.StateGasRefundAdvanced += unappliedRefund; | ||
| // Conservative bound: the child's spill-marks attributable to the still-revocable | ||
| // (unapplied) portion cannot be attributed exactly without the reference model. | ||
| _currentState.StateGasSpillRefundAdvanced += Math.Min(childState.StateGasSpillRefundAdvanced, unappliedRefund); |
There was a problem hiding this comment.
Just flagging for the follow-up: I think this deferred combinator isn't purely internal. On a top-level halt the extra spill-marks flow through StateGasSpillRefunded -> netSpill -> StateGasSpillBurned (which survives ResetForHalt) -> block gas, so an over-attribution here can change header.GasUsed. On success/revert it nets out via the Value/reservoir swap, but the halt path has no clamp. Not blocking, just want to make sure the follow-up covers the halt case too.
…nce (#12419) * fix(evm): address devnet-6 review findings in the state-gas policy - Make advanced state-gas refund revocation exact: track how much spill refund each advance marked (VmState.StateGasSpillRefundAdvanced) and undo it on revert; the removal drains the reservoir only to zero, then recorded usage, and restores any net-spill debt the credit had filled. - Collapse StateGasRefundPending into StateGasRefundAdvanced (they were provably lockstep at every site). - Set the OutOfGas flag when ConsumeStateGas fails on the spill path. - Saturate the EIP-7825 cap subtraction in CreateAvailableFromIntrinsic (replacing the debug assert) so an over-cap intrinsic moves the excess to the reservoir; regression tests for all three. - Extract the shared destroy/recreate logic into DestroyAccount on the non-generic TransactionProcessorBase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(evm): align EIP-8037 spill-refund accounting with the EELS reference Differential-tested against EELS amsterdam at execution-specs d0338f561 (the tests-glamsterdam-devnet@v6.1.1 commit), settling the two open questions from the PR #12369 review: - Advanced (cross-frame) state-gas refunds carry no spill marks: the discharge in IncorporateChildStateGasRefunds no longer re-marks the parent's StateGasSpillRefunded (the double-marking), and the Min/Max combinator question dissolves - marks travel only via the ordinary child-gas merge. - The advanced portion of a refund continues the source-based LIFO refill (gas_left up to the frame's unrefunded spill, remainder to the reservoir) instead of parking everything in the reservoir; revocation claws the full advance back from the reservoir without unmarking. - Reverted and halted children no longer merge their gross StateGasSpill/StateGasSpillRefunded into the parent, so a reverted descendant's spill cannot dock the parent reservoir at a later halt (sender was overcharged) and advance money can no longer survive a halt in the reservoir (sender was undercharged). - A top-level exceptional halt keeps the full post-refund intrinsic state gas in the block state dimension; burned spill stays regular (removes the StateGasSpillBurned reattribution and the now-dead StateGasSpillReclassified field). Adds Eip8037EelsReferenceTests: 13 scenarios whose expected (spentGas, blockRegularGas, blockStateGas) triples were produced by the EELS reference; all 812 v6.1.1 EIP-8037/8038 fixture tests stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(evm): tighten EIP-8037 accounting comments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(evm): make EIP-8037 accounting comments self-contained Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(evm): cover EIP-8037 reservoir-funded state gas (R0 > 0) Adds the reservoir paths reachable when tx.gas exceeds the EIP-7825 cap: reservoir-funded charges, the reservoir/gas_left boundary spill, and the initial-reservoir restore across nested exceptional halts. Also drops an unused using directive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Marc Harvey-Hill <10379486+Marchhill@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Changes
Addresses the review findings on #12117:
RemoveStateGasRefundFromReservoir: with a net-spill (negative) reservoir, revoking an advanced state-gas refund reclaimed spilled gas and over-deductedStateGasUsed. Now clamped to zero (matchingDiscardStateGas), with a regression test.ConsumeStateGasnow sets theOutOfGasflag when the spill-path consume fails.CreateAvailableFromIntrinsicsaturates the EIP-7825 cap subtraction instead of wrapping.TransactionProcessorextracted into a sharedDestroyAccounthelper.Types of changes
What types of changes does your code introduce?
Testing
Requires testing
If yes, did you write tests?
Notes on testing
Regression test for the negative-reservoir refund removal; Evm.Test and Blockchain.Test suites pass; devnet-6 consensus behavior covered by the EEST v6.1.1 fixtures in CI.
Documentation
Requires documentation update
Requires explanation in Release Notes