Skip to content

feat(eip8130): phased call execution + policy gate + full fee settlement#3696

Merged
chunter-cb merged 16 commits into
mainfrom
hh/eip-8130-phased-calls
Jun 24, 2026
Merged

feat(eip8130): phased call execution + policy gate + full fee settlement#3696
chunter-cb merged 16 commits into
mainfrom
hh/eip-8130-phased-calls

Conversation

@chunter-cb

@chunter-cb chunter-cb commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Drives an EIP-8130 transaction's calls (Vec<Vec<Call>>) as real EVM call frames after the pre-call pipeline, adds the actor policy gate, and reworks gas/fee settlement to route the full fee.

  • Phased call execution — phases share a single gas pool (gas_limit - sender_intrinsic) and commit independently in sequence. Calls within a phase are atomic; if any call reverts (or is policy-gated), that phase's state is discarded and every later phase is skipped, but the tx is still included (nonce consumed, fee paid). Each call dispatches from sender to call.to with msg.value == 0 and tx.origin == sender.
  • Transaction context — publishes sender / payer / sender_actor_id to TxContextStorage before dispatching calls.
  • Policy gate — resolves the sender actor's policy manager once; gates every call.to, reverting the phase with ActorPolicyViolation(bytes32,address) on mismatch.
  • Full fee settlement — pre-charges the worst case (gas + L1 + operator), caps the refund per EIP-3529, then routes base fee, priority tip, L1 cost, and operator fee to their vaults and refunds the surplus to the payer.
  • Status — overall transaction outcome reported via ExecutionResult::Success / Revert.

EIP-8037 (Amsterdam) maps to ForkCondition::Never on Base, so the state-gas reservoir is always zero and is not threaded through call frames.

Test plan

  • eoa_self_pay_transaction_executes_and_charges_sender
  • underfunded_payer_is_rejected
  • single_phase_call_executes_against_contract_code
  • reverting_call_includes_tx_with_revert_status
  • later_phase_skipped_after_earlier_phase_reverts
  • policy_gate_blocks_call_to_unauthorized_target
  • policy_gate_allows_call_to_authorized_target
  • actor_policy_violation_data_is_abi_encoded
  • cargo clippy --features std --all-targets clean
  • cargo check --no-default-features (no_std) compiles

Comment thread crates/common/evm/src/eip8130.rs
Comment thread crates/common/evm/src/eip8130.rs Outdated
Comment thread crates/common/evm/src/eip8130.rs Outdated
chunter-cb and others added 3 commits June 23, 2026 12:30
…ent txns)

Wire base-execution-eip8130 into the EVM via a gas-free, journal-backed
Eip8130Executor invoked from the alloy Evm::transact_raw seam. For an
EIP-8130 transaction the executor bypasses the mainnet single-frame handler
and runs the enshrined pipeline directly against the block journal:
authorize sender/payer + config changes, validate and advance the 2D nonce,
charge the EIP-8130 intrinsic-gas schedule, validate fee caps and debit the
payer, and apply account changes (config/create/delegation) including the
deferred code installs and code-less-EOA auto-delegation.

Scope: account-management transactions only (empty `calls`). Call-bearing
transactions are rejected until phased call execution lands; fee settlement
routes base fee to the base-fee vault and the tip to the beneficiary (L1 and
operator fee routing deferred). RPC/ingress gates remain closed.

Adds the BaseTransactionError::Eip8130 rejection variant (mapped to a new
RPC Eip8130Rejected error) and end-to-end BaseEvm tests covering EOA
self-pay execution, call-bearing rejection, and underfunded-payer rejection.

The std-only executor and its base-execution-eip8130 / base-precompile-storage
deps are optional and gated behind the `std` feature so no_std (proof/zkVM)
builds are unaffected; no_std and the lower-level transact_one / inspect_one_tx
paths reject 8130 txns fail-loud. The payer is debited with checked_sub, and a
BaseTransactionError::eip8130 helper collapses the pipeline error mapping.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
- Reject (rather than saturate) a block timestamp that exceeds u64, so a
  clamped value can never silently shift expiry validation in the
  authorizer / nonce validator on this consensus-critical path.
- Reuse the original enveloped wire bytes captured during from_encoded_tx
  for intrinsic-gas metering instead of re-encoding the signed envelope,
  avoiding an allocation and the byte-identical re-encoding assumption.
- Document that auto-delegation of a code-less EOA sender to DEFAULT_ACCOUNT
  is unconditional: an explicit non-zero delegation is preserved, while
  clearing it leaves the basic-account sender re-delegated.
- Cover the BaseTransactionError::Eip8130 variant with Display and serde
  round-trip tests so its RPC-facing format is locked in.
@chunter-cb chunter-cb force-pushed the hh/eip-8130-handler-precall branch from eacb602 to 04583ad Compare June 23, 2026 16:31
@chunter-cb chunter-cb force-pushed the hh/eip-8130-phased-calls branch from 8da372c to 9638ae5 Compare June 23, 2026 16:38
Comment thread crates/common/evm/src/eip8130.rs
Comment thread crates/common/evm/src/eip8130.rs Outdated
Comment thread crates/common/evm/src/eip8130.rs Outdated
Comment thread crates/common/evm/src/eip8130.rs
Comment thread crates/common/evm/src/eip8130.rs
The enshrined EIP-8130 executor lives in base-common-evm and calls the
EIP-8130 protocol logic (authorize / nonce / intrinsic-gas / apply) in
base-execution-eip8130, which is also consumed by the execution-layer
txpool/builder. The crate is foundational protocol logic rather than
execution-specific, so add it to the dependency-check ALLOWED_DEPS
exception list (to be relocated under crates/common/ in a follow-up).
@chunter-cb chunter-cb force-pushed the hh/eip-8130-phased-calls branch from 9638ae5 to 17f00ca Compare June 23, 2026 16:55
Comment thread crates/common/evm/src/eip8130.rs Outdated
Comment thread crates/common/evm/src/eip8130.rs
Comment thread crates/common/evm/src/eip8130.rs
…ement

Drive an EIP-8130 transaction's `calls` (Vec<Vec<Call>>) as real EVM call
frames after the pre-call pipeline. Phases share a single gas pool and
commit independently: calls within a phase are atomic, and if any call
reverts (or is blocked by the policy gate) that phase is discarded and all
later phases are skipped while the transaction is still included.

- Publish the transaction context (sender / payer / sender_actor_id) and set
  tx.origin to the resolved sender before dispatching calls.
- Resolve the sender actor's policy manager once and gate every call.to,
  reverting the phase with ActorPolicyViolation(bytes32,address) on mismatch.
- Rework gas/fee settlement: pre-charge the worst-case (gas + L1 + operator),
  cap the refund per EIP-3529, then route base fee, priority tip, L1 cost,
  and operator fee to their vaults and refund the surplus to the payer.
- Surface overall status via ExecutionResult::Success/Revert.

Tests cover single-phase execution, reverting calls, multi-phase skip-on-
revert atomicity, the policy gate (allowed/blocked), and the revert ABI
encoding.
- Document that the receipt's gas-refunded counter is intentionally left 0:
  the refund is already folded into gas_used via net_used in settle_fees,
  and the per-phase receipt breakdown is deferred.
- Hardcode the ActorPolicyViolation(bytes32,address) selector (0x1f1c0d27)
  as a const instead of recomputing keccak256 on every policy-gate revert;
  the existing actor_policy_violation_data_is_abi_encoded test pins it to
  the canonical signature so it cannot drift.
@chunter-cb chunter-cb force-pushed the hh/eip-8130-phased-calls branch from 17f00ca to a565676 Compare June 23, 2026 17:06
Address review feedback on the phased-call executor:

- Replace `saturating_add` on the payer refund with `checked_add` + error,
  consistent with the checked-arithmetic discipline used elsewhere; a
  silent clamp to U256::MAX would mint ETH.
- Replace `saturating_sub` on the per-phase gas pool with `checked_sub` +
  error to surface an EVM invariant violation (a call spending more gas
  than the pool held) instead of clamping to 0.
- Clear the cached tx L1 cost on the prepay / execute_calls / settle_fees
  error paths so a stale value can't leak into the next transaction in the
  block, matching the mainnet handler's catch_error cleanup.
- Reclaim the LocalContext buffer and drain the frame stack after commit
  (local_mut().clear() / frame_stack().clear()) for parity with the
  mainnet handler's post-commit cleanup.
@chunter-cb chunter-cb marked this pull request as ready for review June 23, 2026 18:29
Resolve two review verification/spec-confirmation threads with comments:

- Note that revm's checkpoint_commit merges a committed phase savepoint
  into its parent, so an outer checkpoint_revert still rolls committed
  phases back; phases are only durable once commit_tx runs.
- Note that the EIP-3529 refund-cap denominator intentionally includes
  sender_intrinsic, matching the mainnet refund-cap convention.
Base automatically changed from hh/eip-8130-handler-precall to main June 23, 2026 20:48
@cb-heimdall

cb-heimdall commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Heimdall Review Status

Requirement Status More Info
Reviews 1/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

# Conflicts:
#	crates/common/evm/src/eip8130.rs
#	crates/common/evm/src/evm.rs
Comment thread crates/common/evm/src/eip8130.rs
Comment thread crates/common/evm/src/eip8130.rs
Note that wrapping the LocalContext's shared memory buffer (rather than
allocating per call) mirrors the mainnet handler's first-frame init: the
Rc::clone is a refcount bump, so every call reuses the same backing
allocation, and the per-tx buffer is reclaimed by local_mut().clear()
after commit_tx. Pre-empts the review question on per-call allocation.
Each `call` in an EIP-8130 transaction runs as an independent top-level
frame, but they share one journal (no commit_tx between calls), so a
slot's transaction-start original value persists across calls. The prior
code summed each call's refund floored at zero
(`gas.refunded().max(0)`), which discarded a call's negative refund delta
when it re-dirtied a slot a previous call had cleared — over-refunding
the payer relative to standard EVM accounting and diverging gas_used on a
consensus-critical path.

Accumulate the signed refund (i64) across all committed calls/phases and
clamp to >= 0 once, then apply the EIP-3529 cap, in a new
`capped_refund` helper. Offsetting SSTORE refunds now cancel exactly as
under one continuous execution.

Tests:
- capped_refund_uses_signed_transaction_level_accounting: pins the
  signed-sum-then-clamp-then-cap policy.
- cross_call_offsetting_refunds_cancel: clears a slot in call 1, then
  restores vs re-clears it in call 2 with gas-cost- and intrinsic-
  identical calldata; the restore tx must report strictly more gas_used,
  which fails if per-call refund clamping regresses.
Comment thread crates/common/evm/src/eip8130.rs
Two review follow-ups, both confirmed correct as-is:

- run_call uses CallValue::Transfer(U256::ZERO): document that this is
  exactly what a zero-value CALL opcode lowers to (Apparent is for
  DELEGATECALL), so msg.value reads 0 and the target is touched per CALL
  semantics. Touching an empty target is a no-op under EIP-161, and no
  new-account gas differs from Apparent — the classic 25000 charge is at
  the CALL-opcode gas site (bypassed by the directly-built frame) and
  applies only when value > 0.
- Expand the SharedMemory comment to note that run_exec_loop merely drops
  the wrapper on completion (refcount decrement); the backing Vec stays
  owned by the LocalContext, so the next call reuses it with no per-call
  allocation and no "return to pool" step.
@chunter-cb chunter-cb enabled auto-merge June 24, 2026 00:20
Comment thread crates/common/evm/src/eip8130.rs
Comment thread crates/common/evm/src/eip8130.rs
@github-actions

Copy link
Copy Markdown
Contributor

Review Summary — EIP-8130 Phased Call Execution

Overall: Well-structured implementation. The pre-charge / settle / refund architecture is sound, the checkpoint nesting is correct, and the test coverage (including the cross-call refund cancellation test) is thorough. Several issues from the prior review round have been addressed in the current code (checked arithmetic on payer refund, clear_tx_l1_cost() on error paths, local_mut().clear() / frame_stack().clear() on the success path). The remaining inline comments are mostly spec-confirmation requests rather than bugs.

Already addressed (no action needed)

  • Payer refund overflow — now uses checked_add with error propagation (line 754), matching the checked-arithmetic discipline elsewhere.
  • Selector precomputationSELECTOR is a const [u8; 4] (line 789), pinned by the actor_policy_violation_data_is_abi_encoded test.
  • L1 cost cache clearing — all post-prepay error paths call clear_tx_l1_cost().
  • Post-commit cleanuplocal_mut().clear() and frame_stack().clear() are called on the success path (lines 268–269).
  • Gas pool checked_sub — pool decrement uses checked_sub with an explicit error (line 555), not saturating_sub.

Open items from inline comments worth confirming

# Area Nature
1 Error-path cleanup (lines 237–242, 249–253) execute_calls and settle_fees error paths clear the L1 cost and revert the checkpoint, but do not call local_mut().clear() / frame_stack().clear(). In practice these errors are only database failures (fatal to block execution), but for defensive parity with the mainnet catch_error handler, consider adding both calls.
2 Journal checkpoint_commit semantics (line 590) The correctness of the outer-checkpoint revert undoing inner-committed phases depends on checkpoint_commit being "merge into parent" — verify this is the journal's contract.
3 Operator fee linearity assumption (lines 488, 734) settle_fees recomputes operator cost from scratch with billable_gas rather than using the mainnet charge(gas_limit) - charge(gas_used) delta pattern. Equivalent today (linear), but would diverge under a non-linear fee schedule.
4 Single-hop EIP-7702 delegation (line 628) run_call resolves one delegation level; a multi-hop chain (delegated target itself delegates) would not be followed. EIP-7702 targets are expected to be concrete contracts, so this is likely fine — confirm against spec.

Tests

The 10 test cases cover the key execution paths well: self-pay, underfunded payer, single-phase call, revert inclusion, phase skipping, policy gate (allow/block), ABI encoding, and cross-call refund cancellation. The cross_call_offsetting_refunds_cancel test is a particularly good regression guard for the signed-refund accounting.

@github-actions

Copy link
Copy Markdown
Contributor

✅ base-std fork tests: all 616 passed

base/base is fully in sync with the base-std spec.

Dependency Ref Commit
base-std main 4658f1b7
base-anvil 0092692587d8d064dd2c6923ce26a682c58f3694 00926925

@chunter-cb chunter-cb added this pull request to the merge queue Jun 24, 2026
Merged via the queue into main with commit 72d25e2 Jun 24, 2026
24 checks passed
@chunter-cb chunter-cb deleted the hh/eip-8130-phased-calls branch June 24, 2026 15:09
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.

3 participants