arb_stylus/
evm_api_impl.rs

1use std::collections::HashMap;
2
3use alloy_primitives::{Address, B256, Log, U256};
4use arb_chainspec::arbos_version::ARBOS_VERSION_STYLUS_LAST_CODE_CACHE_FIX;
5use arb_primitives::multigas::MultiGas;
6use revm::Database;
7
8use crate::{
9    evm_api::{CreateResponse, EvmApi, UserOutcomeKind},
10    ink::Gas,
11    multi_gas,
12};
13
14/// EIP-2929 gas costs for storage operations.
15const COLD_SLOAD_COST: u64 = 2100;
16const WARM_STORAGE_READ_COST: u64 = 100;
17const COLD_ACCOUNT_ACCESS_COST: u64 = 2600;
18const WARM_ACCOUNT_ACCESS_COST: u64 = 100;
19
20/// Extra gas charged when loading account code in Stylus.
21/// Matches Go: `cfg.MaxCodeSize() / params.DefaultMaxCodeSize * params.ExtcodeSizeGasEIP150`
22/// = 24576 / 24576 * 700 = 700.
23const WASM_EXT_CODE_COST: u64 = 700;
24
25// ── Type-erased journal access ──────────────────────────────────────
26
27/// Flattened SSTORE result without revm generics.
28pub struct SStoreInfo {
29    pub is_cold: bool,
30    pub original_value: U256,
31    pub present_value: U256,
32    pub new_value: U256,
33}
34
35/// Object-safe trait wrapping `Journal<DB>` operations needed by Stylus.
36///
37/// By erasing the `DB` type parameter, [`StylusEvmApi`] becomes non-generic
38/// and trivially satisfies `'static` (required by wasmer's `FunctionEnv`).
39pub trait JournalAccess {
40    fn sload(&mut self, addr: Address, key: U256) -> eyre::Result<(U256, bool)>;
41    fn sstore(&mut self, addr: Address, key: U256, value: U256) -> eyre::Result<SStoreInfo>;
42    fn tload(&mut self, addr: Address, key: U256) -> U256;
43    fn tstore(&mut self, addr: Address, key: U256, value: U256);
44    fn log(&mut self, log: Log);
45    fn account_balance(&mut self, addr: Address) -> eyre::Result<(U256, bool)>;
46    fn account_code(&mut self, addr: Address) -> eyre::Result<(Vec<u8>, bool)>;
47    fn account_codehash(&mut self, addr: Address) -> eyre::Result<(B256, bool)>;
48    fn address_in_access_list(&self, addr: Address) -> bool;
49    fn add_address_to_access_list(&mut self, addr: Address);
50    fn is_account_empty(&mut self, addr: Address) -> eyre::Result<bool>;
51}
52
53impl<DB: Database> JournalAccess for revm::Journal<DB> {
54    fn sload(&mut self, addr: Address, key: U256) -> eyre::Result<(U256, bool)> {
55        let result = self
56            .inner
57            .sload(&mut self.database, addr, key, false)
58            .map_err(|e| eyre::eyre!("sload failed: {e:?}"))?;
59        Ok((result.data, result.is_cold))
60    }
61
62    fn sstore(&mut self, addr: Address, key: U256, value: U256) -> eyre::Result<SStoreInfo> {
63        let result = self
64            .inner
65            .sstore(&mut self.database, addr, key, value, false)
66            .map_err(|e| eyre::eyre!("sstore failed: {e:?}"))?;
67        Ok(SStoreInfo {
68            is_cold: result.is_cold,
69            original_value: result.data.original_value,
70            present_value: result.data.present_value,
71            new_value: result.data.new_value,
72        })
73    }
74
75    fn tload(&mut self, addr: Address, key: U256) -> U256 {
76        self.inner.tload(addr, key)
77    }
78
79    fn tstore(&mut self, addr: Address, key: U256, value: U256) {
80        self.inner.tstore(addr, key, value);
81    }
82
83    fn log(&mut self, log: Log) {
84        self.inner.log(log);
85    }
86
87    fn account_balance(&mut self, addr: Address) -> eyre::Result<(U256, bool)> {
88        let result = self
89            .inner
90            .load_account(&mut self.database, addr)
91            .map_err(|e| eyre::eyre!("load_account failed: {e:?}"))?;
92        Ok((result.data.info.balance, result.is_cold))
93    }
94
95    fn account_code(&mut self, addr: Address) -> eyre::Result<(Vec<u8>, bool)> {
96        let result = self
97            .inner
98            .load_code(&mut self.database, addr)
99            .map_err(|e| eyre::eyre!("load_code failed: {e:?}"))?;
100        let code = result
101            .data
102            .info
103            .code
104            .as_ref()
105            .map(|c: &revm::bytecode::Bytecode| c.original_bytes().to_vec())
106            .unwrap_or_default();
107        Ok((code, result.is_cold))
108    }
109
110    fn account_codehash(&mut self, addr: Address) -> eyre::Result<(B256, bool)> {
111        let result = self
112            .inner
113            .load_account(&mut self.database, addr)
114            .map_err(|e| eyre::eyre!("load_account failed: {e:?}"))?;
115        let is_cold = result.is_cold;
116        // EIP-1052: an empty account's code hash is zero, not `keccak("")`.
117        let hash = if result.data.info.is_empty() {
118            B256::ZERO
119        } else {
120            result.data.info.code_hash
121        };
122        Ok((hash, is_cold))
123    }
124
125    fn address_in_access_list(&self, addr: Address) -> bool {
126        // Pre-warmed: precompiles, coinbase, EIP-2930 access list.
127        if self.inner.warm_addresses.is_warm(&addr) {
128            return true;
129        }
130        // EIP-2929 access list lives on each account as a (transaction_id, Cold flag)
131        // pair. A reverted sub-call leaves the account in the state HashMap but
132        // re-marked Cold via JournalEntry::AccountWarmed::revert, so a plain
133        // contains_key check would miss the revert and report stale warmth.
134        if let Some(account) = self.inner.state.get(&addr) {
135            return !account.is_cold_transaction_id(self.inner.transaction_id);
136        }
137        false
138    }
139
140    fn add_address_to_access_list(&mut self, addr: Address) {
141        // Load the account to mark it warm in the state map.
142        let _ = self.inner.load_account(&mut self.database, addr);
143    }
144
145    fn is_account_empty(&mut self, addr: Address) -> eyre::Result<bool> {
146        let result = self
147            .inner
148            .load_account(&mut self.database, addr)
149            .map_err(|e| eyre::eyre!("load_account failed: {e:?}"))?;
150        let acc = result.data;
151        Ok(acc.info.balance.is_zero()
152            && acc.info.nonce == 0
153            && acc.info.code_hash == revm::primitives::KECCAK_EMPTY)
154    }
155}
156
157// ── StylusEvmApi ────────────────────────────────────────────────────
158
159/// Result from a sub-call (CALL, DELEGATECALL, STATICCALL).
160pub struct SubCallResult {
161    pub output: Vec<u8>,
162    pub gas_cost: u64,
163    pub success: bool,
164    /// Gas refund accumulated during the sub-call (EIP-3529 SSTORE refunds).
165    pub refund: i64,
166    /// Page counters after the sub-call returns.
167    pub pages: (u16, u16),
168}
169
170/// Result from a CREATE/CREATE2 operation.
171pub struct SubCreateResult {
172    pub address: Option<Address>,
173    pub output: Vec<u8>,
174    pub gas_cost: u64,
175    pub pages: (u16, u16),
176}
177
178/// Type-erased function pointer for executing sub-calls from Stylus.
179///
180/// `call_type`: `0=CALL`, `1=DELEGATECALL`, `2=STATICCALL`.
181/// `pages` carries the parent's (open, ever) counters into the new frame.
182///
183/// The first pointer is the type-erased revm `Context`; the second points at
184/// the `ArbPrecompileCtx` shared by the precompile closures and dispatch
185/// path. Both must remain live for the duration of the trampoline call.
186pub type DoCallFn = fn(
187    *mut (),
188    *const (),
189    u8,
190    Address,
191    Address,
192    Address,
193    &[u8],
194    u64,
195    U256,
196    (u16, u16),
197) -> SubCallResult;
198
199/// Type-erased function pointer for executing CREATE/CREATE2 from Stylus.
200pub type DoCreateFn =
201    fn(*mut (), *const (), Address, &[u8], u64, U256, Option<B256>, (u16, u16)) -> SubCreateResult;
202
203/// Per-call storage cache entry.
204struct StorageCacheEntry {
205    /// Current value (may be dirty from a write).
206    value: B256,
207    /// Original value from the journal (None = written before first read).
208    known: Option<B256>,
209}
210
211impl StorageCacheEntry {
212    fn known(value: B256) -> Self {
213        Self {
214            value,
215            known: Some(value),
216        }
217    }
218
219    fn unknown(value: B256) -> Self {
220        Self { value, known: None }
221    }
222
223    fn dirty(&self) -> bool {
224        self.known != Some(self.value)
225    }
226}
227
228/// Per-call storage cache: avoids repeat journal hits and charges the
229/// `evm_api_gas` surcharge only on the first miss per slot.
230struct StorageCache {
231    slots: HashMap<B256, StorageCacheEntry>,
232    reads: u32,
233    writes: u32,
234}
235
236impl StorageCache {
237    fn new() -> Self {
238        Self {
239            slots: HashMap::new(),
240            reads: 0,
241            writes: 0,
242        }
243    }
244
245    fn read_gas(&mut self) -> Gas {
246        self.reads += 1;
247        match self.reads {
248            0..=32 => Gas(0),
249            33..=128 => Gas(2),
250            _ => Gas(10),
251        }
252    }
253
254    fn write_gas(&mut self) -> Gas {
255        self.writes += 1;
256        match self.writes {
257            0..=8 => Gas(0),
258            9..=64 => Gas(7),
259            _ => Gas(10),
260        }
261    }
262}
263
264/// Concrete [`EvmApi`] bridging WASM host function calls to revm's journaled state.
265///
266/// Uses a type-erased raw pointer to [`JournalAccess`] so that the `DB` generic
267/// parameter is erased. This allows `StylusEvmApi` to be `'static` without
268/// requiring `DB: 'static`, which is needed for wasmer's `FunctionEnv`.
269///
270/// # Safety
271///
272/// Wasmer executes WASM programs synchronously on the calling thread, so no
273/// cross-thread sharing occurs despite the `Send` bound on [`EvmApi`].
274/// The raw pointer must remain valid for the lifetime of the Stylus execution.
275pub struct StylusEvmApi {
276    /// Type-erased raw pointer to the journal (implements [`JournalAccess`]).
277    journal: *mut dyn JournalAccess,
278    /// The contract address being executed.
279    address: Address,
280    /// The caller (msg.sender) of the current contract.
281    caller: Address,
282    /// Value of the current call (needed for DELEGATECALL forwarding).
283    call_value: U256,
284    /// Per-call storage cache.
285    storage_cache: StorageCache,
286    /// Accumulated SSTORE refund (EIP-3529) from flush operations.
287    sstore_refund: i64,
288    /// Return data from the last sub-call.
289    return_data: Vec<u8>,
290    /// Whether the current execution context is read-only (STATICCALL).
291    read_only: bool,
292    /// ArbOS version — flush semantics are version-gated at v50.
293    arbos_version: u64,
294    /// Type-erased context pointer and callbacks for sub-calls.
295    ctx_ptr: *mut (),
296    /// Type-erased pointer to the `ArbPrecompileCtx` carried alongside
297    /// `ctx_ptr` so the trampoline can access per-block / per-tx state
298    /// without going through a thread-local.
299    precompile_ctx_ptr: *const (),
300    do_call: Option<DoCallFn>,
301    do_create: Option<DoCreateFn>,
302    /// Most recent `account_code` result; a same-address repeat read is free.
303    last_code: Option<(Address, Vec<u8>)>,
304    /// Per-dimension gas attributed across this program's host calls. The
305    /// `WasmComputation` residual is added by the caller once the program ends.
306    multi_gas: MultiGas,
307    /// Gas forwarded to and consumed by sub-calls. Excluded from this frame's
308    /// residual because the callee frame attributes its own dimensions.
309    sub_call_gas: u64,
310}
311
312// SAFETY: `wasmer::FunctionEnv::new<T>` requires `T: Send + 'static`, so
313// `StylusEvmApi` (held inside a `WasmEnv` that is passed to wasmer host
314// functions) must be `Send`. The `*mut dyn JournalAccess` and the
315// `*mut ()` / `*const ()` context handles inside this struct make it
316// `!Send` by default. They are only ever dereferenced by host functions
317// invoked synchronously on the same thread that built and is currently
318// driving the `Instance` — wasmer never moves the env across threads
319// while host calls are in flight, so the raw pointers are never observed
320// from any thread other than the one that constructed them.
321unsafe impl Send for StylusEvmApi {}
322
323impl StylusEvmApi {
324    /// Create a new StylusEvmApi from a raw pointer to a revm Journal.
325    ///
326    /// # Safety
327    ///
328    /// The `journal` pointer must remain valid for the lifetime of this struct.
329    /// The caller must ensure exclusive mutable access through this pointer.
330    /// If `ctx_ptr` is provided, it must also remain valid.
331    #[allow(clippy::too_many_arguments)]
332    pub unsafe fn new<DB: Database>(
333        journal: *mut revm::Journal<DB>,
334        address: Address,
335        caller: Address,
336        call_value: U256,
337        read_only: bool,
338        arbos_version: u64,
339        ctx_ptr: *mut (),
340        precompile_ctx_ptr: *const (),
341        do_call: Option<DoCallFn>,
342        do_create: Option<DoCreateFn>,
343    ) -> Self {
344        unsafe {
345            let journal: *mut dyn JournalAccess = {
346                // Bind the trait object with the borrow's own lifetime (so `DB` need
347                // not be `'static`), then erase that lifetime to `'static` for
348                // storage. A direct `as` cast forces the object to `'static` and
349                // thus `DB: 'static`, which the callers cannot satisfy.
350                // SAFETY: the caller guarantees the journal pointer outlives this
351                // struct (see the `# Safety` section above); transmuting a reference
352                // to a same-layout raw pointer only erases that lifetime.
353                let r: &mut dyn JournalAccess = &mut *journal;
354                core::mem::transmute(r)
355            };
356            Self {
357                journal,
358                address,
359                caller,
360                call_value,
361                storage_cache: StorageCache::new(),
362                sstore_refund: 0,
363                return_data: Vec::new(),
364                read_only,
365                arbos_version,
366                ctx_ptr,
367                precompile_ctx_ptr,
368                do_call,
369                do_create,
370                last_code: None,
371                multi_gas: MultiGas::zero(),
372                sub_call_gas: 0,
373            }
374        }
375    }
376
377    /// Per-dimension gas attributed across this program's host calls.
378    pub fn multi_gas(&self) -> MultiGas {
379        self.multi_gas
380    }
381
382    /// Gas forwarded to and consumed by sub-calls; the caller excludes it from
383    /// this frame's `WasmComputation` residual.
384    pub fn sub_call_gas(&self) -> u64 {
385        self.sub_call_gas
386    }
387
388    fn add_multi_gas(&mut self, gas: MultiGas) {
389        self.multi_gas = self.multi_gas.saturating_add(gas);
390    }
391
392    fn record_sub_call(&mut self, base_cost_gas: MultiGas, sub_gas: u64) {
393        self.multi_gas = self.multi_gas.saturating_add(base_cost_gas);
394        self.sub_call_gas = self.sub_call_gas.saturating_add(sub_gas);
395    }
396
397    /// Get a mutable reference to the type-erased journal.
398    fn journal(&mut self) -> &mut dyn JournalAccess {
399        // SAFETY: `self.journal` was set by `Self::new`, whose caller
400        // contract requires the pointer to remain valid for the lifetime
401        // of this struct and grants exclusive access. The dispatch path
402        // upholds this: the EVM context owning the journal is kept alive
403        // and is not borrowed elsewhere while host functions run.
404        unsafe { &mut *self.journal }
405    }
406
407    /// Return the accumulated SSTORE refund from flush operations.
408    pub fn sstore_refund(&self) -> i64 {
409        self.sstore_refund
410    }
411}
412
413impl std::fmt::Debug for StylusEvmApi {
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        f.debug_struct("StylusEvmApi")
416            .field("address", &self.address)
417            .field("read_only", &self.read_only)
418            .field("cache_size", &self.storage_cache.slots.len())
419            .finish()
420    }
421}
422
423impl EvmApi for StylusEvmApi {
424    fn get_bytes32(&mut self, key: B256, evm_api_gas_to_use: Gas) -> eyre::Result<(B256, Gas)> {
425        let mut cost = self.storage_cache.read_gas();
426
427        let value = if let Some(entry) = self.storage_cache.slots.get(&key) {
428            entry.value
429        } else {
430            let storage_key = U256::from_be_bytes(key.0);
431            let addr = self.address;
432            let (value_u256, is_cold) = self.journal().sload(addr, storage_key)?;
433            let value = B256::from(value_u256.to_be_bytes());
434
435            let sload_cost = if is_cold {
436                COLD_SLOAD_COST
437            } else {
438                WARM_STORAGE_READ_COST
439            };
440            cost = Gas(cost
441                .0
442                .saturating_add(sload_cost)
443                .saturating_add(evm_api_gas_to_use.0));
444            self.add_multi_gas(multi_gas::state_load(is_cold));
445
446            self.storage_cache
447                .slots
448                .insert(key, StorageCacheEntry::known(value));
449            value
450        };
451
452        Ok((value, cost))
453    }
454
455    fn cache_bytes32(&mut self, key: B256, value: B256) -> eyre::Result<Gas> {
456        let cost = self.storage_cache.write_gas();
457        match self.storage_cache.slots.get_mut(&key) {
458            Some(entry) => entry.value = value,
459            None => {
460                self.storage_cache
461                    .slots
462                    .insert(key, StorageCacheEntry::unknown(value));
463            }
464        }
465        Ok(cost)
466    }
467
468    fn flush_storage_cache(
469        &mut self,
470        clear: bool,
471        gas_left: Gas,
472    ) -> eyre::Result<(Gas, UserOutcomeKind)> {
473        // Collect dirty entries
474        let dirty: Vec<(B256, B256)> = self
475            .storage_cache
476            .slots
477            .iter()
478            .filter(|(_, v)| v.dirty())
479            .map(|(k, v)| (*k, v.value))
480            .collect();
481
482        if clear {
483            self.storage_cache.slots.clear();
484        } else {
485            // Mark all entries as known (clean)
486            for entry in self.storage_cache.slots.values_mut() {
487                entry.known = Some(entry.value);
488            }
489        }
490
491        if dirty.is_empty() {
492            return Ok((Gas(0), UserOutcomeKind::Success));
493        }
494
495        if self.read_only {
496            return Ok((Gas(0), UserOutcomeKind::Failure));
497        }
498
499        let mut total_gas = 0u64;
500        let mut remaining = gas_left.0;
501        let mut is_out_of_gas = false;
502
503        for (key, value) in &dirty {
504            let storage_key = U256::from_be_bytes(key.0);
505            let storage_value = U256::from_be_bytes(value.0);
506
507            let addr = self.address;
508            let info = self.journal().sstore(addr, storage_key, storage_value)?;
509
510            let sstore_cost = sstore_gas_cost(&info);
511            if sstore_cost > remaining {
512                is_out_of_gas = true;
513                total_gas = gas_left.0;
514                break;
515            }
516            remaining -= sstore_cost;
517            total_gas += sstore_cost;
518            self.sstore_refund += sstore_refund(&info);
519            self.add_multi_gas(multi_gas::state_store(
520                info.is_cold,
521                info.original_value,
522                info.present_value,
523                info.new_value,
524            ));
525        }
526
527        // A budget that was exhausted — by partial OOG or by hitting exactly
528        // zero — must surface as a non-Success outcome so the caller traps.
529        if is_out_of_gas || remaining == 0 {
530            const ARBOS_VERSION_DIA: u64 = 50;
531            let outcome = if self.arbos_version < ARBOS_VERSION_DIA {
532                UserOutcomeKind::Failure
533            } else {
534                UserOutcomeKind::OutOfInk
535            };
536            return Ok((Gas(total_gas), outcome));
537        }
538
539        Ok((Gas(total_gas), UserOutcomeKind::Success))
540    }
541
542    fn get_transient_bytes32(&mut self, key: B256) -> eyre::Result<B256> {
543        let storage_key = U256::from_be_bytes(key.0);
544        let addr = self.address;
545        let value = self.journal().tload(addr, storage_key);
546        Ok(B256::from(value.to_be_bytes()))
547    }
548
549    fn set_transient_bytes32(&mut self, key: B256, value: B256) -> eyre::Result<UserOutcomeKind> {
550        if self.read_only {
551            return Ok(UserOutcomeKind::Failure);
552        }
553        let storage_key = U256::from_be_bytes(key.0);
554        let storage_value = U256::from_be_bytes(value.0);
555        let addr = self.address;
556        self.journal().tstore(addr, storage_key, storage_value);
557        Ok(UserOutcomeKind::Success)
558    }
559
560    fn contract_call(
561        &mut self,
562        contract: Address,
563        calldata: &[u8],
564        gas_left: Gas,
565        gas_req: Gas,
566        value: U256,
567        pages: (u16, u16),
568    ) -> eyre::Result<(u32, Gas, UserOutcomeKind, (u16, u16))> {
569        if self.read_only && !value.is_zero() {
570            self.return_data = Vec::new();
571            return Ok((0, Gas(0), UserOutcomeKind::Failure, pages));
572        }
573
574        let do_call = match self.do_call {
575            Some(f) => f,
576            None => {
577                self.return_data = b"sub-calls not available".to_vec();
578                return Ok((
579                    self.return_data.len() as u32,
580                    Gas(0),
581                    UserOutcomeKind::Failure,
582                    pages,
583                ));
584            }
585        };
586
587        let (base_cost, oog, call_mg) =
588            wasm_call_cost(self.journal(), contract, &value, gas_left.0);
589        if oog {
590            self.return_data = Vec::new();
591            return Ok((0, Gas(gas_left.0), UserOutcomeKind::Failure, pages));
592        }
593
594        let start_gas = gas_left.0.saturating_sub(base_cost) * 63 / 64;
595        let gas = start_gas.min(gas_req.0);
596
597        let gas = if !value.is_zero() {
598            gas.saturating_add(2300)
599        } else {
600            gas
601        };
602
603        let result = (do_call)(
604            self.ctx_ptr,
605            self.precompile_ctx_ptr,
606            0,
607            contract,
608            self.address,
609            contract,
610            calldata,
611            gas,
612            value,
613            pages,
614        );
615
616        self.return_data = result.output;
617        let cost = base_cost.saturating_add(result.gas_cost);
618        self.record_sub_call(call_mg, result.gas_cost);
619        self.sstore_refund = self.sstore_refund.saturating_add(result.refund);
620
621        let outcome = if result.success {
622            UserOutcomeKind::Success
623        } else {
624            UserOutcomeKind::Failure
625        };
626        Ok((
627            self.return_data.len() as u32,
628            Gas(cost),
629            outcome,
630            result.pages,
631        ))
632    }
633
634    fn delegate_call(
635        &mut self,
636        contract: Address,
637        calldata: &[u8],
638        gas_left: Gas,
639        gas_req: Gas,
640        pages: (u16, u16),
641    ) -> eyre::Result<(u32, Gas, UserOutcomeKind, (u16, u16))> {
642        let do_call = match self.do_call {
643            Some(f) => f,
644            None => {
645                self.return_data = b"sub-calls not available".to_vec();
646                return Ok((
647                    self.return_data.len() as u32,
648                    Gas(0),
649                    UserOutcomeKind::Failure,
650                    pages,
651                ));
652            }
653        };
654
655        let (base_cost, oog, call_mg) =
656            wasm_call_cost(self.journal(), contract, &U256::ZERO, gas_left.0);
657        if oog {
658            self.return_data = Vec::new();
659            return Ok((0, Gas(gas_left.0), UserOutcomeKind::Failure, pages));
660        }
661
662        let start_gas = gas_left.0.saturating_sub(base_cost) * 63 / 64;
663        let gas = start_gas.min(gas_req.0);
664
665        let result = (do_call)(
666            self.ctx_ptr,
667            self.precompile_ctx_ptr,
668            1,
669            contract,
670            self.caller,
671            self.address,
672            calldata,
673            gas,
674            self.call_value,
675            pages,
676        );
677
678        self.return_data = result.output;
679        let cost = base_cost.saturating_add(result.gas_cost);
680        self.record_sub_call(call_mg, result.gas_cost);
681        self.sstore_refund = self.sstore_refund.saturating_add(result.refund);
682
683        let outcome = if result.success {
684            UserOutcomeKind::Success
685        } else {
686            UserOutcomeKind::Failure
687        };
688        Ok((
689            self.return_data.len() as u32,
690            Gas(cost),
691            outcome,
692            result.pages,
693        ))
694    }
695
696    fn static_call(
697        &mut self,
698        contract: Address,
699        calldata: &[u8],
700        gas_left: Gas,
701        gas_req: Gas,
702        pages: (u16, u16),
703    ) -> eyre::Result<(u32, Gas, UserOutcomeKind, (u16, u16))> {
704        let do_call = match self.do_call {
705            Some(f) => f,
706            None => {
707                self.return_data = b"sub-calls not available".to_vec();
708                return Ok((
709                    self.return_data.len() as u32,
710                    Gas(0),
711                    UserOutcomeKind::Failure,
712                    pages,
713                ));
714            }
715        };
716
717        let (base_cost, oog, call_mg) =
718            wasm_call_cost(self.journal(), contract, &U256::ZERO, gas_left.0);
719        if oog {
720            self.return_data = Vec::new();
721            return Ok((0, Gas(gas_left.0), UserOutcomeKind::Failure, pages));
722        }
723
724        let start_gas = gas_left.0.saturating_sub(base_cost) * 63 / 64;
725        let gas = start_gas.min(gas_req.0);
726
727        let result = (do_call)(
728            self.ctx_ptr,
729            self.precompile_ctx_ptr,
730            2,
731            contract,
732            self.address,
733            contract,
734            calldata,
735            gas,
736            U256::ZERO,
737            pages,
738        );
739
740        self.return_data = result.output;
741        let cost = base_cost.saturating_add(result.gas_cost);
742        self.record_sub_call(call_mg, result.gas_cost);
743        self.sstore_refund = self.sstore_refund.saturating_add(result.refund);
744
745        let outcome = if result.success {
746            UserOutcomeKind::Success
747        } else {
748            UserOutcomeKind::Failure
749        };
750        Ok((
751            self.return_data.len() as u32,
752            Gas(cost),
753            outcome,
754            result.pages,
755        ))
756    }
757
758    fn create1(
759        &mut self,
760        code: Vec<u8>,
761        endowment: U256,
762        gas: Gas,
763        pages: (u16, u16),
764    ) -> eyre::Result<(CreateResponse, u32, Gas, (u16, u16))> {
765        if self.read_only {
766            self.return_data = Vec::new();
767            return Ok((
768                CreateResponse::Fail("write protection".into()),
769                0,
770                Gas(0),
771                pages,
772            ));
773        }
774
775        let do_create = match self.do_create {
776            Some(f) => f,
777            None => {
778                self.return_data = b"creates not available".to_vec();
779                return Ok((
780                    CreateResponse::Fail("not available".into()),
781                    self.return_data.len() as u32,
782                    Gas(0),
783                    pages,
784                ));
785            }
786        };
787
788        let base_cost: u64 = 32000;
789        if gas.0 < base_cost {
790            self.return_data = Vec::new();
791            return Ok((
792                CreateResponse::Fail("out of gas".into()),
793                0,
794                Gas(gas.0),
795                pages,
796            ));
797        }
798        let remaining = gas.0 - base_cost;
799        let one_64th = remaining / 64;
800        let call_gas = remaining - one_64th;
801
802        let result = (do_create)(
803            self.ctx_ptr,
804            self.precompile_ctx_ptr,
805            self.address,
806            &code,
807            call_gas,
808            endowment,
809            None,
810            pages,
811        );
812
813        self.return_data = result.output.clone();
814        let cost = base_cost.saturating_add(result.gas_cost);
815        self.record_sub_call(MultiGas::computation_gas(base_cost), result.gas_cost);
816
817        let response = match result.address {
818            Some(addr) => CreateResponse::Success(addr),
819            None => CreateResponse::Success(Address::ZERO),
820        };
821
822        Ok((
823            response,
824            self.return_data.len() as u32,
825            Gas(cost),
826            result.pages,
827        ))
828    }
829
830    fn create2(
831        &mut self,
832        code: Vec<u8>,
833        endowment: U256,
834        salt: B256,
835        gas: Gas,
836        pages: (u16, u16),
837    ) -> eyre::Result<(CreateResponse, u32, Gas, (u16, u16))> {
838        if self.read_only {
839            self.return_data = Vec::new();
840            return Ok((
841                CreateResponse::Fail("write protection".into()),
842                0,
843                Gas(0),
844                pages,
845            ));
846        }
847
848        let do_create = match self.do_create {
849            Some(f) => f,
850            None => {
851                self.return_data = b"creates not available".to_vec();
852                return Ok((
853                    CreateResponse::Fail("not available".into()),
854                    self.return_data.len() as u32,
855                    Gas(0),
856                    pages,
857                ));
858            }
859        };
860
861        let keccak_words = (code.len() as u64).div_ceil(32);
862        let keccak_cost = keccak_words.saturating_mul(6);
863        let base_cost = 32000u64.saturating_add(keccak_cost);
864        if gas.0 < base_cost {
865            self.return_data = Vec::new();
866            return Ok((
867                CreateResponse::Fail("out of gas".into()),
868                0,
869                Gas(gas.0),
870                pages,
871            ));
872        }
873        let remaining = gas.0 - base_cost;
874        let one_64th = remaining / 64;
875        let call_gas = remaining - one_64th;
876
877        let result = (do_create)(
878            self.ctx_ptr,
879            self.precompile_ctx_ptr,
880            self.address,
881            &code,
882            call_gas,
883            endowment,
884            Some(salt),
885            pages,
886        );
887
888        self.return_data = result.output.clone();
889        let cost = base_cost.saturating_add(result.gas_cost);
890        self.record_sub_call(MultiGas::computation_gas(base_cost), result.gas_cost);
891
892        let response = match result.address {
893            Some(addr) => CreateResponse::Success(addr),
894            None => CreateResponse::Success(Address::ZERO),
895        };
896
897        Ok((
898            response,
899            self.return_data.len() as u32,
900            Gas(cost),
901            result.pages,
902        ))
903    }
904
905    fn get_return_data(&self) -> Vec<u8> {
906        self.return_data.clone()
907    }
908
909    fn emit_log(&mut self, data: Vec<u8>, topics: u32) -> eyre::Result<()> {
910        if self.read_only {
911            return Err(eyre::eyre!("cannot emit log in static context"));
912        }
913
914        let topic_bytes = (topics as usize) * 32;
915        if data.len() < topic_bytes {
916            return Err(eyre::eyre!("log data too short for {topics} topics"));
917        }
918
919        let mut topic_list = Vec::with_capacity(topics as usize);
920        for i in 0..topics as usize {
921            let start = i * 32;
922            let mut bytes = [0u8; 32];
923            bytes.copy_from_slice(&data[start..start + 32]);
924            topic_list.push(B256::from(bytes));
925        }
926
927        let log_data = data[topic_bytes..].to_vec();
928        self.add_multi_gas(multi_gas::log(topics as u64, log_data.len() as u64));
929
930        let addr = self.address;
931        let log = Log::new(addr, topic_list, log_data.into()).expect("too many log topics");
932
933        self.journal().log(log);
934        Ok(())
935    }
936
937    fn account_balance(&mut self, address: Address) -> eyre::Result<(U256, Gas)> {
938        let (balance, is_cold) = self.journal().account_balance(address)?;
939        // WasmAccountTouchCost(withCode=false): cold/warm access cost
940        let gas_cost = if is_cold {
941            COLD_ACCOUNT_ACCESS_COST
942        } else {
943            WARM_ACCOUNT_ACCESS_COST
944        };
945        self.add_multi_gas(multi_gas::account_touch(is_cold, 0));
946        Ok((balance, Gas(gas_cost)))
947    }
948
949    fn account_code(
950        &mut self,
951        _arbos_version: u64,
952        address: Address,
953        gas_left: Gas,
954    ) -> eyre::Result<(Vec<u8>, Gas)> {
955        if let Some((stored, data)) = self.last_code.as_ref()
956            && *stored == address
957        {
958            return Ok((data.clone(), Gas(0)));
959        }
960        let (code, is_cold) = self.journal().account_code(address)?;
961        // WasmAccountTouchCost(withCode=true): extCodeCost + cold/warm access cost
962        let access_cost = if is_cold {
963            COLD_ACCOUNT_ACCESS_COST
964        } else {
965            WARM_ACCOUNT_ACCESS_COST
966        };
967        let gas_cost = WASM_EXT_CODE_COST + access_cost;
968        self.add_multi_gas(multi_gas::account_touch(is_cold, WASM_EXT_CODE_COST));
969        // If insufficient gas, return empty code but still charge
970        if gas_left.0 < gas_cost {
971            return Ok((Vec::new(), Gas(gas_cost)));
972        }
973        if !code.is_empty() || self.arbos_version < ARBOS_VERSION_STYLUS_LAST_CODE_CACHE_FIX {
974            self.last_code = Some((address, code.clone()));
975        }
976        Ok((code, Gas(gas_cost)))
977    }
978
979    fn account_codehash(&mut self, address: Address) -> eyre::Result<(B256, Gas)> {
980        let (hash, is_cold) = self.journal().account_codehash(address)?;
981        // WasmAccountTouchCost(withCode=false)
982        let gas_cost = if is_cold {
983            COLD_ACCOUNT_ACCESS_COST
984        } else {
985            WARM_ACCOUNT_ACCESS_COST
986        };
987        self.add_multi_gas(multi_gas::account_touch(is_cold, 0));
988        Ok((hash, Gas(gas_cost)))
989    }
990
991    fn capture_hostio(
992        &mut self,
993        _name: &str,
994        _args: &[u8],
995        _outs: &[u8],
996        _start_ink: crate::ink::Ink,
997        _end_ink: crate::ink::Ink,
998    ) {
999        // Debug tracing — no-op in production.
1000    }
1001}
1002
1003/// Compute the caller's base gas cost for a CALL from Stylus, with its
1004/// per-dimension split.
1005///
1006/// Matches Go's `WasmCallCost`: EIP-2929 warm/cold access + value transfer +
1007/// new account creation cost. Returns `(cost, out_of_gas, multi_gas)`; on
1008/// out-of-gas the dimensions are zero (the failing call consumes all gas).
1009fn wasm_call_cost(
1010    journal: &mut dyn JournalAccess,
1011    contract: Address,
1012    value: &U256,
1013    budget: u64,
1014) -> (u64, bool, MultiGas) {
1015    let is_cold = !journal.address_in_access_list(contract);
1016    if is_cold {
1017        journal.add_address_to_access_list(contract);
1018    }
1019    let transfers_value = !value.is_zero();
1020    let new_account = transfers_value && journal.is_account_empty(contract).unwrap_or(false);
1021
1022    let mg = multi_gas::call_cost(is_cold, transfers_value, new_account);
1023    let total = mg.single_gas();
1024    if total > budget {
1025        return (total, true, MultiGas::zero());
1026    }
1027    (total, false, mg)
1028}
1029
1030/// EIP-3529 SSTORE refund constants (post-London).
1031const SSTORE_CLEARS_SCHEDULE: i64 = 4_800; // WARM_SSTORE_RESET(2900) + ACCESS_LIST_STORAGE_KEY(1900)
1032const SSTORE_SET_REFUND: i64 = 19_900; // SSTORE_SET(20000) - WARM_STORAGE_READ(100)
1033const SSTORE_RESET_REFUND: i64 = 2_800; // WARM_SSTORE_RESET(2900) - WARM_STORAGE_READ(100)
1034
1035/// Compute SSTORE refund following revm's `sstore_refund` formula (Istanbul+/EIP-3529).
1036fn sstore_refund(info: &SStoreInfo) -> i64 {
1037    let original = info.original_value;
1038    let present = info.present_value;
1039    let new = info.new_value;
1040
1041    // No-op: new equals current value
1042    if new == present {
1043        return 0;
1044    }
1045
1046    // Refund for clearing on first write to a slot whose original is non-zero
1047    if original == present && new.is_zero() {
1048        return SSTORE_CLEARS_SCHEDULE;
1049    }
1050
1051    let mut refund: i64 = 0;
1052
1053    // If original is non-zero, track clearing/un-clearing of the slot
1054    if !original.is_zero() {
1055        if present.is_zero() {
1056            // Slot was previously cleared in this tx; un-clear it now
1057            refund -= SSTORE_CLEARS_SCHEDULE;
1058        } else if new.is_zero() {
1059            // Now clearing a previously non-zero slot
1060            refund += SSTORE_CLEARS_SCHEDULE;
1061        }
1062    }
1063
1064    // Refund for restoring the slot to its original value
1065    if original == new {
1066        if original.is_zero() {
1067            refund += SSTORE_SET_REFUND;
1068        } else {
1069            refund += SSTORE_RESET_REFUND;
1070        }
1071    }
1072
1073    refund
1074}
1075
1076/// Compute SSTORE gas cost following EIP-2929 + EIP-3529 (post-London).
1077fn sstore_gas_cost(info: &SStoreInfo) -> u64 {
1078    let base = if info.original_value == info.new_value {
1079        WARM_STORAGE_READ_COST
1080    } else if info.original_value == info.present_value {
1081        if info.original_value.is_zero() {
1082            20_000 // SSTORE_SET_GAS
1083        } else {
1084            2_900 // SSTORE_RESET_GAS (5000 - 2100)
1085        }
1086    } else {
1087        WARM_STORAGE_READ_COST
1088    };
1089
1090    let cold_cost = if info.is_cold { COLD_SLOAD_COST } else { 0 };
1091    base + cold_cost
1092}
1093
1094// SSTORE gas + refund parity tests for the 9 canonical EIP-2200 cases
1095// plus the EIP-3529 refund schedule.
1096#[cfg(test)]
1097mod sstore_parity_tests {
1098    use alloy_primitives::U256;
1099
1100    use super::{SStoreInfo, sstore_gas_cost, sstore_refund};
1101
1102    fn info(original: u64, present: u64, new: u64, is_cold: bool) -> SStoreInfo {
1103        SStoreInfo {
1104            is_cold,
1105            original_value: U256::from(original),
1106            present_value: U256::from(present),
1107            new_value: U256::from(new),
1108        }
1109    }
1110
1111    // ── EIP-2200 base costs (warm-access, EIP-2929/3529 adjusted) ─────
1112
1113    /// Case 1 (noop on untouched slot): `current == value`, `original == current`.
1114    /// Expected: `WarmStorageReadCostEIP2929 = 100`.
1115    #[test]
1116    fn case_1_noop_untouched_warm() {
1117        assert_eq!(sstore_gas_cost(&info(5, 5, 5, false)), 100);
1118        assert_eq!(sstore_refund(&info(5, 5, 5, false)), 0);
1119    }
1120
1121    /// Case 2.1.1 (create slot): `original == current == 0`, `value != 0`.
1122    /// Expected: `SstoreSetGasEIP2200 = 20_000`.
1123    #[test]
1124    fn case_2_1_1_create_slot() {
1125        assert_eq!(sstore_gas_cost(&info(0, 0, 5, false)), 20_000);
1126        assert_eq!(sstore_refund(&info(0, 0, 5, false)), 0);
1127    }
1128
1129    /// Case 2.1.2 (update clean): `original == current != 0`, `value != 0`, `value != original`.
1130    /// Expected: `SstoreResetGasEIP2200 - ColdSloadCostEIP2929 = 2_900`.
1131    #[test]
1132    fn case_2_1_2_update_clean() {
1133        assert_eq!(sstore_gas_cost(&info(5, 5, 10, false)), 2_900);
1134        assert_eq!(sstore_refund(&info(5, 5, 10, false)), 0);
1135    }
1136
1137    /// Case 2.1.2b (delete clean): original == current != 0, value == 0.
1138    /// Same base cost as 2.1.2 plus an EIP-3529 `SstoreClearsScheduleRefundEIP3529 = 4_800` refund.
1139    #[test]
1140    fn case_2_1_2b_delete_clean() {
1141        assert_eq!(sstore_gas_cost(&info(5, 5, 0, false)), 2_900);
1142        assert_eq!(sstore_refund(&info(5, 5, 0, false)), 4_800);
1143    }
1144
1145    /// Case 2.2 (dirty update, no restore): `original != current`, `value`
1146    /// matches neither original nor a clearing pattern. Expected: 100.
1147    #[test]
1148    fn case_2_2_dirty_update() {
1149        // original=5, current=10, new=15 — pure dirty update
1150        assert_eq!(sstore_gas_cost(&info(5, 10, 15, false)), 100);
1151        assert_eq!(sstore_refund(&info(5, 10, 15, false)), 0);
1152    }
1153
1154    /// Case 2.2.1.1 (un-clear): `original != 0`, `current == 0`, `value != 0`.
1155    /// Expected: 100 gas, `-clearingRefund` (−4_800).
1156    #[test]
1157    fn case_2_2_1_1_un_clear_dirty() {
1158        assert_eq!(sstore_gas_cost(&info(5, 0, 10, false)), 100);
1159        assert_eq!(sstore_refund(&info(5, 0, 10, false)), -4_800);
1160    }
1161
1162    /// Case 2.2.1.2 (clear dirty): `original != 0`, `current != 0`, `value == 0`.
1163    /// Expected: 100 gas, `+clearingRefund` (+4_800).
1164    #[test]
1165    fn case_2_2_1_2_clear_dirty() {
1166        // original=5, present=10, new=0 — value becomes zero from a dirty state
1167        assert_eq!(sstore_gas_cost(&info(5, 10, 0, false)), 100);
1168        assert_eq!(sstore_refund(&info(5, 10, 0, false)), 4_800);
1169    }
1170
1171    /// Case 2.2.2.1 (restore to inexistent original): `original == 0`, `value == 0`, `current !=
1172    /// 0`. Expected: 100 gas, refund `SstoreSetGasEIP2200 - WarmStorageReadCostEIP2929 =
1173    /// 19_900`.
1174    #[test]
1175    fn case_2_2_2_1_restore_to_zero_original() {
1176        assert_eq!(sstore_gas_cost(&info(0, 5, 0, false)), 100);
1177        assert_eq!(sstore_refund(&info(0, 5, 0, false)), 19_900);
1178    }
1179
1180    /// Case 2.2.2.2 (restore to non-zero original): `original != 0`,
1181    /// `value == original`, `current != value`, `current != 0`.
1182    /// Expected: 100 gas, refund
1183    /// `SstoreResetGasEIP2200 - ColdSloadCostEIP2929 - WarmStorageReadCostEIP2929 = 2_800`.
1184    #[test]
1185    fn case_2_2_2_2_restore_to_nonzero_original() {
1186        assert_eq!(sstore_gas_cost(&info(5, 10, 5, false)), 100);
1187        assert_eq!(sstore_refund(&info(5, 10, 5, false)), 2_800);
1188    }
1189
1190    // ── Cold-access surcharge (EIP-2929) ─────────────────────────────
1191
1192    /// Cold slot access adds `ColdSloadCostEIP2929 = 2_100` to the base.
1193    #[test]
1194    fn cold_access_adds_2100() {
1195        assert_eq!(sstore_gas_cost(&info(5, 5, 5, true)), 100 + 2_100);
1196        assert_eq!(sstore_gas_cost(&info(5, 5, 10, true)), 2_900 + 2_100);
1197        assert_eq!(sstore_gas_cost(&info(0, 0, 5, true)), 20_000 + 2_100);
1198        assert_eq!(sstore_gas_cost(&info(5, 10, 0, true)), 100 + 2_100);
1199    }
1200
1201    // ── Combined refund patterns (step 4 + step 5 can stack) ────────
1202
1203    /// `original != 0`, `current == 0` (un-clear refund of −4_800) AND
1204    /// `value == original` (restore refund of +2_800) stack, netting −2_000.
1205    /// Observable if an SSTORE clears then restores a slot within one tx.
1206    #[test]
1207    fn un_clear_plus_restore_stacks() {
1208        // original=5, current=0, new=5 — effectively restoring after a delete
1209        assert_eq!(sstore_gas_cost(&info(5, 0, 5, false)), 100);
1210        assert_eq!(sstore_refund(&info(5, 0, 5, false)), -4_800 + 2_800);
1211    }
1212
1213    /// original != 0, current != 0, value == 0 (clear refund of +4_800),
1214    /// AND NOT value == original (so no restore refund).
1215    #[test]
1216    fn clear_dirty_without_restore() {
1217        // original=5, current=10, new=0 — plain clear from a dirty state
1218        assert_eq!(sstore_refund(&info(5, 10, 0, false)), 4_800);
1219    }
1220
1221    // ── Multi-slot flush totals ──────────────────────────────────────
1222
1223    /// An 8-slot flush exercising every case above: the gas + refund totals
1224    /// must equal the sum of per-case expectations.
1225    #[test]
1226    fn mixed_eight_slot_flush_totals() {
1227        let slots = [
1228            info(0x12e, 0x12f, 0x130, false),        // dirty update
1229            info(0x880f, 0x880f, 0x23b5, false),     // reset clean
1230            info(0x10906e, 0x10906e, 0x275b, false), // reset clean
1231            info(0, 0, 1, false),                    // create
1232            info(0x2fea, 0x2fea, 0xf8e2, false),     // reset clean
1233            info(0x26658a, 0x26658a, 0x27bd, false), // reset clean
1234            info(0x7e51, 0x2160, 0x7e51, false),     // restore to original
1235            info(0x1a5f, 0x1a5f, 0x1c50, false),     // reset clean
1236        ];
1237        let total_cost: u64 = slots.iter().map(sstore_gas_cost).sum();
1238        let total_refund: i64 = slots.iter().map(sstore_refund).sum();
1239        assert_eq!(total_cost, 100 + 2_900 * 5 + 20_000 + 100);
1240        assert_eq!(total_cost, 34_700);
1241        assert_eq!(total_refund, 2_800);
1242    }
1243}