Skip to content

execution: implement EIP-2780 resource-based intrinsic transaction gas - #22053

Merged
taratorio merged 34 commits into
mainfrom
worktree-gd6-eip-2780
Jul 10, 2026
Merged

taratorio merged 34 commits into
mainfrom
worktree-gd6-eip-2780

Conversation

@taratorio

@taratorio taratorio commented Jun 26, 2026

Copy link
Copy Markdown
Member

@taratorio taratorio added the Glamsterdam https://eips.ethereum.org/EIPS/eip-7773 label Jun 26, 2026
@taratorio
taratorio requested a review from lupin012 as a code owner July 2, 2026 13:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Implements EIP-2780 resource-based intrinsic transaction gas (for Amsterdam/glamsterdam rules) across intrinsic gas calculation, EVM execution-time top-level charges, txpool/shutter selection/validation, and RPC gas estimation—plus updates to tests and CI tolerances.

Changes:

  • Extend intrinsic gas calculation inputs to support EIP-2780 (TX_BASE / recipient cold access / value costs, self-transfer exemptions) and plumb the new flags through execution and txn providers.
  • Add EIP-2780 top-level frame charges in the EVM (NEW_ACCOUNT state gas for account-creating value transfers; delegated-recipient cold access).
  • Update eth_estimateGas to return exact gas used for code-less transfers and add regression tests / adjust engine-api tests for new gas accounting.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
txnprovider/txpool/pool.go Plumbs EIP-2780 inputs into intrinsic gas used for tx selection/validation.
txnprovider/shutter/pool.go Plumbs EIP-2780 intrinsic gas inputs when selecting decrypted txns.
tools/eest-spec-shards.yml Updates allowed failure thresholds for EEST shard runs.
rpc/jsonrpc/eth_call.go Adjusts EstimateGas transfer short-circuit to return actual gas used (EIP-2780 aware).
rpc/jsonrpc/eth_call_test.go Adds EIP-2780 estimateGas regression test + adjusts chain setup gas limits for fillers.
execution/vm/evm.go Adds EIP-2780 top-level frame charging and threads adjusted gas into Run.
execution/tests/testutil/transaction_test_util.go Updates intrinsic gas calc args in execution tests for EIP-2780.
execution/protocol/txn_executor.go Updates intrinsic gas calc args for EIP-2780 in the executor path.
execution/protocol/params/protocol.go Introduces EIP-2780 gas constants.
execution/protocol/mdgas/intrinsic_gas.go Implements EIP-2780 intrinsic gas decomposition and floor cost changes.
execution/protocol/mdgas/intrinsic_gas_test.go Adds intrinsic gas unit tests for EIP-2780 reference cases.
execution/engineapi/engineapitester/transactor.go Switches simple transfer gas limit to RPC estimate (EIP-2780 compatible).
execution/engineapi/engine_api_builder_test.go Updates engine API block gas overflow tests for account-creating transfers under Amsterdam.
.github/workflows/test-hive-eest.yml Updates hive/eest workflow failure tolerance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread txnprovider/txpool/pool.go
Comment thread txnprovider/txpool/pool.go
Comment thread execution/vm/evm.go
Comment thread txnprovider/shutter/pool.go

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The intrinsic decomposition and the top-level runtime charges look right for the common paths (reference-table values check out), but there are a few spec divergences to fix:

  1. EIP-7623 floor base (mdgas/intrinsic_gas.go): FloorGasCost is seeded with a flat TxBaseEIP2780 (12,000) for every tx shape. Per the EIP's interactions section, the floor base is the decomposed regular-gas intrinsic of the transaction (21,000 for a value transfer, 15,000 for a zero-value call, 23,000/24,756 for creation, 12,000 only for self-transfer). Undercharges floor-bound (data-heavy) txns by 3,000–12,756 and feeds both the Gas() < FloorGasCost validity check and final txnGasUsed — consensus divergence. Reuse the base computed in the IsEIP2780 branch for both RegularGas and FloorGasCost. TestEIP2780IntrinsicGas currently pins the flat value, but all its cases have empty calldata so the floor never binds — please add a floor-bound case.

  2. Delegated-recipient charge is always cold (chargeTopLevelFrameGas): spec says COLD_ACCOUNT_ACCESS if cold, WARM_ACCESS (100) if warm (delegation target in the access list, == sender, etc.). Mirror the in-frame logic in operations_acl.go (AddressInAccessList(dd) check before AddAddressToAccessList(dd)).

  3. Depth-0 defer reorder in evm.call changes failure accounting for all Amsterdam txns, not just 2780 paths. Zeroing gasUsed.State before deriving Regular fixes the exceptional-halt receipt (previously under-reported), but on depth-0 REVERT the sender now pays the frame's reverted state gas as regular gas, where EIP-8037's refill rules say it's refunded — the old order got revert right and halt wrong, the new order the opposite. The two error kinds need distinguishing. Also create() still has the old order, so CALL-tx vs CREATE-tx failure accounting is now inconsistent, and the comment above the defer still describes the old design.

  4. Pre-execution OOG doesn't revert 7702 delegations: on OOG in chargeTopLevelFrameGas we unwind only the snapshot taken inside call(), but authorizations were applied earlier in TxnExecutor — the spec (test case 10) requires reverting those too ("all state changes are reverted — including any EIP-7702 delegations"). Suggests hoisting these runtime charges into the executor's pre-execution phase, where the auth application is in scope.

  5. Creation state charge vs spec: it stays intrinsic and unconditional (spec: runtime, only if the deployment target doesn't exist — see the created_address.balance > 0 reference row) and is part of the Gas() < Regular+State validity threshold (spec: validity is regular-intrinsic-only; underfunded txns are included and halt). If this is deliberate staging against the devnet-6 pin, a tracking note would help; otherwise it needs aligning. Same question for the worst-case per-auth state gas in validity, and PerAuthBaseCostEIP8037 = 7,500 vs the EIP table's 7,816.

  6. Plumbing (first and third also flagged by Copilot): txpool never sets IsSelfTransfer, so self-transfers are overcharged and valid txns with gas limit in [12k, 21k) get rejected — TxnSlot.Txn.GetTo() plus the senderID mapping make it computable, or comment the conservative choice; shutter ignores GetSender()'s bool; and case IsEIP2780 now shadows case IsAATxn, so under Amsterdam AA txns silently get 2780 (creation) pricing instead of TxAAGas — make the precedence explicit.

Also missing: unit coverage for chargeTopLevelFrameGas itself (warm-target case, OOG-halt receipt, delegated self-transfer).

@yperbasis

Copy link
Copy Markdown
Member

Follow-up to my review: I checked which EIP-2780 revision glamsterdam-devnet-6 actually pins (devnet page: ethereum/EIPs#11645 + ethereum/execution-specs#3017, fixtures tests-glamsterdam-devnet@v6.1.1, cut Jul 2 at d0338f56) and diffed the pinned EELS forks/amsterdam code against each point. The EIP text moved twice around this PR (Jun 25 and Jul 7, the latter adding rakita's intrinsic/runtime restructure), and several of my points were about text that is ahead of the pin. Corrected framing:

Withdrawn as devnet-6 blockers — the PR matches the pinned EELS:

These three become follow-ups for whichever devnet picks up the Jun 25 / Jul 7 EIP revisions — worth a tracking issue so they don't get lost.

Confirmed against the pinned fixtures — these still need fixing:

  • Point 3 (depth-0 defer reorder): pinned EELS runs refill_frame_state_gas on both Revert and ExceptionalHalt, and burns gas_left only on halt (interpreter.py process_message error handling). So on a depth-0 revert the frame's state charges must be refunded to the sender; the reorder makes erigon bill them as regular gas. The old order matched reverts but not halts — the two error kinds need distinguishing, and create() needs the same treatment.
  • New: missing created_target_alive refund. Pinned EELS refunds the creation NEW_ACCOUNT state charge post-execution when the deployment target was already alive, not just on error (fork.py: if isinstance(tx.to, Bytes0) and (tx_output.error is not None or tx_output.created_target_alive)). Erigon only refunds on error (evm.create depth-0 defer), so a create to a prefunded address overcharges 183,600 vs the fixtures.
  • Heads-up, base-branch scope (predates this PR): pinned EELS charges (ACCOUNT_WRITE + REGULAR_PER_AUTH_BASE_COST) = 8,000 + 7,816 = 15,816 regular per authorization; erigon charges PerAuthBaseCostEIP8037 = 7,500. Any 7702 txn diverges from the fixtures by 8,316/auth.

Point 5's validity-boundary concern is withdrawn (pinned EELS also includes intrinsic state gas in the validity check); point 6 (txpool/shutter/AA plumbing) is unaffected by the pin question.

@taratorio

Copy link
Copy Markdown
Member Author

The intrinsic decomposition and the top-level runtime charges look right for the common paths (reference-table values check out), but there are a few spec divergences to fix:

  1. EIP-7623 floor base (mdgas/intrinsic_gas.go): FloorGasCost is seeded with a flat TxBaseEIP2780 (12,000) for every tx shape. Per the EIP's interactions section, the floor base is the decomposed regular-gas intrinsic of the transaction (21,000 for a value transfer, 15,000 for a zero-value call, 23,000/24,756 for creation, 12,000 only for self-transfer). Undercharges floor-bound (data-heavy) txns by 3,000–12,756 and feeds both the Gas() < FloorGasCost validity check and final txnGasUsed — consensus divergence. Reuse the base computed in the IsEIP2780 branch for both RegularGas and FloorGasCost. TestEIP2780IntrinsicGas currently pins the flat value, but all its cases have empty calldata so the floor never binds — please add a floor-bound case.
  2. Delegated-recipient charge is always cold (chargeTopLevelFrameGas): spec says COLD_ACCOUNT_ACCESS if cold, WARM_ACCESS (100) if warm (delegation target in the access list, == sender, etc.). Mirror the in-frame logic in operations_acl.go (AddressInAccessList(dd) check before AddAddressToAccessList(dd)).
  3. Depth-0 defer reorder in evm.call changes failure accounting for all Amsterdam txns, not just 2780 paths. Zeroing gasUsed.State before deriving Regular fixes the exceptional-halt receipt (previously under-reported), but on depth-0 REVERT the sender now pays the frame's reverted state gas as regular gas, where EIP-8037's refill rules say it's refunded — the old order got revert right and halt wrong, the new order the opposite. The two error kinds need distinguishing. Also create() still has the old order, so CALL-tx vs CREATE-tx failure accounting is now inconsistent, and the comment above the defer still describes the old design.
  4. Pre-execution OOG doesn't revert 7702 delegations: on OOG in chargeTopLevelFrameGas we unwind only the snapshot taken inside call(), but authorizations were applied earlier in TxnExecutor — the spec (test case 10) requires reverting those too ("all state changes are reverted — including any EIP-7702 delegations"). Suggests hoisting these runtime charges into the executor's pre-execution phase, where the auth application is in scope.
  5. Creation state charge vs spec: it stays intrinsic and unconditional (spec: runtime, only if the deployment target doesn't exist — see the created_address.balance > 0 reference row) and is part of the Gas() < Regular+State validity threshold (spec: validity is regular-intrinsic-only; underfunded txns are included and halt). If this is deliberate staging against the devnet-6 pin, a tracking note would help; otherwise it needs aligning. Same question for the worst-case per-auth state gas in validity, and PerAuthBaseCostEIP8037 = 7,500 vs the EIP table's 7,816.
  6. Plumbing (first and third also flagged by Copilot): txpool never sets IsSelfTransfer, so self-transfers are overcharged and valid txns with gas limit in [12k, 21k) get rejected — TxnSlot.Txn.GetTo() plus the senderID mapping make it computable, or comment the conservative choice; shutter ignores GetSender()'s bool; and case IsEIP2780 now shadows case IsAATxn, so under Amsterdam AA txns silently get 2780 (creation) pricing instead of TxAAGas — make the precedence explicit.

Also missing: unit coverage for chargeTopLevelFrameGas itself (warm-target case, OOG-halt receipt, delegated self-transfer).

@yperbasis thanks. addressed applicable ones

  1. N/A; glamstedam-devnet-7 spec
  2. N/A; glamstedam-devnet-7 spec
  3. this one is gd6 spec but relates to EIP-8037 more, handled in the 8037 PR - execution: implement EIP-8037 updates for glamsterdam-devnet-6 #22122
  4. N/A; glamstedam-devnet-7 spec
  5. N/A; glamstedam-devnet-7 spec
  6. done in 25dc0ec. about AA - that codepath is currently disabled (we're not accepting any AA txns since that is not a feature landed on ethereum yet; can be revived at a later point when the time comes - code path is ignored for now)
  7. about mentioned tests - the warm case is glamsterdam-devnet-7 spec, the rest are covered by eest spec tests

@yperbasis

Copy link
Copy Markdown
Member

Re-checked after 25dc0ec — the plumbing items are resolved: shutter now checks the GetSender bool, and IsSelfTransfer is correctly populated in both best (via senderID2Addr) and validateTx (works because the parser caches the sender at ingestion via txn.Sender(*signer), so GetSender is populated there; the senderOk guard covers the rest).

Still outstanding — both are pinned devnet-6 fixture semantics (verified against EELS d0338f56), so unlike the withdrawn floor/warm-cold/delegation-revert items they can't be deferred to devnet-7:

  1. Depth-0 defer reorder in evm.call: a reverting top-level frame now bills its state charges as regular gas; pinned EELS runs refill_frame_state_gas on both Revert and ExceptionalHalt and burns gas_left only on halt — so revert must refund, halt must consume. create() still has the old ordering, so CALL-tx vs CREATE-tx failure accounting is also inconsistent.
  2. Missing created_target_alive refund: a creation tx whose deployment target is already alive still pays the 183,600 NEW_ACCOUNT state gas; pinned fork.py refunds it post-execution (error is not None or created_target_alive), not just on error.

Minor leftover: the case IsAATxn arm in CalcIntrinsicGas is dead under Amsterdam (shadowed by case IsEIP2780) — worth making the precedence explicit even if AA×Amsterdam is undefined.

Base automatically changed from glamsterdam-devnet-6-fixtures to main July 10, 2026 04:51
@taratorio
taratorio enabled auto-merge July 10, 2026 05:06
@taratorio taratorio changed the title [DO-NOT-MERGE] execution: implement EIP-2780 resource-based intrinsic transaction gas execution: implement EIP-2780 resource-based intrinsic transaction gas Jul 10, 2026
@taratorio
taratorio disabled auto-merge July 10, 2026 05:14
@taratorio
taratorio enabled auto-merge July 10, 2026 05:14
@taratorio
taratorio added this pull request to the merge queue Jul 10, 2026
Merged via the queue into main with commit 60dd779 Jul 10, 2026
175 of 177 checks passed
@taratorio
taratorio deleted the worktree-gd6-eip-2780 branch July 10, 2026 06:49
Sahil-4555 pushed a commit to Sahil-4555/erigon that referenced this pull request Jul 10, 2026
## Problem

The `eest-spec-enginextests-benchmark-150m-{parallel,sequential}` jobs
intermittently kill their runner — the job log ends with `make: ***
Terminated` followed by `The runner has received a shutdown signal`, the
hosted-runner presentation of the VM exhausting memory. 5 of the last 54
`benchmark-150m` job instances died this way (e.g. [this run on
erigontech#22053](https://github.com/erigontech/erigon/actions/runs/29070572016/job/86291064304),
[an unrelated branch the same
morning](https://github.com/erigontech/erigon/actions/runs/29069679000),
[a sequential-variant
instance](https://github.com/erigontech/erigon/actions/runs/29067842538)),
always at 95–100% of the test phase, independent of the PR under test.

Profiling the 150m shard (locally, pinned to CI parallelism) shows peak
demand of **~16.8 GB against the runner's 16 GB**: a 9.9 GB evm process
footprint (live heap 4.9 GB, roughly doubled by default GOGC; dominated
by `opReturn` return-data buffers and `TemporalMemBatch` write-set
clones during the `test_unchunkified_bytecode` cases, which allocate
10.6–12.1 GB each) plus a 6.9 GB MDBX datadir sitting on the 8 GB
ramdisk — tmpfs bytes and process bytes compete for the same RAM. 1075
of the shard's 1077 tests share one `(fork, preAllocHash)` group, so a
single node's datadir grows for essentially the whole run and the peak
lands at the end. Whether a run survives comes down to GC timing and
randomized test order — hence the flakiness.

## Prior investigation

erigontech#22325 (closed in favour of this PR) profiled the same OOM and pinned
why the shard began flaking on Jul 7: the persistent code cache from
erigontech#22154 grew the benchmark datadir by ~2 GB (5.65 → 7.40 GB peak),
pushing the peak co-resident heap+datadir from ~12 GB to ~14+ GB — see
[the profiling
comment](erigontech#22325 (comment)).
The FCU-less CodeStore growth is being capped separately in erigontech#22335; with
the datadir off RAM, that growth no longer threatens the runner either
way — the two changes are complementary.

## Change

The ramdisk exists for shards that churn hundreds of short-lived
datadirs, where create/unlink journaling dominates. The benchmark shards
are the opposite shape (3 long-lived datadirs), so the ramdisk buys them
no wall time — and costs them the RAM that OOMs the runner.

- `tools/eest-spec-shards.yml`: new per-shard key `no-ramdisk: true`,
set on all 14 `enginextests-benchmark-*` shards. Opt-in-true on purpose:
a `false`-valued key would be invisible to both jq's `//` default and
GitHub expressions' loose `==` (`null == false` is true there).
- `.github/workflows/test-eest-spec.yml`: honors the key — skips
creating the tmpfs. Nothing else sets
`ERIGON_EXECUTION_TESTS_TMPDIR`/`TMPDIR` on Linux (the setup-erigon
TMPDIR override is Windows-gated), so these shards exercise the
env-var-unset path and their datadirs land in the runner's default temp
dir (`/tmp`, on the same root SSD as `$RUNNER_TEMP`).
- `tools/run-eest-spec-test.sh`: the same key also skips the local
(Darwin) auto-ramdisk for these shards — whose 2 GB default the 150m
datadir (~7 GB) could not fit anyway. Local runs likewise fall through
to the OS default temp dir.

Non-benchmark shards are unchanged.

## Wall-time evidence (A/B on identical runners)

[Dispatched run
29073818835](https://github.com/erigontech/erigon/actions/runs/29073818835)
— the 150m shards with datadirs on SSD, vs the five most recent green
ramdisk runs:

| | ramdisk (5 runs) | no ramdisk | delta |
|---|---|---|---|
| 150m-parallel, test phase | 16m40s – 17m06s | 17m09s | +1.8% vs mean |
| 150m-sequential, test phase | 11m14s – 13m06s | 13m22s | +8% vs mean,
within the baseline's own ±8% spread |
| 150m-parallel, job total | 20.3 – 22.5 min | 20.0 min | inside range |
| 150m-sequential, job total | 17.0 – 17.7 min | 17.3 min | inside range
|

Both A/B jobs passed, with ~7 GB more headroom at peak.

**Benchmark semantics note:** these are `--time` throughput shards, so
datadirs-on-disk puts SSD writeback inside the measured path — per-test
wall times (and any MGas/s derived from them) shift slightly at this
PR's boundary; the A/B above bounds it at ~+1.8% for the 150m-parallel
test phase. This is deliberate: production nodes run MDBX on SSD/NVMe
with the OS page cache, so the post-PR numbers are more representative
of real-world execution than tmpfs-backed ones. Treat pre-/post-PR
timings as different baselines when comparing historical job logs.

## Verification

- `actionlint`, `shellcheck`, `bash -n` clean; `make lint` clean.
- Matrix render (`yq -o=json` of the manifest) carries `no-ramdisk`
through to `matrix.*`; row parsing verified for benchmark, stable, and
race-regex shards.
- `make eest-spec-enginextests-benchmark-1m-sequential` run locally on
Darwin through the new path: no auto-ramdisk created, datadirs in the
default temp dir, all tests pass (1076/1076); a stable-shard control
still creates the ramdisk.
- On this PR's CI: every benchmark shard's `Create RAM disk` step is
skipped and its log prints the default-tmpdir routing, while stable
shards keep `tmpdir: /mnt/erigon-ramdisk`; the first CI Gate run had all
eest shards green including both 150m jobs.
- The no-ramdisk configuration was also validated end-to-end by the A/B
run above before this PR.

No Go code changes; this is CI/tooling configuration, so the TDD cycle
does not apply.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Glamsterdam https://eips.ethereum.org/EIPS/eip-7773

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[gd6] implement EIP-2780: Resource-based intrinsic transaction gas

3 participants