arb_evm/multi_gas/
inspector.rs

1//! revm inspector that accumulates per-transaction multi-gas by dimension.
2//!
3//! Each opcode's observed gas delta (`step` to `step_end`) is split with
4//! [`classify`]. Frame-spawning opcodes are special: their delta includes the
5//! gas forwarded to the child frame, whose own opcodes are observed separately,
6//! so the forwarded portion is removed via the `call`/`create` hooks. Contract
7//! code-deposit gas is charged at frame return rather than at an opcode, so it
8//! is added in `create_end`.
9
10use alloy_evm::Database;
11use alloy_primitives::{Address, B256, U256};
12use arb_primitives::multigas::MultiGas;
13use parking_lot::Mutex;
14use revm::{
15    Context, Inspector, Journal,
16    bytecode::opcode,
17    context::{JournalEntry, JournalInner},
18    interpreter::{
19        CallInputs, CallOutcome, CallValue, CreateInputs, CreateOutcome, Interpreter,
20        interpreter::EthInterpreter,
21        interpreter_types::{InputsTr, Jumps},
22    },
23};
24
25/// EVM context the inspector observes, generic over the block/tx/cfg/chain
26/// environments so it works on both the top-level executor and the Stylus
27/// sub-call interpreter; only the journal (fixed to `Journal<DB>`) is read.
28type Ctx<B, T, C, DB, Ch> = Context<B, T, C, DB, Journal<DB>, Ch>;
29use std::sync::Arc;
30
31use crate::multi_gas::classify::{OpKind, classify};
32
33/// Shared slot a [`MultiGasInspector`] writes each transaction's multi-gas to,
34/// read by the block executor after execution.
35pub type MultiGasSink = Arc<Mutex<Option<MultiGas>>>;
36
37const WARM: u64 = 100; // WarmStorageReadCostEIP2929
38const CREATE_DATA_GAS: u64 = 200; // CreateDataGas (code storage, per byte)
39
40/// Accumulates per-transaction multi-gas across every executed frame.
41#[derive(Debug, Default)]
42pub struct MultiGasInspector {
43    prev_gas: u64,
44    pending: Pending,
45    accumulated: MultiGas,
46    sink: Option<MultiGasSink>,
47    /// Count of frames opened (call/create) and not yet closed. The outermost
48    /// frame closes when this returns to zero; the journal depth cannot be used
49    /// for this because a create frame can leave it offset.
50    open_frames: u32,
51}
52
53#[derive(Debug, Default)]
54enum Pending {
55    #[default]
56    None,
57    /// SLOAD (`account = false`) or BALANCE/EXTCODESIZE/EXTCODEHASH
58    /// (`account = true`): the cold surcharge is the whole dynamic cost, so
59    /// cold is read off the step delta.
60    DeltaCold {
61        account: bool,
62    },
63    Log {
64        topics: u8,
65        data_len: u64,
66    },
67    ExtCodeCopy {
68        cold: bool,
69        words: u64,
70    },
71    SelfDestruct {
72        cold: bool,
73        new_account: bool,
74    },
75    /// SSTORE needs the committed value, only reliable after the write; the new
76    /// value, warmth, and pre-write current value are captured at step time.
77    SStore {
78        cold: bool,
79        contract: Address,
80        key: U256,
81        new: U256,
82        current: Option<U256>,
83    },
84    /// CALL/CREATE family. `delta` is filled at `step_end`; the own cost is
85    /// resolved in the matching `call`/`create` hook.
86    Frame {
87        cold: bool,
88        is_create: bool,
89        is_plain_call: bool,
90        delta: u64,
91    },
92    Other,
93}
94
95impl MultiGasInspector {
96    /// Creates an inspector that publishes each transaction's multi-gas to a
97    /// shared sink when the top-level frame returns.
98    pub fn with_sink(sink: MultiGasSink) -> Self {
99        Self {
100            sink: Some(sink),
101            ..Default::default()
102        }
103    }
104
105    /// Returns the accumulated multi-gas and resets for the next transaction.
106    pub fn take_multi_gas(&mut self) -> MultiGas {
107        self.flush_dangling_frame();
108        self.pending = Pending::None;
109        self.prev_gas = 0;
110        self.open_frames = 0;
111        core::mem::replace(&mut self.accumulated, MultiGas::zero())
112    }
113
114    /// Publishes the accumulated multi-gas to the sink and resets, called when
115    /// the outermost frame returns.
116    fn publish(&mut self) {
117        if self.sink.is_some() {
118            let gas = self.take_multi_gas();
119            if let Some(sink) = &self.sink {
120                *sink.lock() = Some(gas);
121            }
122        }
123    }
124
125    fn add(&mut self, gas: MultiGas) {
126        self.accumulated = self.accumulated.saturating_add(gas);
127    }
128
129    /// Records a frame closing. When the outermost frame closes (no frames left
130    /// open) the transaction's multi-gas is complete and is published.
131    fn frame_closed(&mut self) {
132        self.open_frames = self.open_frames.saturating_sub(1);
133        if self.open_frames == 0 {
134            self.publish();
135        }
136    }
137
138    /// A frame opcode that halted before forwarding (e.g. out of gas) never
139    /// reaches its `call`/`create` hook; classify it from the full delta.
140    fn flush_dangling_frame(&mut self) {
141        if let Pending::Frame {
142            cold,
143            is_create,
144            delta,
145            ..
146        } = self.pending
147        {
148            let gas = if is_create {
149                MultiGas::computation_gas(delta)
150            } else {
151                classify(
152                    OpKind::Call {
153                        cold,
154                        new_account: false,
155                    },
156                    delta,
157                )
158            };
159            self.add(gas);
160            self.pending = Pending::None;
161        }
162    }
163}
164
165impl<B, T, C, DB: Database, Ch> Inspector<Ctx<B, T, C, DB, Ch>, EthInterpreter>
166    for MultiGasInspector
167{
168    fn step(&mut self, interp: &mut Interpreter<EthInterpreter>, ctx: &mut Ctx<B, T, C, DB, Ch>) {
169        self.flush_dangling_frame();
170        self.prev_gas = interp.gas.remaining();
171        let op = interp.bytecode.opcode();
172        let journal = &ctx.journaled_state.inner;
173        self.pending = match op {
174            opcode::SLOAD => Pending::DeltaCold { account: false },
175            opcode::BALANCE | opcode::EXTCODESIZE | opcode::EXTCODEHASH => {
176                Pending::DeltaCold { account: true }
177            }
178            opcode::EXTCODECOPY => Pending::ExtCodeCopy {
179                cold: address_cold(journal, addr_arg(interp, 0)),
180                words: word_count(peek(interp, 3)),
181            },
182            opcode::LOG0..=opcode::LOG4 => Pending::Log {
183                topics: op - opcode::LOG0,
184                data_len: to_u64(peek(interp, 1)),
185            },
186            opcode::SSTORE => {
187                let contract = interp.input.target_address();
188                let key = peek(interp, 0);
189                Pending::SStore {
190                    cold: slot_cold(journal, contract, key),
191                    contract,
192                    key,
193                    new: peek(interp, 1),
194                    current: slot_values(journal, contract, key).map(|(_, present)| present),
195                }
196            }
197            opcode::SELFDESTRUCT => {
198                let beneficiary = addr_arg(interp, 0);
199                let contract = interp.input.target_address();
200                Pending::SelfDestruct {
201                    cold: address_cold(journal, beneficiary),
202                    new_account: account_empty(journal, beneficiary)
203                        && !account_balance_zero(journal, contract),
204                }
205            }
206            opcode::CALL | opcode::CALLCODE => Pending::Frame {
207                cold: address_cold(journal, addr_arg(interp, 1)),
208                is_create: false,
209                is_plain_call: op == opcode::CALL,
210                delta: 0,
211            },
212            opcode::DELEGATECALL | opcode::STATICCALL => Pending::Frame {
213                cold: address_cold(journal, addr_arg(interp, 1)),
214                is_create: false,
215                is_plain_call: false,
216                delta: 0,
217            },
218            opcode::CREATE | opcode::CREATE2 => Pending::Frame {
219                cold: false,
220                is_create: true,
221                is_plain_call: false,
222                delta: 0,
223            },
224            _ => Pending::Other,
225        };
226    }
227
228    fn step_end(
229        &mut self,
230        interp: &mut Interpreter<EthInterpreter>,
231        ctx: &mut Ctx<B, T, C, DB, Ch>,
232    ) {
233        let delta = self.prev_gas.saturating_sub(interp.gas.remaining());
234        let pending = core::mem::replace(&mut self.pending, Pending::None);
235        let gas = match pending {
236            Pending::Frame {
237                cold,
238                is_create,
239                is_plain_call,
240                ..
241            } => {
242                self.pending = Pending::Frame {
243                    cold,
244                    is_create,
245                    is_plain_call,
246                    delta,
247                };
248                return;
249            }
250            Pending::None => return,
251            Pending::DeltaCold { account } => {
252                let cold = delta > WARM;
253                let kind = if account {
254                    OpKind::AccountAccess { cold }
255                } else {
256                    OpKind::StorageRead { cold }
257                };
258                classify(kind, delta)
259            }
260            Pending::Log { topics, data_len } => classify(OpKind::Log { topics, data_len }, delta),
261            Pending::ExtCodeCopy { cold, words } => {
262                classify(OpKind::ExtCodeCopy { cold, words }, delta)
263            }
264            Pending::SelfDestruct { cold, new_account } => {
265                classify(OpKind::SelfDestruct { cold, new_account }, delta)
266            }
267            Pending::SStore {
268                cold,
269                contract,
270                key,
271                new,
272                current,
273            } => {
274                let original = slot_values(&ctx.journaled_state.inner, contract, key)
275                    .map(|(original, _)| original)
276                    .unwrap_or(U256::ZERO);
277                let present = current.unwrap_or(original);
278                classify(
279                    OpKind::StorageWrite {
280                        cold,
281                        original,
282                        present,
283                        new,
284                    },
285                    delta,
286                )
287            }
288            Pending::Other => classify(OpKind::Other, delta),
289        };
290        self.add(gas);
291    }
292
293    fn call(
294        &mut self,
295        ctx: &mut Ctx<B, T, C, DB, Ch>,
296        inputs: &mut CallInputs,
297    ) -> Option<CallOutcome> {
298        self.open_frames += 1;
299        if let Pending::Frame {
300            cold,
301            is_create: false,
302            is_plain_call,
303            delta,
304        } = self.pending
305        {
306            self.pending = Pending::None;
307            let value_transfer = matches!(inputs.value, CallValue::Transfer(v) if !v.is_zero());
308            let own = call_own_cost(delta, inputs.gas_limit);
309            let new_account = is_plain_call
310                && value_transfer
311                && account_empty(&ctx.journaled_state.inner, inputs.target_address);
312            self.add(classify(OpKind::Call { cold, new_account }, own));
313        }
314        None
315    }
316
317    fn call_end(
318        &mut self,
319        _ctx: &mut Ctx<B, T, C, DB, Ch>,
320        _inputs: &CallInputs,
321        _outcome: &mut CallOutcome,
322    ) {
323        self.frame_closed();
324    }
325
326    fn create(
327        &mut self,
328        _ctx: &mut Ctx<B, T, C, DB, Ch>,
329        inputs: &mut CreateInputs,
330    ) -> Option<CreateOutcome> {
331        self.open_frames += 1;
332        if let Pending::Frame {
333            is_create: true,
334            delta,
335            ..
336        } = self.pending
337        {
338            self.pending = Pending::None;
339            let own = delta.saturating_sub(inputs.gas_limit());
340            self.add(MultiGas::computation_gas(own));
341        }
342        None
343    }
344
345    fn create_end(
346        &mut self,
347        _ctx: &mut Ctx<B, T, C, DB, Ch>,
348        _inputs: &CreateInputs,
349        outcome: &mut CreateOutcome,
350    ) {
351        if outcome.result.is_ok() {
352            let deposit = (outcome.result.output.len() as u64).saturating_mul(CREATE_DATA_GAS);
353            self.add(MultiGas::storage_growth_gas(deposit));
354        }
355        self.frame_closed();
356    }
357}
358
359/// Own cost of a call opcode: the step delta minus the child's full gas limit.
360/// The child limit includes any value-transfer stipend; the stipend and any
361/// unused forwarded gas are returned to the caller when the child frame ends,
362/// so they belong to the child's accounting, not the caller's own cost.
363fn call_own_cost(delta: u64, child_gas_limit: u64) -> u64 {
364    delta.saturating_sub(child_gas_limit)
365}
366
367fn peek(interp: &Interpreter<EthInterpreter>, from_top: usize) -> U256 {
368    let data = interp.stack.data();
369    data.len()
370        .checked_sub(from_top + 1)
371        .map(|i| data[i])
372        .unwrap_or(U256::ZERO)
373}
374
375fn addr_arg(interp: &Interpreter<EthInterpreter>, from_top: usize) -> Address {
376    Address::from_word(B256::from(peek(interp, from_top).to_be_bytes::<32>()))
377}
378
379fn word_count(len: U256) -> u64 {
380    to_u64(len).div_ceil(32)
381}
382
383fn to_u64(v: U256) -> u64 {
384    u64::try_from(v).unwrap_or(u64::MAX)
385}
386
387fn address_cold(journal: &JournalInner<JournalEntry>, addr: Address) -> bool {
388    if journal.warm_addresses.is_warm(&addr) {
389        return false;
390    }
391    match journal.state.get(&addr) {
392        Some(account) => account.is_cold_transaction_id(journal.transaction_id),
393        None => true,
394    }
395}
396
397fn slot_cold(journal: &JournalInner<JournalEntry>, addr: Address, key: U256) -> bool {
398    match journal.state.get(&addr).and_then(|a| a.storage.get(&key)) {
399        Some(slot) => slot.is_cold_transaction_id(journal.transaction_id),
400        None => true,
401    }
402}
403
404fn slot_values(
405    journal: &JournalInner<JournalEntry>,
406    addr: Address,
407    key: U256,
408) -> Option<(U256, U256)> {
409    let slot = journal.state.get(&addr)?.storage.get(&key)?;
410    Some((slot.original_value, slot.present_value))
411}
412
413fn account_empty(journal: &JournalInner<JournalEntry>, addr: Address) -> bool {
414    match journal.state.get(&addr) {
415        Some(account) => {
416            account.info.balance.is_zero()
417                && account.info.nonce == 0
418                && account.info.code_hash == revm::primitives::KECCAK_EMPTY
419        }
420        None => true,
421    }
422}
423
424fn account_balance_zero(journal: &JournalInner<JournalEntry>, addr: Address) -> bool {
425    match journal.state.get(&addr) {
426        Some(account) => account.info.balance.is_zero(),
427        None => true,
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn call_own_cost_strips_forwarded_gas() {
437        // delta = own (2600 cold + 9000 value + 100 warm) + forwarded 50000.
438        let delta = 2_600 + 9_000 + 100 + 50_000;
439        assert_eq!(call_own_cost(delta, 50_000), 2_600 + 9_000 + 100);
440    }
441
442    #[test]
443    fn call_own_cost_strips_value_transfer_stipend() {
444        // A value transfer to an EOA forwards no gas; the child limit is just the
445        // 2300 stipend, which the EOA does not spend and revm returns to the
446        // caller. So the caller's own cost is the call cost minus the stipend.
447        const CALL_STIPEND: u64 = 2_300;
448        let call_cost = 2_600 + 9_000; // cold access + value transfer
449        let delta = call_cost; // stipend is not deducted from the caller
450        assert_eq!(call_own_cost(delta, CALL_STIPEND), call_cost - CALL_STIPEND);
451    }
452
453    #[test]
454    fn call_own_cost_saturates() {
455        assert_eq!(call_own_cost(100, 50_000), 0);
456    }
457
458    #[test]
459    fn word_count_rounds_up() {
460        assert_eq!(word_count(U256::ZERO), 0);
461        assert_eq!(word_count(U256::from(1)), 1);
462        assert_eq!(word_count(U256::from(32)), 1);
463        assert_eq!(word_count(U256::from(33)), 2);
464    }
465}