feat(eip8130): phased call execution + policy gate + full fee settlement#3696
Conversation
034fb90 to
5c51344
Compare
…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.
eacb602 to
04583ad
Compare
8da372c to
9638ae5
Compare
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).
9638ae5 to
17f00ca
Compare
…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.
17f00ca to
a565676
Compare
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.
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.
✅ Heimdall Review Status
|
# Conflicts: # crates/common/evm/src/eip8130.rs # crates/common/evm/src/evm.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.
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.
Review Summary — EIP-8130 Phased Call ExecutionOverall: 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, Already addressed (no action needed)
Open items from inline comments worth confirming
TestsThe 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 |
✅ base-std fork tests: all 616 passedbase/base is fully in sync with the base-std spec.
|
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.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 fromsendertocall.towithmsg.value == 0andtx.origin == sender.sender_actor_idtoTxContextStoragebefore dispatching calls.call.to, reverting the phase withActorPolicyViolation(bytes32,address)on mismatch.ExecutionResult::Success/Revert.EIP-8037 (Amsterdam) maps to
ForkCondition::Neveron 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_senderunderfunded_payer_is_rejectedsingle_phase_call_executes_against_contract_codereverting_call_includes_tx_with_revert_statuslater_phase_skipped_after_earlier_phase_revertspolicy_gate_blocks_call_to_unauthorized_targetpolicy_gate_allows_call_to_authorized_targetactor_policy_violation_data_is_abi_encodedcargo clippy --features std --all-targetscleancargo check --no-default-features(no_std) compiles