arb_stylus/
evm_api_impl.rs

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