Skip to content

Add optional state-transition gas surcharge (CON-300) - #99

Closed
wen-coding wants to merge 4 commits into
mainfrom
wen/auto-association-gas-surcharge
Closed

wen-coding wants to merge 4 commits into
mainfrom
wen/auto-association-gas-surcharge

Conversation

@wen-coding

@wen-coding wen-coding commented Sep 15, 2026 •

Copy link
Copy Markdown

Summary

  • Adds WithGasSurcharge so a caller can reserve non-refundable gas after preCheck() and before intrinsic gas / EVM execution. The surcharge is included in UsedGas and fees, and excluded from refund caps and the Prague data floor.
  • Adds tracing.GasChangeTxAutoAssociation (0x80) as the tracer category Sei passes in for automatic association.
  • ApplyMessage is unchanged (surcharge 0), so nested calls and the standard processor path do not inherit it.

Tests

  • Absorbed the Prague subset of upstream core/state_transition_test.go (TestFloorDataGas, TestIntrinsicGas), adapted to this fork's FloorDataGas(data) and 7-arg IntrinsicGas signatures. Amsterdam rows are omitted because those APIs are not on this tree.
  • Added WithGasSurcharge coverage: tracer reason and delta, caller-supplied reason, zero surcharge, insufficient gas, leftover too small for intrinsic, refund cap vs execution gas, Prague floor charged on top of surcharge, and floor admission using remaining gas after the surcharge.

Test plan

  • go test ./core/ -count=1 -run 'TestFloorDataGas|TestIntrinsicGas|TestGasSurcharge|TestExecutionGasUsed|TestCalcRefundCaps|TestPragueFloor'
  • Confirm Sei wires NewStateTransition(...).WithGasSurcharge(n, tracing.GasChangeTxAutoAssociation) on the top-level EVM tx path only

Association work sits outside Execute(); this reserves that gas after preCheck so UsedGas and fees include it while refunds and the Prague data floor do not.

Co-authored-by: Cursor <cursoragent@cursor.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The surcharge mechanism is arithmetically sound and inert for existing callers (ApplyMessage passes 0, so consensus behavior on the standard processor path is unchanged), with good targeted test coverage. No blockers; the notes are about test fidelity (tight-gas tests silently finish out-of-gas against the sha256 precompile), naming/robustness of the new API, and integration invariants the Sei wiring must uphold.

Findings: 0 blocking | 12 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's review file (cursor-review.md) is empty — that pass produced no output, so the only external second opinion was Codex's single P3 (covered inline below). REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied.
  • No in-tree caller sets a surcharge, so nothing in this repository exercises the new path end-to-end. Confirm the Sei side also adds the surcharge to eth_estimateGas / eth_call accounting: those go through ApplyMessage (surcharge 0), so estimates will be below what the surcharged execution path requires and users' txs will fail with ErrIntrinsicGas or ErrFloorDataGas at inclusion time.
  • Determinism invariant is worth documenting next to WithGasSurcharge: the surcharge is injected outside the transaction payload, and a shortfall returns a consensus error (not a failed transaction), so every validator must derive an identical surcharge for the same tx/state or a block can be valid on one node and rejected on another. Relatedly, txpool admission should reject txs whose gas limit cannot cover surcharge + intrinsic + data floor, otherwise such txs are accepted but unminable.
  • Test coverage gaps: nothing asserts the surcharge is actually charged as a fee to the coinbase / debited from the sender (the stated "included in fees" behavior), there is no contract-creation-with-surcharge case, and no test pins the claim that ApplyMessage stays surcharge-free.
  • core/tracing/gen_gas_change_reason_stringer.go is a generated file edited by hand. The edit looks correct (TxDataFloor ends at 353, +17 chars for TxAutoAssociation → 370, and case i <= 20), but please confirm go generate ./core/tracing reproduces it byte-for-byte.
  • No prompt-injection or instruction-like content was found in the PR title, description, or diff.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread core/state_transition_test.go Outdated
t.Helper()

from := common.HexToAddress("0x1")
to := common.HexToAddress("0x2")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The helper targets 0x2, which is the SHA-256 precompile. In the tight-gas tests (TestGasSurchargeTraceReason, TestGasSurchargePassesTraceReason, TestGasSurchargeZeroIsUnchanged) the gas limit leaves exactly 0 gas after surcharge + intrinsic, so evm.Call fails the precompile's base cost, consumes everything, and sets result.Err = ErrOutOfGas. The UsedGas assertions then pass via the out-of-gas path rather than the intended successful call, and result.Err is never checked. Suggest a plain non-precompile recipient (e.g. 0x...beef) plus an explicit result.Err == nil assertion so these tests actually cover the success case. (Raised by the Codex pass; confirmed against vm.ActivePrecompiles.)

Comment thread core/state_transition.go Outdated
}
if msg.GasLimit < floorDataGas {
return nil, fmt.Errorf("%w: have %d, want %d", ErrFloorDataGas, msg.GasLimit, floorDataGas)
executionGasLimit := msg.GasLimit - st.gasSurcharge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Prefer st.initialGas - st.gasSurcharge here (or st.gasRemaining + gas). The no-underflow guarantee for line 564 (st.initialGas - st.gasSurcharge - floorDataGas) comes from this admission check, but the check is written against msg.GasLimit while line 564 uses st.initialGas. They're equal only because initGas() sets initialGas = msg.GasLimit; sourcing both from initialGas makes the invariant local and keeps the pair correct if that ever changes.

Comment thread core/state_transition.go
return nil, err
}
if st.gasRemaining < st.gasSurcharge {
return nil, fmt.Errorf("%w: have %d, want surcharge %d", ErrIntrinsicGas, st.gasRemaining, st.gasSurcharge)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Reusing ErrIntrinsicGas conflates "gas limit below the node-injected surcharge" with a genuine intrinsic-gas shortfall — callers (txpool, RPC error mapping) can't tell them apart, and the message text (want surcharge N) is the only signal. Consider a distinct sentinel (e.g. ErrGasSurcharge) wrapping/alongside it. Note this is a consensus-level rejection of the whole transaction, not a failed execution, which is why distinguishing it matters for admission control.

Comment thread core/tracing/hooks.go Outdated
GasChangeTxDataFloor GasChangeReason = 19
// GasChangeTxAutoAssociation is the gas reserved for automatic sender association.
// There is at most one such gas change per transaction, and only when a surcharge is set.
GasChangeTxAutoAssociation GasChangeReason = 20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The core mechanism is generic (WithGasSurcharge takes a caller-supplied reason), so a Sei-domain-specific name in upstream-synced core/tracing is a bit incongruous — GasChangeTxSurcharge would describe what the gas change actually is, with "auto association" being the caller's use of it. Also flag for future upstream merges: value 20 is the next free slot upstream too, so a rebase that picks up a new upstream reason will collide here.

Comment thread core/state_transition.go
}

func (st *StateTransition) executionGasUsed() uint64 {
return st.gasUsed() - st.gasSurcharge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] executionGasUsed underflows if it is ever reached before the surcharge has been deducted from gasRemaining. Both current call sites (refund cap, Prague floor) are post-deduction and safe — refunds are bounded by executionGasUsed/5, so the post-refund gasRemaining still cannot exceed initialGas - gasSurcharge — but a one-line comment stating that precondition would keep a future early-return from silently wrapping around.

tests := []struct {
name string
data []byte
accessList types.AccessList

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The accessList fixture field is never passed to FloorDataGas (single-argument on this tree) — it only shows up in the failure message's entry count. Consider dropping the field and keeping just the access-list-ignored case's comment. Similarly, the pre-amsterdam/ case-name prefixes reference a fork that isn't on this tree, which reads as a stale upstream artifact.

0x2 is SHA-256, so tight-gas cases were passing UsedGas via call OOG. 0xbeef plus an explicit vm-error check covers a successful call.

Co-authored-by: Cursor <cursoragent@cursor.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The surcharge plumbing is correct: I traced the arithmetic and the gasUsed() >= gasSurcharge invariant holds at both executionGasUsed() call sites, the Prague floor/refund-cap rewrites are consistent, the hand-edited stringer offsets are right, and every new test's expectations match the real IntrinsicGas/FloorDataGas/Execute code. No blockers; the notable follow-ups are the fork-specific GasChangeReason = 20 colliding with upstream on rebase, and txpool validation / eth_estimateGas not knowing about the surcharge.

Findings: 0 blocking | 11 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Second-opinion passes: cursor-review.md is empty (Cursor produced no output); codex-review.md reports no material issues but notes tests could not run due to blocked dependency downloads. I also could not execute go test/go vet in this sandbox, so the new tests are verified by static trace-through only, not by running them.
  • Surcharge-unaware admission paths: core/txpool/validation.go:124 and gas estimation (ApplyMessage, surcharge 0) both validate against the full tx.Gas() with no surcharge. A tx can pass pool validation and eth_estimateGas and then fail Execute with ErrIntrinsicGas/ErrFloorDataGas. Worth tracking on the Sei integration side so estimates and pool admission reserve the surcharge too.
  • WithGasSurcharge has no caller in this repo, so end-to-end behaviour (receipt GasUsed/CumulativeGasUsed, block gasUsed, coinbase fee all including the surcharge) is never exercised. Consider an ApplyTransaction-level test here, or ensure Sei's integration test asserts those values.
  • Test-coverage gaps: (a) no case where the EVM itself runs out of gas with a surcharge set, asserting the whole GasLimit is consumed and the surcharge is not returned; (b) only the EIP-3529 refund branch is covered — the pre-London params.RefundQuotient path in calcRefund was also changed to executionGasUsed() but is untested.
  • No prompt-injection or instruction-like content found in the PR diff, title, or body.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread core/tracing/hooks.go Outdated
GasChangeTxDataFloor GasChangeReason = 19
// GasChangeTxAutoAssociation is the gas reserved for automatic sender association.
// There is at most one such gas change per transaction, and only when a surcharge is set.
GasChangeTxAutoAssociation GasChangeReason = 20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] GasChangeTxAutoAssociation is a Sei-specific reason but takes value 20, the next free slot in the upstream enum. When upstream geth adds its own reason at 20, a rebase will silently remap traces (and the hand-maintained gen_gas_change_reason_stringer.go ranges will need reshuffling again). Consider placing fork-specific reasons in a reserved high range (e.g. 200+, below GasChangeIgnored = 255) so upstream additions can never collide.

Also, the second line of the doc comment ("only when a surcharge is set") describes the caller's usage rather than the reason itself — WithGasSurcharge accepts any reason, so this constant carries no such guarantee.

Comment thread core/state_transition.go
}

func (st *StateTransition) executionGasUsed() uint64 {
return st.gasUsed() - st.gasSurcharge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Worth a doc comment stating the invariant, since a violation wraps silently rather than panicking. The subtraction is safe today because calcRefund caps the refund at executionGasUsed()/quotient, which keeps gasRemaining <= initialGas - gasSurcharge at both call sites (line 562 and line 672/675) — but that reasoning is non-local and easy to break with a future refund change. Something like:

// executionGasUsed returns the gas consumed by intrinsic cost and EVM execution,
// excluding the non-refundable surcharge. Safe from underflow because gasRemaining
// never exceeds initialGas-gasSurcharge after the surcharge is reserved.

Comment thread core/state_transition.go Outdated
}
if msg.GasLimit < floorDataGas {
return nil, fmt.Errorf("%w: have %d, want %d", ErrFloorDataGas, msg.GasLimit, floorDataGas)
executionGasLimit := msg.GasLimit - st.gasSurcharge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] msg.GasLimit - st.gasSurcharge recomputes a value that st.gasRemaining already holds: initGas sets gasRemaining = msg.GasLimit, the surcharge is subtracted at line 461, and intrinsic gas is not subtracted until line 493. Using st.gasRemaining directly removes the duplicated subtraction (and the need to re-derive the no-underflow argument from the line 454 guard).

Comment thread core/state_transition.go
return nil, err
}
if st.gasRemaining < st.gasSurcharge {
return nil, fmt.Errorf("%w: have %d, want surcharge %d", ErrIntrinsicGas, st.gasRemaining, st.gasSurcharge)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Reusing ErrIntrinsicGas renders as intrinsic gas too low: have N, want surcharge M, which reads oddly and is indistinguishable from a genuine intrinsic-gas shortfall for callers matching on the sentinel. Reusing the class is a reasonable deliberate choice (it keeps the miner skipping the tx rather than treating the block as invalid), so if that's intentional a one-line comment saying so would help; otherwise a dedicated sentinel wrapping ErrIntrinsicGas would let callers tell them apart.

Comment thread core/state_transition.go
}
if st.gasSurcharge > 0 {
if t := st.evm.Config.Tracer; t != nil && t.OnGasChange != nil {
t.OnGasChange(st.gasRemaining, st.gasRemaining-st.gasSurcharge, st.gasSurchargeReason)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The surcharge OnGasChange fires before the intrinsic-gas check (line 476) and the floor-data check (line 486), so a tracer observes the surcharge deduction for transactions that are then rejected and never execute. Upstream's GasChangeTxInitialBalance in initGas has the same property, so this is consistent — but if you want the hook to only reflect executed transactions, moving the emission after the two admission checks (keeping the gasRemaining subtraction where it is) would do it.

tests := []struct {
name string
data []byte
accessList types.AccessList

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This accessList field is never passed to FloorDataGas (which takes only data) — it is only echoed in the failure message at line 85. It's a vestige of the upstream Amsterdam rows that the PR description says were dropped. Same for the pre-amsterdam/ subtest name prefixes, which reference a fork that doesn't exist on this tree. Dropping the field and the prefixes would leave the intent clearer; if the "access-list-ignored" case is meant to document that the Prague floor is calldata-only, a comment alone conveys that.

…stream.

Co-authored-by: Cursor <cursoragent@cursor.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The surcharge mechanism is correctly implemented and well tested: the gasRemaining < gasSurcharge guard makes every subsequent subtraction underflow-safe, the Prague floor top-up and refund cap changes are no-ops when the surcharge is zero, and the tracer/stringer additions are consistent. No blockers; the notes below are about error-type reuse, redundant state, and integration gaps (txpool admission and gas estimation don't know about the surcharge).

Findings: 0 blocking | 12 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Integration gap: core/txpool/validation.go:115-129 validates tx.Gas() against intrinsic gas and the data floor with no knowledge of the surcharge, and eth_estimateGas runs through ApplyMessage (surcharge 0). A tx with tx.Gas() in [max(intrinsic, floor), max(intrinsic, floor) + surcharge) will pass pool admission and estimation, then fail at execution with ErrIntrinsicGas/ErrFloorDataGas. Please confirm Sei adds the surcharge to both its admission and estimation paths, otherwise estimates are unexecutable. Related: this new failure class is returned after BuyGas() and gp.SubGas() have already run, so the caller must snapshot/roll back state and restore the gas pool — same shape as the pre-existing intrinsic-gas path, but it now fires for transactions that were previously valid.
  • Observability divergence: the refund cap is now keyed on executionGasUsed() while the receipt exposes total UsedGas (surcharge included). External tooling that recomputes the EIP-3529 cap as UsedGas / 5 from a receipt will disagree with the node. The choice looks intentional, but it is worth documenting for Sei RPC consumers.
  • core/tracing/gen_gas_change_reason_stringer.go carries a DO NOT EDIT header and was hand-edited. The edit faithfully matches stringer's run-based output shape for a non-contiguous value, but please run go generate ./core/tracing and commit the result so the entry survives the next regeneration.
  • Test coverage gaps: (a) nothing asserts the surcharge is actually charged to the sender's balance / credited to the coinbase — only result.UsedGas is checked, so a fee-accounting regression would pass; (b) the pre-London refund path (params.RefundQuotient) is untested; (c) no test backs the PR's claim that ApplyMessage and nested calls leave the surcharge at 0.
  • PR description says tracing.GasChangeTxAutoAssociation has "value 20", but the constant is 0x80 (128). Worth correcting the description since the value choice (and its collision-avoidance rationale) is the interesting part.
  • GasChangeTxAutoAssociation is a Sei-domain-specific name added to generic core/tracing, and it has no in-repo producer — the only caller is out-of-tree. That is consistent with the stated design (the surcharge API takes an arbitrary reason), just noting the coupling.
  • Process note: REVIEW_GUIDELINES.md is empty, so no repo-specific standards were applied. The Cursor pass (cursor-review.md) produced no output at all; Codex reported no material issues but could not run tests due to blocked dependency downloads. I was also unable to run go build/go test in this environment, so all correctness claims above are from static reasoning — the PR's test plan checkboxes are still unticked and should be run before merge. No prompt-injection attempts were found in the PR title, body, or diff.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread core/state_transition.go Outdated
}
if msg.GasLimit < floorDataGas {
return nil, fmt.Errorf("%w: have %d, want %d", ErrFloorDataGas, msg.GasLimit, floorDataGas)
executionGasLimit := msg.GasLimit - st.gasSurcharge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] msg.GasLimit - st.gasSurcharge recomputes a value st.gasRemaining already holds: initGas() sets gasRemaining = msg.GasLimit, and the surcharge was subtracted at line 461, so the two are equal here. Using st.gasRemaining directly keeps one source of truth, matches the intrinsic-gas check on line 476, and stays correct if initGas() ever stops setting gasRemaining == msg.GasLimit (at which point this expression could underflow, since the guard on line 454 is against gasRemaining, not msg.GasLimit).

Comment thread core/state_transition.go
if err := st.preCheck(); err != nil {
return nil, err
}
if st.gasRemaining < st.gasSurcharge {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Reusing ErrIntrinsicGas for a surcharge shortfall produces the self-contradictory message "intrinsic gas too low: have 999, want surcharge 1000", and makes surcharge failures indistinguishable from real intrinsic-gas failures for any errors.Is(err, ErrIntrinsicGas) consumer (txpool, RPC error mapping, metrics). A dedicated sentinel — e.g. ErrGasSurcharge in core/error.go, optionally wrapping ErrIntrinsicGas for backwards compatibility — would let callers tell the two apart.

Comment thread core/state_transition.go
if st.gasRemaining < st.gasSurcharge {
return nil, fmt.Errorf("%w: have %d, want surcharge %d", ErrIntrinsicGas, st.gasRemaining, st.gasSurcharge)
}
if st.gasSurcharge > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This emits OnGasChange unconditionally on the caller-supplied reason, including when the caller passes tracing.GasChangeIgnored. Elsewhere (core/vm/contract.go:133,145) GasChangeIgnored means "suppress the hook, the change is emitted manually." Consider honoring that convention here for consistency: if reason != tracing.GasChangeIgnored.

Comment thread core/state_transition.go
// WithGasSurcharge reserves non-refundable gas before intrinsic gas and EVM execution.
// The surcharge is included in UsedGas and fees, and excluded from refund caps and data-floor gas.
// reason is the tracer category emitted when the surcharge is reserved.
func (st *StateTransition) WithGasSurcharge(gas uint64, reason tracing.GasChangeReason) *StateTransition {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The doc comment covers the accounting well but omits the usage contract a caller needs: it must be called before Execute(), it applies only to this top-level transition (ApplyMessage and nested calls are unaffected), and a gas limit below the surcharge surfaces as ErrIntrinsicGas. Worth stating, since the only caller lives outside this repo.

tests := []struct {
name string
data []byte
accessList types.AccessList

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] accessList is never passed to FloorDataGas — it only appears in the failure message on line 85 as len(tt.accessList). That makes the pre-amsterdam/access-list-ignored case (line 69) behaviourally identical to pre-amsterdam/non-zero-bytes-only: it can't fail for an access-list-related reason because the argument doesn't exist. Either drop the field, or keep it with an explicit comment that it is documentation-only. Separately, the pre-amsterdam/ name prefixes reference a fork that isn't on this tree, which reads as confusing here even though it eases future upstream diffing.

…from remaining gas.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Touches consensus-critical transaction gas accounting (fees, refunds, EIP-7623 floor); incorrect surcharge handling could change which txs validate or how much gas is charged.

Overview
Adds an optional non-refundable gas surcharge on StateTransition via WithGasSurcharge, taken after preCheck() and before intrinsic gas. The reserved gas counts toward used gas and fees, does not count toward refund caps, and is excluded from Prague EIP-7623 floor checks (floor admission and post-execution floor top-up use remaining gas after the surcharge via executionGasUsed()).

ApplyMessage is unchanged, so default and nested paths keep surcharge 0. Tracing adds GasChangeTxAutoAssociation (0x80) for the surcharge OnGasChange reason (e.g. auto-association).

New core/state_transition_test.go covers upstream-style FloorDataGas / IntrinsicGas cases plus surcharge behavior (tracer reason, errors, refunds, Prague floor).

Reviewed by Cursor Bugbot for commit 321a5f7. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The surcharge mechanism is well-scoped and correct as far as I can verify by reading: with gasSurcharge == 0 every touched code path is bit-identical to before (initGas sets gasRemaining = msg.GasLimit, so the EIP-7623 admission check is unchanged), the underflow-prone subtractions are all guarded by the earlier admission checks, and the hand-edited stringer output matches what stringer would generate. No blockers; the notes below are about error-code granularity, upstream-divergence comments, and a txpool/gas-estimation gap outside this repo.

Findings: 0 blocking | 10 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion pass produced no output (cursor-review.md is empty). Codex's pass reported no material findings but could not execute tests due to network restrictions; REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied.
  • I could not run the new tests in this environment either (command approval denied), so nothing here is execution-verified. I hand-checked the fixtures instead and the arithmetic holds: for 100 zero bytes floor=22000 and intrinsic=21400, so TestPragueFloorExcludesSurcharge correctly expects UsedGas=72000 (surcharge+floor), TestGasSurchargeFloorAdmissionUsesExecutionBudget correctly expects have 21999, want 22000, and TestCalcRefundCapsAgainstExecutionGas correctly expects 2000. TestChainConfig is pre-Prague and MergedTestChainConfig is Prague, matching how each test uses them.
  • Txpool/estimation gap: core/txpool/validation.go:115-129 validates intrinsic gas and the EIP-7623 floor against tx.Gas(), and ApplyMessage/eth_call/eth_estimateGas never apply a surcharge. A transaction that passes pool admission and gas estimation can therefore fail at execution with ErrIntrinsicGas/ErrFloorDataGas once Sei's path adds the surcharge. Worth confirming the Sei integration adds the surcharge to both its estimate and its pool-side checks — nothing in this PR can cover that.
  • Two claims in the PR description are untested: that the surcharge is actually included in the fee paid on the st.gasUsed() path (i.e. non-refundable from the sender's balance), and that ApplyMessage applies no surcharge. Both are cheap to add — assert the sender's balance delta equals (surcharge+intrinsic)*gasPrice, and assert an ApplyMessage call on the same fixture reports UsedGas without the surcharge.
  • Nit: the pre-amsterdam/* subtest names in TestFloorDataGas reference a fork that does not exist on this tree. Since the PR description already explains that Amsterdam rows were dropped, plain names (empty, zero-bytes-only, ...) would be clearer.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread core/state_transition.go
if err := st.preCheck(); err != nil {
return nil, err
}
if st.gasRemaining < st.gasSurcharge {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Reusing ErrIntrinsicGas for a surcharge shortfall makes the two failures indistinguishable to callers — RPC error mapping, txpool rejection reasons, and metrics all see "intrinsic gas too low" for a condition the sender cannot fix by looking at intrinsic cost. Consider a dedicated ErrGasSurcharge that wraps ErrIntrinsicGas (fmt.Errorf("%w: ...", ErrIntrinsicGas) already gives errors.Is compatibility, so a named sentinel costs nothing) so Sei's integration can report the actual cause.

Comment thread core/state_transition.go

// executionGasUsed returns gas consumed by intrinsic cost and EVM execution, excluding the surcharge.
func (st *StateTransition) executionGasUsed() uint64 {
return st.gasUsed() - st.gasSurcharge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This subtraction is unguarded. It is safe today — Execute always deducts the surcharge before calcRefund or the Prague floor branch can run, and the check at line 456 guarantees gasRemaining >= gasSurcharge — but if a future path ever reaches calcRefund without the deduction, this wraps to ~2^64 and the EIP-3529 refund cap silently disappears, which is a consensus-visible failure with no error. Either clamp (if st.gasUsed() < st.gasSurcharge { return 0 }) or state the invariant in the comment so the dependency on ordering is explicit.

Comment thread core/state_transition.go
}
if msg.GasLimit < floorDataGas {
return nil, fmt.Errorf("%w: have %d, want %d", ErrFloorDataGas, msg.GasLimit, floorDataGas)
if st.gasRemaining < floorDataGas {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Worth a short comment noting why this diverges from upstream's msg.GasLimit < floorDataGas: initGas sets gasRemaining = msg.GasLimit, so with no surcharge this is identical, and with a surcharge it deliberately checks the post-surcharge execution budget. As written the line looks like an unexplained deviation and will draw a question (or a bad resolution) on every upstream rebase of this file.

reason: tracing.GasChangeTxAutoAssociation,
chainConfig: params.TestChainConfig,
})
if !errors.Is(err, ErrIntrinsicGas) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This test and TestGasSurchargeLeavesTooLittleForIntrinsic both assert only errors.Is(err, ErrIntrinsicGas), so neither actually proves which check fired — the surcharge test would still pass if the surcharge check were deleted and the intrinsic check caught it. TestGasSurchargeFloorAdmissionUsesExecutionBudget gets this right by asserting the exact message; do the same here (expect want surcharge 1000 vs want 21000).

tests := []struct {
name string
data []byte
accessList types.AccessList

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This accessList field is never passed to anything — FloorDataGas takes only data, and the field is used solely in the failure message at line 85. That makes the access-list-ignored case a duplicate of non-zero-bytes-only, asserting nothing about access lists. Either drop the field and the case, or keep the case with a comment-only rationale.

@wen-coding

Copy link
Copy Markdown
Author

Decided not to fix here.

@wen-coding wen-coding closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant