feat(eip8130): phased call execution + policy gate + full fee settlement#3696
feat(eip8130): phased call execution + policy gate + full fee settlement#3696chunter-cb wants to merge 2 commits into
Conversation
…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.
034fb90 to
5c51344
Compare
| logs: Vec::new(), | ||
| output: Output::Call(Bytes::new()), | ||
| }) | ||
| let result_gas = ResultGas::new_with_state_gas(gas_used, 0, 0, 0); |
There was a problem hiding this comment.
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).
| /// 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)"); |
There was a problem hiding this comment.
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:
| 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); |
There was a problem hiding this comment.
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.
Review Summary — EIP-8130 Phased Call Execution + Policy Gate + Fee SettlementThorough review of the two changed files ( What was verified
FindingsOne inline comment posted regarding per-call refund accumulation in No correctness, safety, or concurrency issues found beyond the above observation. |
✅ 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