Skip to content

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

Draft
chunter-cb wants to merge 2 commits into
hh/eip-8130-handler-precallfrom
hh/eip-8130-phased-calls
Draft

feat(eip8130): phased call execution + policy gate + full fee settlement#3696
chunter-cb wants to merge 2 commits into
hh/eip-8130-handler-precallfrom
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

…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.
@chunter-cb chunter-cb force-pushed the hh/eip-8130-phased-calls branch from 034fb90 to 5c51344 Compare June 22, 2026 18:42
logs: Vec::new(),
output: Output::Call(Bytes::new()),
})
let result_gas = ResultGas::new_with_state_gas(gas_used, 0, 0, 0);

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.

The second ResultGas argument (refunded) is hardcoded to 0, so the gas refund won't be surfaced in the receipt's gas_refunded field. The refund is correctly applied to gas_used (via net_used in settle_fees), so the fee accounting is right, but downstream receipt consumers expecting a populated refund counter won't see one.

I see the "Scope" section notes that per-phase receipt details are deferred — if the refund counter omission is intentional for now, consider adding a brief comment here (e.g., // refund already folded into gas_used; per-phase breakdown deferred).

Comment thread crates/common/evm/src/eip8130.rs Outdated
/// ABI-encodes the `ActorPolicyViolation(bytes32 actorId, address target)`
/// protocol revert: the 4-byte selector followed by the two 32-byte words.
fn actor_policy_violation_data(actor_id: B256, target: Address) -> Bytes {
let selector = keccak256(b"ActorPolicyViolation(bytes32,address)");

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.

The keccak256 selector is recomputed on every policy-gate violation. Since this is a constant ABI selector, consider computing it once as a module-level constant:

Suggested change
let selector = keccak256(b"ActorPolicyViolation(bytes32,address)");
fn actor_policy_violation_data(actor_id: B256, target: Address) -> Bytes {
/// `keccak256(b"ActorPolicyViolation(bytes32,address)")[..4]`
const SELECTOR: [u8; 4] = [0x2c, 0x49, 0x48, 0xad];
let mut out = Vec::with_capacity(4 + 32 + 32);
out.extend_from_slice(&SELECTOR);
out.extend_from_slice(actor_id.as_slice());
out.extend_from_slice(target.into_word().as_slice());
Bytes::from(out)
}

(Verify the selector bytes match keccak256(b"ActorPolicyViolation(bytes32,address)")[..4].)

This avoids a hash computation on the hot path, though in practice this path is only hit on policy violations so it's not critical.

- 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.

let result = frame.interpreter_result().result;
if result.is_ok() {
phase_refund = phase_refund.saturating_add(gas.refunded().max(0) as u64);

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.

gas.refunded() returns i64, and the as u64 cast after .max(0) is safe. However, if the refund counter is accumulated across multiple successful calls within a single phase by clamping each call's contribution to max(0) independently, it could over-count versus the true aggregate. Consider: call A clears a storage slot (+4800 refund), call B re-dirties it (−4800 refund). Clamped per-call: 4800 + 0 = 4800. Aggregated as i64 first: 4800 + (−4800) = 0.

In practice this likely doesn't arise because each call dispatched via run_exec_loop gets an independent Gas struct (fresh frame at depth 0), so SSTORE refund interactions across calls don't propagate through the refund counter. But it's worth confirming that the EIP-8130 spec intends per-call refund isolation. If cross-call refund cancellation should apply within a phase, the accumulator should be i64 and clamped once before the EIP-3529 cap in settle_fees.

@github-actions

Copy link
Copy Markdown
Contributor

Review Summary — EIP-8130 Phased Call Execution + Policy Gate + Fee Settlement

Thorough review of the two changed files (eip8130.rs +663/−147, evm.rs +1/−1). The implementation is well-structured with careful attention to consensus-critical invariants.

What was verified

  • Checkpoint lifecycle: Outer checkpoint at the top of execute() is correctly paired — committed on the success path, reverted on every error path (authorize, prepay, execute_calls, settle_fees).
  • Gas accounting: total_gas_spent() from per-call FrameResult tracks raw gas consumed; refunds are accumulated separately and the EIP-3529 cap is applied once in settle_fees. The pool is correctly decremented without giving refunds back as available gas for subsequent calls.
  • Prepay/settle fee pattern: Worst-case charge (full gas_limit + payer_auth at effective price + L1 cost + operator fee) is debited in prepay; the actual cost is computed in settle_fees and the surplus refunded. Verified that prepay >= total_cost holds by construction (operator fee and gas charge are monotonically increasing in gas amount).
  • L1 cost cache: calculate_tx_l1_cost is called with identical (encoded, spec) in both prepay and settle_fees, so the cache returns the correct value. clear_tx_l1_cost() is called after commit.
  • Policy gate: Correctly checked before each call dispatch; a policy violation fails the phase without consuming call gas, and the ActorPolicyViolation selector is pinned by a test against the canonical keccak256.
  • EIP-7702 delegation resolution: run_call correctly follows delegation designators to resolve the actual bytecode, mirroring the mainnet path.
  • Checked arithmetic: All fee computations use checked_mul/checked_sub/checked_add with explicit error messages; saturating_add is used only where overflow is provably impossible (refund back to payer whose balance was reduced by the prepay amount).
  • Error conversions: From<BaseTransactionError> for EVMError is properly implemented and used consistently.

Findings

One inline comment posted regarding per-call refund accumulation in execute_calls (line 535): gas.refunded() returns i64, and clamping each call's refund independently to max(0) before accumulating as u64 could theoretically over-count if cross-call refund cancellation is expected within a phase. In practice, each call runs as an independent top-level frame with a fresh Gas struct, so this is likely safe — but worth confirming against the EIP-8130 spec's intent for intra-phase refund semantics.

No correctness, safety, or concurrency issues found beyond the above observation.

@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

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