Skip to main content

tempo_precompiles/
lib.rs

1//! Tempo precompile implementations.
2#![cfg_attr(not(test), warn(unused_crate_dependencies))]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4
5pub mod error;
6pub use error::{IntoPrecompileResult, Result};
7
8pub mod storage;
9
10pub(crate) mod ip_validation;
11
12pub mod account_keychain;
13pub mod address_registry;
14pub mod nonce;
15pub mod receive_policy_guard;
16pub mod signature_verifier;
17pub mod stablecoin_dex;
18pub mod storage_credits;
19pub mod tip20;
20pub mod tip20_channel_reserve;
21pub mod tip20_factory;
22pub mod tip403_registry;
23pub mod tip_fee_manager;
24pub mod validator_config;
25pub mod validator_config_v2;
26
27#[cfg(any(test, feature = "test-utils"))]
28pub mod test_util;
29
30use crate::{
31    account_keychain::AccountKeychain,
32    address_registry::AddressRegistry,
33    nonce::NonceManager,
34    receive_policy_guard::ReceivePolicyGuard,
35    signature_verifier::SignatureVerifier,
36    stablecoin_dex::StablecoinDEX,
37    storage::{StorageCtx, actions::StorageActions},
38    storage_credits::StorageCredits,
39    tip_fee_manager::TipFeeManager,
40    tip20::TIP20Token,
41    tip20_channel_reserve::TIP20ChannelReserve,
42    tip20_factory::TIP20Factory,
43    tip403_registry::TIP403Registry,
44    validator_config::ValidatorConfig,
45    validator_config_v2::ValidatorConfigV2,
46};
47use tempo_chainspec::hardfork::TempoHardfork;
48use tempo_primitives::TempoAddressExt;
49
50#[cfg(test)]
51use alloy::sol_types::SolInterface;
52use alloy::{
53    primitives::{Address, Bytes},
54    sol,
55    sol_types::{SolCall, SolError},
56};
57use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap};
58use revm::{
59    context::CfgEnv,
60    handler::EthPrecompiles,
61    precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult},
62    primitives::hardfork::SpecId,
63};
64
65pub use tempo_contracts::precompiles::{
66    ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN,
67    NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS,
68    SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS, STORAGE_CREDITS_ADDRESS,
69    TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, TIP20_FACTORY_ADDRESS,
70    TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS,
71};
72
73// Re-export storage layout helpers for read-only contexts (e.g., pool validation)
74pub use account_keychain::AuthorizedKey;
75
76/// Fixed system precompile addresses and corresponding activation hardfork
77pub const SYSTEM_PRECOMPILES: &[(Address, TempoHardfork)] = &[
78    (TIP403_REGISTRY_ADDRESS, TempoHardfork::Genesis),
79    (TIP_FEE_MANAGER_ADDRESS, TempoHardfork::Genesis),
80    (STABLECOIN_DEX_ADDRESS, TempoHardfork::Genesis),
81    (NONCE_PRECOMPILE_ADDRESS, TempoHardfork::Genesis),
82    (ACCOUNT_KEYCHAIN_ADDRESS, TempoHardfork::Genesis),
83    (VALIDATOR_CONFIG_ADDRESS, TempoHardfork::Genesis),
84    (VALIDATOR_CONFIG_V2_ADDRESS, TempoHardfork::Genesis),
85    (TIP20_FACTORY_ADDRESS, TempoHardfork::Genesis),
86    (ADDRESS_REGISTRY_ADDRESS, TempoHardfork::T3),
87    (SIGNATURE_VERIFIER_ADDRESS, TempoHardfork::T3),
88    (TIP20_CHANNEL_RESERVE_ADDRESS, TempoHardfork::T5),
89    (RECEIVE_POLICY_GUARD_ADDRESS, TempoHardfork::T6),
90    (STORAGE_CREDITS_ADDRESS, TempoHardfork::T7),
91];
92
93/// Returns `true` if `addr` is any precompile active at `spec`: a TIP-20 token (matched by prefix)
94/// or a fixed system precompile.
95pub fn is_precompile_address(addr: Address, spec: TempoHardfork) -> bool {
96    addr.is_tip20()
97        || SYSTEM_PRECOMPILES
98            .iter()
99            .any(|&(a, activated)| a == addr && spec >= activated)
100}
101
102/// Input per word cost. It covers abi decoding and cloning of input into call data.
103///
104/// Being careful and pricing it twice as COPY_COST to mitigate different abi decodings.
105pub const INPUT_PER_WORD_COST: u64 = 6;
106
107/// Gas cost for `ecrecover` signature verification (used by KeyAuthorization and Permit).
108pub const ECRECOVER_GAS: u64 = 3_000;
109
110/// Returns the gas cost for decoding calldata of the given length, rounded up to word boundaries.
111#[inline]
112pub fn input_cost(calldata_len: usize) -> u64 {
113    calldata_len
114        .div_ceil(32)
115        .saturating_mul(INPUT_PER_WORD_COST as usize) as u64
116}
117
118/// Trait implemented by all Tempo precompile contract types.
119///
120/// Precompiles must provide a dispatcher that decodes the 4-byte function selector from calldata,
121/// ABI-decodes the arguments, and routes to the corresponding method.
122pub trait Precompile {
123    /// Dispatches an EVM call to this precompile.
124    ///
125    /// Implementations should deduct calldata gas upfront via [`input_cost`], then decode the
126    /// 4-byte function selector from `calldata` and route to the matching method using
127    /// `dispatch_call` combined with the `view`, `mutate`, or `mutate_void` helpers.
128    ///
129    /// Business-logic errors are returned as reverted [`PrecompileOutput`]s with ABI-encoded
130    /// error data, while fatal failures (e.g. out-of-gas) are returned as
131    /// [`PrecompileError`](revm::precompile::PrecompileError).
132    fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult;
133}
134
135/// Returns the full Tempo precompiles for the given config.
136///
137/// Pre-T1C hardforks use Prague precompiles, T1C+ uses Osaka precompiles.
138/// Tempo-specific precompiles are also registered via [`extend_tempo_precompiles`].
139#[cfg(test)]
140pub fn tempo_precompiles(cfg: &CfgEnv<TempoHardfork>) -> PrecompilesMap {
141    tempo_precompiles_with_actions(cfg, StorageActions::disabled())
142}
143
144/// Returns the full Tempo precompiles for the given config and storage actions.
145///
146/// Pre-T1C hardforks use Prague precompiles, T1C+ uses Osaka precompiles.
147/// Tempo-specific precompiles are also registered via [`extend_tempo_precompiles`].
148pub fn tempo_precompiles_with_actions(
149    cfg: &CfgEnv<TempoHardfork>,
150    actions: StorageActions,
151) -> PrecompilesMap {
152    let spec = if cfg.spec.is_t1c() {
153        cfg.spec.into()
154    } else {
155        SpecId::PRAGUE
156    };
157    let mut precompiles = PrecompilesMap::from_static(EthPrecompiles::new(spec).precompiles);
158    extend_tempo_precompiles(&mut precompiles, cfg, actions);
159    precompiles
160}
161
162/// Registers Tempo-specific precompiles into an existing [`PrecompilesMap`] by installing a
163/// lookup function that matches addresses to their precompile: TIP-20 tokens (by prefix),
164/// TIP20Factory, TIP403Registry, TipFeeManager, StablecoinDEX, NonceManager, ValidatorConfig,
165/// AccountKeychain, and ValidatorConfigV2. Each precompile is wrapped via the `tempo_precompile!`
166/// macro which enforces direct-call-only (no delegatecall) and sets up the storage context.
167pub fn extend_tempo_precompiles(
168    precompiles: &mut PrecompilesMap,
169    cfg: &CfgEnv<TempoHardfork>,
170    actions: StorageActions,
171) {
172    let cfg = cfg.clone();
173
174    precompiles.set_precompile_lookup(move |address: &Address| {
175        if address.is_tip20() {
176            Some(TIP20Token::create_precompile(
177                *address,
178                &cfg,
179                actions.clone(),
180            ))
181        } else if *address == TIP20_FACTORY_ADDRESS {
182            Some(TIP20Factory::create_precompile(&cfg, actions.clone()))
183        } else if *address == TIP20_CHANNEL_RESERVE_ADDRESS && cfg.spec.is_t5() {
184            Some(TIP20ChannelReserve::create_precompile(
185                &cfg,
186                actions.clone(),
187            ))
188        } else if *address == ADDRESS_REGISTRY_ADDRESS && cfg.spec.is_t3() {
189            Some(AddressRegistry::create_precompile(&cfg, actions.clone()))
190        } else if *address == TIP403_REGISTRY_ADDRESS {
191            Some(TIP403Registry::create_precompile(&cfg, actions.clone()))
192        } else if *address == TIP_FEE_MANAGER_ADDRESS {
193            Some(TipFeeManager::create_precompile(&cfg, actions.clone()))
194        } else if *address == STABLECOIN_DEX_ADDRESS {
195            Some(StablecoinDEX::create_precompile(&cfg, actions.clone()))
196        } else if *address == NONCE_PRECOMPILE_ADDRESS {
197            Some(NonceManager::create_precompile(&cfg, actions.clone()))
198        } else if *address == VALIDATOR_CONFIG_ADDRESS {
199            Some(ValidatorConfig::create_precompile(&cfg, actions.clone()))
200        } else if *address == ACCOUNT_KEYCHAIN_ADDRESS {
201            Some(AccountKeychain::create_precompile(&cfg, actions.clone()))
202        } else if *address == VALIDATOR_CONFIG_V2_ADDRESS {
203            Some(ValidatorConfigV2::create_precompile(&cfg, actions.clone()))
204        } else if *address == SIGNATURE_VERIFIER_ADDRESS && cfg.spec.is_t3() {
205            Some(SignatureVerifier::create_precompile(&cfg, actions.clone()))
206        } else if *address == RECEIVE_POLICY_GUARD_ADDRESS && cfg.spec.is_t6() {
207            Some(ReceivePolicyGuard::create_precompile(&cfg, actions.clone()))
208        } else if *address == STORAGE_CREDITS_ADDRESS && cfg.spec.is_t7() {
209            Some(StorageCredits::create_precompile(&cfg, actions.clone()))
210        } else {
211            None
212        }
213    });
214}
215
216sol! {
217    error DelegateCallNotAllowed();
218    error StaticCallNotAllowed();
219}
220
221macro_rules! tempo_precompile {
222    ($id:expr, $cfg:expr, |$input:ident| $impl:expr) => {{
223        #[cfg(not(test))]
224        compile_error!("tempo_precompile! without actions is only available in tests");
225        #[cfg(test)]
226        tempo_precompile!($id, $cfg, StorageActions::disabled(), |$input| $impl)
227    }};
228    ($id:expr, $cfg:expr, $actions:expr, |$input:ident| $impl:expr) => {{
229        let spec = $cfg.spec;
230        let amsterdam_eip8037_enabled = $cfg.enable_amsterdam_eip8037;
231        let gas_params = $cfg.gas_params.clone();
232        let actions = $actions.clone();
233        DynPrecompile::new_stateful(PrecompileId::Custom($id.into()), move |$input| {
234            if !$input.is_direct_call() {
235                return Ok(PrecompileOutput::revert(
236                    0,
237                    DelegateCallNotAllowed {}.abi_encode().into(),
238                    $input.reservoir,
239                ));
240            }
241            let mut storage = crate::storage::evm::EvmPrecompileStorageProvider::new(
242                $input.internals,
243                $input.gas,
244                $input.reservoir,
245                spec,
246                amsterdam_eip8037_enabled,
247                $input.is_static,
248                gas_params.clone(),
249            )
250            .with_actions(actions.clone());
251            crate::storage::StorageCtx::enter(&mut storage, || {
252                $impl.call($input.data, $input.caller)
253            })
254        })
255    }};
256}
257
258impl TipFeeManager {
259    /// Creates the EVM precompile for this type.
260    pub fn create_precompile(
261        cfg: &CfgEnv<TempoHardfork>,
262        actions: StorageActions,
263    ) -> DynPrecompile {
264        tempo_precompile!("TipFeeManager", cfg, actions, |input| { Self::new() })
265    }
266}
267
268impl AddressRegistry {
269    /// Creates the EVM precompile for this type.
270    pub fn create_precompile(
271        cfg: &CfgEnv<TempoHardfork>,
272        actions: StorageActions,
273    ) -> DynPrecompile {
274        tempo_precompile!("AddressRegistry", cfg, actions, |input| { Self::new() })
275    }
276}
277
278impl TIP403Registry {
279    /// Creates the EVM precompile for this type.
280    pub fn create_precompile(
281        cfg: &CfgEnv<TempoHardfork>,
282        actions: StorageActions,
283    ) -> DynPrecompile {
284        tempo_precompile!("TIP403Registry", cfg, actions, |input| { Self::new() })
285    }
286}
287
288impl TIP20Factory {
289    /// Creates the EVM precompile for this type.
290    pub fn create_precompile(
291        cfg: &CfgEnv<TempoHardfork>,
292        actions: StorageActions,
293    ) -> DynPrecompile {
294        tempo_precompile!("TIP20Factory", cfg, actions, |input| { Self::new() })
295    }
296}
297
298impl TIP20Token {
299    /// Creates the EVM precompile for this type.
300    pub fn create_precompile(
301        address: Address,
302        cfg: &CfgEnv<TempoHardfork>,
303        actions: StorageActions,
304    ) -> DynPrecompile {
305        tempo_precompile!("TIP20Token", cfg, actions, |input| {
306            Self::from_address(address).expect("TIP20 prefix already verified")
307        })
308    }
309}
310
311impl StablecoinDEX {
312    /// Creates the EVM precompile for this type.
313    pub fn create_precompile(
314        cfg: &CfgEnv<TempoHardfork>,
315        actions: StorageActions,
316    ) -> DynPrecompile {
317        tempo_precompile!("StablecoinDEX", cfg, actions, |input| { Self::new() })
318    }
319}
320
321impl NonceManager {
322    /// Creates the EVM precompile for this type.
323    pub fn create_precompile(
324        cfg: &CfgEnv<TempoHardfork>,
325        actions: StorageActions,
326    ) -> DynPrecompile {
327        tempo_precompile!("NonceManager", cfg, actions, |input| { Self::new() })
328    }
329}
330
331impl AccountKeychain {
332    /// Creates the EVM precompile for this type.
333    pub fn create_precompile(
334        cfg: &CfgEnv<TempoHardfork>,
335        actions: StorageActions,
336    ) -> DynPrecompile {
337        tempo_precompile!("AccountKeychain", cfg, actions, |input| { Self::new() })
338    }
339}
340
341impl ValidatorConfig {
342    /// Creates the EVM precompile for this type.
343    pub fn create_precompile(
344        cfg: &CfgEnv<TempoHardfork>,
345        actions: StorageActions,
346    ) -> DynPrecompile {
347        tempo_precompile!("ValidatorConfig", cfg, actions, |input| { Self::new() })
348    }
349}
350
351impl ValidatorConfigV2 {
352    /// Creates the EVM precompile for this type.
353    pub fn create_precompile(
354        cfg: &CfgEnv<TempoHardfork>,
355        actions: StorageActions,
356    ) -> DynPrecompile {
357        tempo_precompile!("ValidatorConfigV2", cfg, actions, |input| { Self::new() })
358    }
359}
360
361impl SignatureVerifier {
362    /// Creates the EVM precompile for this type.
363    pub fn create_precompile(
364        cfg: &CfgEnv<TempoHardfork>,
365        actions: StorageActions,
366    ) -> DynPrecompile {
367        tempo_precompile!("SignatureVerifier", cfg, actions, |input| { Self::new() })
368    }
369}
370
371impl TIP20ChannelReserve {
372    /// Creates the EVM precompile for this type.
373    pub fn create_precompile(
374        cfg: &CfgEnv<TempoHardfork>,
375        actions: StorageActions,
376    ) -> DynPrecompile {
377        tempo_precompile!("TIP20ChannelReserve", cfg, actions, |input| { Self::new() })
378    }
379}
380
381impl ReceivePolicyGuard {
382    /// Creates the EVM precompile for this type.
383    pub fn create_precompile(
384        cfg: &CfgEnv<TempoHardfork>,
385        actions: StorageActions,
386    ) -> DynPrecompile {
387        tempo_precompile!("ReceivePolicyGuard", cfg, actions, |input| { Self::new() })
388    }
389}
390
391impl StorageCredits {
392    /// Creates the EVM precompile for this type.
393    pub fn create_precompile(
394        cfg: &CfgEnv<TempoHardfork>,
395        actions: StorageActions,
396    ) -> DynPrecompile {
397        tempo_precompile!("TIP1060StorageCredits", cfg, actions, |input| {
398            Self::new()
399        })
400    }
401}
402
403/// Dispatches a parameterless view call, encoding the return via `T`.
404#[inline]
405fn metadata<T: SolCall>(f: impl FnOnce() -> Result<T::Return>) -> PrecompileResult {
406    f().into_precompile_result(0, 0, |ret| T::abi_encode_returns(&ret).into())
407}
408
409/// Dispatches a read-only call with decoded arguments, encoding the return via `T`.
410#[inline]
411fn view<T: SolCall>(call: T, f: impl FnOnce(T) -> Result<T::Return>) -> PrecompileResult {
412    f(call).into_precompile_result(0, 0, |ret| T::abi_encode_returns(&ret).into())
413}
414
415/// Dispatches a state-mutating call that returns ABI-encoded data.
416///
417/// Rejects static calls with [`StaticCallNotAllowed`].
418#[inline]
419fn mutate<T: SolCall>(
420    call: T,
421    sender: Address,
422    f: impl FnOnce(Address, T) -> Result<T::Return>,
423) -> PrecompileResult {
424    if StorageCtx.is_static() {
425        return Ok(PrecompileOutput::revert(
426            0,
427            StaticCallNotAllowed {}.abi_encode().into(),
428            StorageCtx.reservoir(),
429        ));
430    }
431    f(sender, call).into_precompile_result(0, 0, |ret| T::abi_encode_returns(&ret).into())
432}
433
434/// Dispatches a state-mutating call that returns no data (e.g. `approve`, `transfer`).
435///
436/// Rejects static calls with [`StaticCallNotAllowed`].
437#[inline]
438fn mutate_void<T: SolCall>(
439    call: T,
440    sender: Address,
441    f: impl FnOnce(Address, T) -> Result<()>,
442) -> PrecompileResult {
443    if StorageCtx.is_static() {
444        return Ok(PrecompileOutput::revert(
445            0,
446            StaticCallNotAllowed {}.abi_encode().into(),
447            StorageCtx.reservoir(),
448        ));
449    }
450    f(sender, call).into_precompile_result(0, 0, |()| Bytes::new())
451}
452
453/// Deducts the calldata input cost, returning an OOG halt result if insufficient gas.
454#[inline]
455pub(crate) fn charge_input_cost(
456    storage: &mut StorageCtx,
457    calldata: &[u8],
458) -> Option<PrecompileResult> {
459    if storage.deduct_gas(input_cost(calldata.len())).is_err() {
460        return Some(Ok(storage.halt_output(PrecompileHalt::OutOfGas)));
461    }
462    None
463}
464
465/// Fills state gas accounting on a [`PrecompileOutput`] from the storage context.
466///
467/// State gas / reservoir tracking is only set when TIP-1016 (EIP-8037) is enabled.
468/// When disabled, `state_gas_used` must remain 0 to avoid leaking into revm's reservoir
469/// accounting and corrupting `tx_gas_used()` via `handle_reservoir_remaining_gas`.
470///
471/// SSTORE refund propagation is activated unconditionally at T4 so the
472/// `TempoPrecompileProvider` wrapper can apply refunds with `record_refund`. Pre-T4
473/// blocks were executed without refund propagation, so we cannot change their gas
474/// accounting.
475#[inline]
476fn fill_state_gas(output: &mut PrecompileOutput, storage: &StorageCtx) {
477    if storage.spec().is_t4() && output.is_success() {
478        output.gas_refunded = storage.gas_refunded();
479    }
480
481    if storage.amsterdam_eip8037_enabled() {
482        if output.is_success() {
483            // On success: parent takes the child's final reservoir.
484            output.reservoir = storage.reservoir();
485            output.state_gas_used = storage.state_gas_used();
486        } else {
487            // On revert or halt: state changes are undone, so ALL state gas returns
488            // to the parent's reservoir.
489            output.reservoir = storage.state_gas_used() + storage.reservoir();
490            output.state_gas_used = 0;
491        }
492    }
493}
494
495/// A selector schedule at a given hardfork boundary.
496///
497/// Before the hardfork activates, selectors in `added` are treated as unknown.
498/// After it activates, selectors in `dropped` are treated as unknown.
499#[derive(Clone, Copy, Debug, Default)]
500pub(crate) struct SelectorSchedule<'a> {
501    hardfork: TempoHardfork,
502    added: &'a [[u8; 4]],
503    dropped: &'a [[u8; 4]],
504}
505
506impl<'a> SelectorSchedule<'a> {
507    /// Creates a new schedule anchored at `hardfork` with no selectors registered yet.
508    pub(crate) const fn new(hardfork: TempoHardfork) -> Self {
509        Self {
510            hardfork,
511            added: &[],
512            dropped: &[],
513        }
514    }
515
516    /// Registers selectors that are introduced at this hardfork boundary.
517    ///
518    /// These selectors are treated as unknown BEFORE `hardfork` activates.
519    pub(crate) const fn with_added(mut self, selectors: &'a [[u8; 4]]) -> Self {
520        self.added = selectors;
521        self
522    }
523
524    /// Registers selectors that are removed at this hardfork boundary.
525    ///
526    /// These selectors are treated as unknown ONCE `hardfork` activates.
527    pub(crate) const fn with_dropped(mut self, selectors: &'a [[u8; 4]]) -> Self {
528        self.dropped = selectors;
529        self
530    }
531
532    /// Returns `true` if this schedule gates out `selector` under the `active` hardfork.
533    #[inline]
534    fn rejects(self, selector: [u8; 4], active: TempoHardfork) -> bool {
535        if self.hardfork <= active {
536            self.dropped
537        } else {
538            self.added
539        }
540        .contains(&selector)
541    }
542}
543
544/// Applies hardfork selector schedules, decodes calldata via `decode`, then dispatches to `f`.
545///
546/// Handles missing selectors (revert on T1+, error on earlier forks), hardfork-gated selectors,
547/// unknown selectors (ABI-encoded `UnknownFunctionSelector`), and malformed ABI data (empty
548/// revert).
549#[inline]
550pub(crate) fn dispatch_call<T>(
551    calldata: &[u8],
552    hardforks: &[SelectorSchedule<'_>],
553    decode: impl FnOnce(&[u8]) -> core::result::Result<T, alloy::sol_types::Error>,
554    f: impl FnOnce(T) -> PrecompileResult,
555) -> PrecompileResult {
556    let storage = StorageCtx::default();
557
558    if calldata.len() < 4 {
559        if storage.spec().is_t1() {
560            return Ok(storage.revert_output(Bytes::new()));
561        } else {
562            return Ok(storage.halt_output(PrecompileHalt::Other(
563                "Invalid input: missing function selector".into(),
564            )));
565        }
566    }
567
568    let selector: [u8; 4] = calldata[..4].try_into().expect("calldata len >= 4");
569    if hardforks
570        .iter()
571        .any(|schedule| schedule.rejects(selector, storage.spec()))
572    {
573        return storage.error_result(error::TempoPrecompileError::UnknownFunctionSelector(
574            selector,
575        ));
576    }
577
578    let result = decode(calldata);
579
580    match result {
581        Ok(call) => f(call).map(|mut res| {
582            // TODO: fix this, each precompile handler should either return output with proper gas values or don't return any gas values at all.
583            res.gas_used = storage.gas_used();
584            fill_state_gas(&mut res, &storage);
585            res
586        }),
587        Err(alloy::sol_types::Error::UnknownSelector { selector, .. }) => storage.error_result(
588            error::TempoPrecompileError::UnknownFunctionSelector(*selector),
589        ),
590        Err(_) => Ok(storage.revert_output(Bytes::new())),
591    }
592}
593
594/// Asserts that `result` is a reverted output whose bytes decode to `expected_error`.
595#[cfg(test)]
596pub fn expect_precompile_revert<E>(result: &PrecompileResult, expected_error: E)
597where
598    E: SolInterface + PartialEq + std::fmt::Debug,
599{
600    match result {
601        Ok(result) => {
602            assert!(result.is_revert());
603            let decoded = E::abi_decode(&result.bytes).unwrap();
604            assert_eq!(decoded, expected_error);
605        }
606        Err(other) => {
607            panic!("expected reverted output, got: {other:?}");
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::{
616        storage::{StorageCtx, hashmap::HashMapStorageProvider},
617        tip20::TIP20Token,
618    };
619    use alloy::primitives::{Address, Bytes, U256, bytes};
620    use alloy_evm::{
621        EthEvmFactory, EvmEnv, EvmFactory, EvmInternals,
622        precompiles::{Precompile as AlloyEvmPrecompile, PrecompileInput},
623    };
624    use revm::{
625        context::{ContextTr, TxEnv},
626        database::{CacheDB, EmptyDB},
627        state::{AccountInfo, Bytecode},
628    };
629    use tempo_contracts::precompiles::{ITIP20, UnknownFunctionSelector};
630
631    #[test]
632    fn test_precompile_delegatecall() {
633        let cfg = CfgEnv::<TempoHardfork>::default();
634        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
635            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
636        });
637
638        let db = CacheDB::new(EmptyDB::new());
639        let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
640        let block = evm.block.clone();
641        let tx = TxEnv::default();
642        let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
643
644        let target_address = Address::random();
645        let bytecode_address = Address::random();
646        let input = PrecompileInput {
647            data: &Bytes::new(),
648            caller: Address::ZERO,
649            internals: evm_internals,
650            gas: 0,
651            value: U256::ZERO,
652            is_static: false,
653            target_address,
654            bytecode_address,
655            reservoir: 0,
656        };
657
658        let result = AlloyEvmPrecompile::call(&precompile, input);
659
660        match result {
661            Ok(output) => {
662                assert!(output.is_revert());
663                let decoded = DelegateCallNotAllowed::abi_decode(&output.bytes).unwrap();
664                assert!(matches!(decoded, DelegateCallNotAllowed {}));
665            }
666            Err(_) => panic!("expected reverted output"),
667        }
668    }
669
670    #[test]
671    fn test_precompile_static_call() {
672        let cfg = CfgEnv::<TempoHardfork>::default();
673        let tx = TxEnv::default();
674        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
675            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
676        });
677
678        let token_address = PATH_USD_ADDRESS;
679
680        let call_static = |calldata: Bytes| {
681            let mut db = CacheDB::new(EmptyDB::new());
682            db.insert_account_info(
683                token_address,
684                AccountInfo {
685                    code: Some(Bytecode::new_raw(bytes!("0xEF"))),
686                    ..Default::default()
687                },
688            );
689            let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
690            let block = evm.block.clone();
691            let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
692
693            let input = PrecompileInput {
694                data: &calldata,
695                caller: Address::ZERO,
696                internals: evm_internals,
697                gas: 1_000_000,
698                is_static: true,
699                value: U256::ZERO,
700                target_address: token_address,
701                bytecode_address: token_address,
702                reservoir: 0,
703            };
704
705            AlloyEvmPrecompile::call(&precompile, input)
706        };
707
708        // Static calls into mutating functions should fail
709        let result = call_static(Bytes::from(
710            ITIP20::transferCall {
711                to: Address::random(),
712                amount: U256::from(100),
713            }
714            .abi_encode(),
715        ));
716        let output = result.expect("expected Ok");
717        assert!(output.is_revert());
718        assert!(StaticCallNotAllowed::abi_decode(&output.bytes).is_ok());
719
720        // Static calls into mutate void functions should fail
721        let result = call_static(Bytes::from(
722            ITIP20::approveCall {
723                spender: Address::random(),
724                amount: U256::from(100),
725            }
726            .abi_encode(),
727        ));
728        let output = result.expect("expected Ok");
729        assert!(output.is_revert());
730        assert!(StaticCallNotAllowed::abi_decode(&output.bytes).is_ok());
731
732        // Static calls into view functions should succeed
733        let result = call_static(Bytes::from(
734            ITIP20::balanceOfCall {
735                account: Address::random(),
736            }
737            .abi_encode(),
738        ));
739        let output = result.expect("expected Ok");
740        assert!(
741            !output.is_revert(),
742            "view function should not revert in static context"
743        );
744    }
745
746    /// Verifies that early-return revert paths in precompile `call()` methods correctly
747    /// report gas_used. When a TIP-20 precompile reverts before reaching `dispatch_call`
748    /// (e.g., uninitialized token), the gas consumed for input decoding and account info
749    /// checks must still be reported in the `PrecompileOutput.gas_used` field.
750    #[test]
751    fn test_early_return_revert_reports_gas_used() {
752        let mut cfg = CfgEnv::<TempoHardfork>::default();
753        cfg.set_spec_and_mainnet_gas_params(TempoHardfork::T1);
754        let tx = TxEnv::default();
755        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
756            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
757        });
758
759        let token_address = PATH_USD_ADDRESS;
760
761        // NO bytecode set -- token is uninitialized, early revert before dispatch_call
762        let db = CacheDB::new(EmptyDB::new());
763        let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
764        let block = evm.block.clone();
765        let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
766
767        let calldata = Bytes::from(
768            ITIP20::transferCall {
769                to: Address::random(),
770                amount: U256::from(100),
771            }
772            .abi_encode(),
773        );
774
775        let input = PrecompileInput {
776            data: &calldata,
777            caller: Address::ZERO,
778            internals: evm_internals,
779            gas: 1_000_000,
780            is_static: false,
781            value: U256::ZERO,
782            target_address: token_address,
783            bytecode_address: token_address,
784            reservoir: 0,
785        };
786
787        let result = AlloyEvmPrecompile::call(&precompile, input);
788        let output = result.expect("expected Ok");
789        assert!(
790            output.status.is_revert(),
791            "uninitialized token should revert"
792        );
793        // Gas used should include input_cost(68) = 18 + with_account_info cost
794        assert!(
795            output.gas_used > 0,
796            "early-return revert should report non-zero gas_used, got {}",
797            output.gas_used
798        );
799    }
800
801    #[test]
802    fn test_invalid_calldata_hardfork_behavior() {
803        let call_with_spec = |calldata: Bytes, spec: TempoHardfork| {
804            let mut cfg = CfgEnv::<TempoHardfork>::default();
805            cfg.set_spec_and_mainnet_gas_params(spec);
806            let tx = TxEnv::default();
807            let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
808                TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
809            });
810
811            let mut db = CacheDB::new(EmptyDB::new());
812            db.insert_account_info(
813                PATH_USD_ADDRESS,
814                AccountInfo {
815                    code: Some(Bytecode::new_raw(bytes!("0xEF"))),
816                    ..Default::default()
817                },
818            );
819            let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
820            let block = evm.block.clone();
821            let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
822
823            let input = PrecompileInput {
824                data: &calldata,
825                caller: Address::ZERO,
826                internals: evm_internals,
827                gas: 1_000_000,
828                is_static: false,
829                value: U256::ZERO,
830                target_address: PATH_USD_ADDRESS,
831                bytecode_address: PATH_USD_ADDRESS,
832                reservoir: 0,
833            };
834
835            AlloyEvmPrecompile::call(&precompile, input)
836        };
837
838        // T1: empty calldata (missing selector) should return a reverted output
839        let empty = call_with_spec(Bytes::new(), TempoHardfork::T1)
840            .expect("T1: expected Ok with reverted output");
841        assert!(empty.is_revert(), "T1: expected reverted output");
842        assert!(empty.bytes.is_empty());
843        // Gas was consumed
844        assert!(empty.gas_used > 0);
845
846        // T1: unknown selector should return a reverted output with UnknownFunctionSelector error
847        let unknown = call_with_spec(Bytes::from([0xAA; 4]), TempoHardfork::T1)
848            .expect("T1: expected Ok with reverted output");
849        assert!(unknown.is_revert(), "T1: expected reverted output");
850
851        // Verify it's an UnknownFunctionSelector error with the correct selector
852        let decoded =
853            tempo_contracts::precompiles::UnknownFunctionSelector::abi_decode(&unknown.bytes)
854                .expect("T1: expected UnknownFunctionSelector error");
855        assert_eq!(decoded.selector.as_slice(), &[0xAA, 0xAA, 0xAA, 0xAA]);
856
857        // Verify gas is tracked for both cases (unknown selector may cost slightly more due `INPUT_PER_WORD_COST`)
858        assert!(unknown.gas_used >= empty.gas_used);
859
860        // Pre-T1 (T0): invalid calldata should return a halted output
861        let result = call_with_spec(Bytes::new(), TempoHardfork::T0);
862        let output = result.expect("T0: expected Ok(halt) for invalid calldata");
863        assert!(
864            output.is_halt(),
865            "T0: expected halted output for invalid calldata"
866        );
867    }
868
869    /// Pre-T4 precompile calls must not report state_gas_used, because the new revm's
870    /// reservoir model propagates it via `handle_reservoir_remaining_gas` on revert/halt,
871    /// corrupting `tx_gas_used()`.
872    #[test]
873    fn test_precompile_state_gas_zero_pre_t4() {
874        let call_with_spec = |calldata: Bytes, spec: TempoHardfork| {
875            let mut cfg = CfgEnv::<TempoHardfork>::default();
876            cfg.set_spec_and_mainnet_gas_params(spec);
877            let tx = TxEnv::default();
878            let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
879                TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
880            });
881
882            let mut db = CacheDB::new(EmptyDB::new());
883            db.insert_account_info(
884                PATH_USD_ADDRESS,
885                AccountInfo {
886                    code: Some(Bytecode::new_raw(bytes!("0xEF"))),
887                    ..Default::default()
888                },
889            );
890            let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
891            let block = evm.block.clone();
892            let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
893
894            let input = PrecompileInput {
895                data: &calldata,
896                caller: Address::ZERO,
897                internals: evm_internals,
898                gas: 1_000_000,
899                is_static: false,
900                value: U256::ZERO,
901                target_address: PATH_USD_ADDRESS,
902                bytecode_address: PATH_USD_ADDRESS,
903                reservoir: 0,
904            };
905
906            AlloyEvmPrecompile::call(&precompile, input)
907        };
908
909        // Pre-T4 (T2): state_gas_used must be 0
910        let result = call_with_spec(
911            ITIP20::balanceOfCall::new((Address::ZERO,))
912                .abi_encode()
913                .into(),
914            TempoHardfork::T2,
915        )
916        .expect("T2 balanceOf should succeed");
917        assert!(result.gas_used > 0, "precompile should consume gas");
918        assert_eq!(
919            result.state_gas_used, 0,
920            "pre-T4 precompile must not report state_gas_used, got {}",
921            result.state_gas_used
922        );
923
924        // Pre-T4 (T1): reverted call should also have state_gas_used == 0
925        let reverted =
926            call_with_spec(Bytes::new(), TempoHardfork::T1).expect("T1 empty should revert");
927        assert!(reverted.status.is_revert());
928        assert_eq!(
929            reverted.state_gas_used, 0,
930            "pre-T4 reverted precompile must not report state_gas_used"
931        );
932    }
933
934    /// T4+ precompile `state_gas_used` must only include state-creating gas (cold SSTORE
935    /// zero->non-zero), not all gas consumed. A read-only operation like `balanceOf` must
936    /// have `state_gas_used == 0` even though `gas_used > 0`.
937    #[test]
938    fn test_t4_state_gas_only_includes_state_creating_ops() {
939        let mut cfg = CfgEnv::<TempoHardfork>::default();
940        cfg.set_spec_and_mainnet_gas_params(TempoHardfork::T4);
941
942        let sender = Address::repeat_byte(0x01);
943        let recipient = Address::repeat_byte(0x02);
944
945        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
946            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
947        });
948
949        let db = CacheDB::new(EmptyDB::new());
950        let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
951
952        // Set up TIP20 token state: initialize pathUSD and mint tokens to sender
953        {
954            let block = evm.block.clone();
955            let tx = TxEnv::default();
956            let internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
957            let mut provider =
958                crate::storage::evm::EvmPrecompileStorageProvider::new_max_gas(internals, &cfg);
959            crate::storage::StorageCtx::enter(&mut provider, || {
960                crate::test_util::TIP20Setup::path_usd(sender)
961                    .with_issuer(sender)
962                    .with_mint(sender, U256::from(1000))
963                    .apply()
964            })
965            .expect("TIP20 setup should succeed");
966        }
967
968        // 1) Read-only: balanceOf must have state_gas_used == 0
969        let calldata: Bytes = ITIP20::balanceOfCall { account: sender }
970            .abi_encode()
971            .into();
972        let block = evm.block.clone();
973        let tx = TxEnv::default();
974        let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
975        let input = PrecompileInput {
976            data: &calldata,
977            caller: sender,
978            internals: evm_internals,
979            gas: 1_000_000,
980            is_static: false,
981            value: U256::ZERO,
982            target_address: PATH_USD_ADDRESS,
983            bytecode_address: PATH_USD_ADDRESS,
984            reservoir: 0,
985        };
986        let output =
987            AlloyEvmPrecompile::call(&precompile, input).expect("balanceOf should succeed");
988        assert!(output.is_success());
989        assert!(output.gas_used > 0, "balanceOf should consume gas");
990        assert_eq!(
991            output.state_gas_used, 0,
992            "read-only balanceOf must have state_gas_used == 0, got {}",
993            output.state_gas_used
994        );
995
996        // 2) Transfer to existing account (warm SSTORE, not zero->non-zero for recipient
997        //    since we pre-fund recipient): state_gas_used must be less than gas_used
998        {
999            // Pre-fund recipient so the transfer is warm SSTORE (nonzero->nonzero)
1000            let block = evm.block.clone();
1001            let tx = TxEnv::default();
1002            let internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
1003            let mut provider =
1004                crate::storage::evm::EvmPrecompileStorageProvider::new_max_gas(internals, &cfg);
1005            crate::storage::StorageCtx::enter(&mut provider, || {
1006                crate::test_util::TIP20Setup::path_usd(sender)
1007                    .with_mint(recipient, U256::from(1))
1008                    .apply()
1009            })
1010            .expect("TIP20 setup should succeed");
1011        }
1012        let calldata: Bytes = ITIP20::transferCall {
1013            to: recipient,
1014            amount: U256::from(100),
1015        }
1016        .abi_encode()
1017        .into();
1018        let block = evm.block.clone();
1019        let tx = TxEnv::default();
1020        let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
1021        let input = PrecompileInput {
1022            data: &calldata,
1023            caller: sender,
1024            internals: evm_internals,
1025            gas: 1_000_000,
1026            is_static: false,
1027            value: U256::ZERO,
1028            target_address: PATH_USD_ADDRESS,
1029            bytecode_address: PATH_USD_ADDRESS,
1030            reservoir: 0,
1031        };
1032        let output = AlloyEvmPrecompile::call(&precompile, input).expect("transfer should succeed");
1033        assert!(output.is_success());
1034        assert!(output.gas_used > 0, "transfer should consume gas");
1035        assert_eq!(
1036            output.state_gas_used, 0,
1037            "transfer to existing account (nonzero->nonzero SSTORE) must have state_gas_used == 0, got {}",
1038            output.state_gas_used
1039        );
1040    }
1041
1042    /// T4+ precompile calls that trigger SSTORE refunds must encode the refund
1043    /// in the `reservoir` field of `PrecompileOutput`, so the wrapper
1044    /// `PrecompileProvider` can extract and apply it via `record_refund`.
1045    /// Pre-T4 blocks were executed without refund propagation, so they must NOT
1046    /// encode refunds.
1047    #[test]
1048    fn test_precompile_gas_refund_in_reservoir_t4() {
1049        let mut cfg = CfgEnv::<TempoHardfork>::default();
1050        cfg.set_spec_and_mainnet_gas_params(TempoHardfork::T4);
1051        // TIP-1016 gates state-gas refund propagation on `enable_amsterdam_eip8037`.
1052        cfg.enable_amsterdam_eip8037 = true;
1053
1054        let sender = Address::repeat_byte(0x01);
1055        let recipient = Address::repeat_byte(0x02);
1056
1057        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
1058            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
1059        });
1060
1061        let db = CacheDB::new(EmptyDB::new());
1062        let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
1063
1064        // Set up TIP20 token state: initialize pathUSD and mint tokens to sender
1065        {
1066            let block = evm.block.clone();
1067            let tx = TxEnv::default();
1068            let internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
1069            let mut provider =
1070                crate::storage::evm::EvmPrecompileStorageProvider::new_max_gas(internals, &cfg);
1071            crate::storage::StorageCtx::enter(&mut provider, || {
1072                crate::test_util::TIP20Setup::path_usd(sender)
1073                    .with_issuer(sender)
1074                    .with_mint(sender, U256::from(1000))
1075                    .apply()
1076            })
1077            .expect("TIP20 setup should succeed");
1078        }
1079
1080        // Transfer ALL tokens from sender to recipient (sender balance: 1000 → 0)
1081        // This triggers SSTORE refund because the balance slot goes from nonzero to zero.
1082        let calldata: Bytes = ITIP20::transferCall {
1083            to: recipient,
1084            amount: U256::from(1000),
1085        }
1086        .abi_encode()
1087        .into();
1088
1089        let block = evm.block.clone();
1090        let tx = TxEnv::default();
1091        let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
1092
1093        let input = PrecompileInput {
1094            data: &calldata,
1095            caller: sender,
1096            internals: evm_internals,
1097            gas: 1_000_000,
1098            is_static: false,
1099            value: U256::ZERO,
1100            target_address: PATH_USD_ADDRESS,
1101            bytecode_address: PATH_USD_ADDRESS,
1102            reservoir: 0,
1103        };
1104
1105        let output = AlloyEvmPrecompile::call(&precompile, input).expect("transfer should succeed");
1106        assert!(output.is_success(), "transfer should be successful");
1107
1108        // T4+: gas refund must be encoded in the gas_refunded field
1109        assert!(
1110            output.gas_refunded != 0,
1111            "T4+ successful precompile with SSTORE refund must encode refund in gas_refunded, got 0"
1112        );
1113    }
1114
1115    #[test]
1116    fn test_dispatch_call_applies_hardfork_selector_gates() -> eyre::Result<()> {
1117        alloy::sol! {
1118            interface ISelectorGatedTest {
1119                function stable() external;
1120                function t2Added(uint256 value) external;
1121                function t3Removed() external;
1122            }
1123        }
1124
1125        const SELECTOR_SCHEDULE: &[SelectorSchedule<'static>] = &[
1126            SelectorSchedule::new(TempoHardfork::T2)
1127                .with_added(&[ISelectorGatedTest::t2AddedCall::SELECTOR]),
1128            SelectorSchedule::new(TempoHardfork::T3)
1129                .with_dropped(&[ISelectorGatedTest::t3RemovedCall::SELECTOR]),
1130        ];
1131
1132        let call_with_spec = |spec: TempoHardfork, calldata: &[u8]| {
1133            let mut storage = HashMapStorageProvider::new_with_spec(1, spec);
1134            StorageCtx::enter(&mut storage, || {
1135                dispatch_call(
1136                    calldata,
1137                    SELECTOR_SCHEDULE,
1138                    ISelectorGatedTest::ISelectorGatedTestCalls::abi_decode,
1139                    |call| match call {
1140                        ISelectorGatedTest::ISelectorGatedTestCalls::stable(_) => {
1141                            Ok(PrecompileOutput::new(0, Bytes::from_static(b"stable"), 0))
1142                        }
1143                        ISelectorGatedTest::ISelectorGatedTestCalls::t2Added(_) => {
1144                            Ok(PrecompileOutput::new(0, Bytes::from_static(b"added"), 0))
1145                        }
1146                        ISelectorGatedTest::ISelectorGatedTestCalls::t3Removed(_) => {
1147                            Ok(PrecompileOutput::new(0, Bytes::from_static(b"removed"), 0))
1148                        }
1149                    },
1150                )
1151            })
1152        };
1153
1154        let t2_added_calldata = ISelectorGatedTest::t2AddedCall { value: U256::ZERO }.abi_encode();
1155        let t3_removed_calldata = ISelectorGatedTest::t3RemovedCall {}.abi_encode();
1156
1157        // pre-T2: selectors introduced at T2 must still look unknown.
1158        let pre_t2_added = call_with_spec(TempoHardfork::T1, &t2_added_calldata)?;
1159        assert!(pre_t2_added.is_revert());
1160        let decoded = UnknownFunctionSelector::abi_decode(&pre_t2_added.bytes)?;
1161        assert_eq!(
1162            decoded.selector.as_slice(),
1163            &ISelectorGatedTest::t2AddedCall::SELECTOR
1164        );
1165
1166        // T2+: that selector becomes available and dispatches normally.
1167        let post_t2_added = call_with_spec(TempoHardfork::T2, &t2_added_calldata)?;
1168        assert!(!post_t2_added.is_revert());
1169        assert_eq!(post_t2_added.bytes.as_ref(), b"added");
1170
1171        // pre-T3: selectors removed at T3 still dispatch normally.
1172        let pre_t3_removed = call_with_spec(TempoHardfork::T2, &t3_removed_calldata)?;
1173        assert!(!pre_t3_removed.is_revert());
1174        assert_eq!(pre_t3_removed.bytes.as_ref(), b"removed");
1175
1176        // T3+: the removed selector must now revert as unknown.
1177        let post_t3_removed = call_with_spec(TempoHardfork::T3, &t3_removed_calldata)?;
1178        assert!(post_t3_removed.is_revert());
1179        let decoded = UnknownFunctionSelector::abi_decode(&post_t3_removed.bytes)?;
1180        assert_eq!(
1181            decoded.selector.as_slice(),
1182            &ISelectorGatedTest::t3RemovedCall::SELECTOR
1183        );
1184
1185        // preT2: gated selectors must return `UnknownFunctionSelector` even for selector-only calldata.
1186        let malformed_added = call_with_spec(
1187            TempoHardfork::T1,
1188            &ISelectorGatedTest::t2AddedCall::SELECTOR,
1189        )?;
1190        assert!(malformed_added.is_revert());
1191        let decoded = UnknownFunctionSelector::abi_decode(&malformed_added.bytes)?;
1192        assert_eq!(
1193            decoded.selector.as_slice(),
1194            &ISelectorGatedTest::t2AddedCall::SELECTOR
1195        );
1196
1197        Ok(())
1198    }
1199
1200    #[test]
1201    fn test_input_cost_returns_non_zero_for_input() {
1202        // Empty input should cost 0
1203        assert_eq!(input_cost(0), 0);
1204
1205        // 1 byte should cost INPUT_PER_WORD_COST (rounds up to 1 word)
1206        assert_eq!(input_cost(1), INPUT_PER_WORD_COST);
1207
1208        // 32 bytes (1 word) should cost INPUT_PER_WORD_COST
1209        assert_eq!(input_cost(32), INPUT_PER_WORD_COST);
1210
1211        // 33 bytes (2 words) should cost 2 * INPUT_PER_WORD_COST
1212        assert_eq!(input_cost(33), INPUT_PER_WORD_COST * 2);
1213    }
1214
1215    #[test]
1216    fn test_extend_tempo_precompiles_registers_precompiles() {
1217        let mut cfg = CfgEnv::<TempoHardfork>::default();
1218        cfg.set_spec_and_mainnet_gas_params(TempoHardfork::T3);
1219        let precompiles = tempo_precompiles(&cfg);
1220
1221        // TIP20Factory should be registered
1222        let factory_precompile = precompiles.get(&TIP20_FACTORY_ADDRESS);
1223        assert!(
1224            factory_precompile.is_some(),
1225            "TIP20Factory should be registered"
1226        );
1227
1228        // TIP403Registry should be registered
1229        let registry_precompile = precompiles.get(&TIP403_REGISTRY_ADDRESS);
1230        assert!(
1231            registry_precompile.is_some(),
1232            "TIP403Registry should be registered"
1233        );
1234
1235        // TipFeeManager should be registered
1236        let fee_manager_precompile = precompiles.get(&TIP_FEE_MANAGER_ADDRESS);
1237        assert!(
1238            fee_manager_precompile.is_some(),
1239            "TipFeeManager should be registered"
1240        );
1241
1242        // StablecoinDEX should be registered
1243        let dex_precompile = precompiles.get(&STABLECOIN_DEX_ADDRESS);
1244        assert!(
1245            dex_precompile.is_some(),
1246            "StablecoinDEX should be registered"
1247        );
1248
1249        // NonceManager should be registered
1250        let nonce_precompile = precompiles.get(&NONCE_PRECOMPILE_ADDRESS);
1251        assert!(
1252            nonce_precompile.is_some(),
1253            "NonceManager should be registered"
1254        );
1255
1256        // ValidatorConfig should be registered
1257        let validator_precompile = precompiles.get(&VALIDATOR_CONFIG_ADDRESS);
1258        assert!(
1259            validator_precompile.is_some(),
1260            "ValidatorConfig should be registered"
1261        );
1262
1263        // ValidatorConfigV2 should be registered
1264        let validator_v2_precompile = precompiles.get(&VALIDATOR_CONFIG_V2_ADDRESS);
1265        assert!(
1266            validator_v2_precompile.is_some(),
1267            "ValidatorConfigV2 should be registered"
1268        );
1269
1270        // AccountKeychain should be registered
1271        let keychain_precompile = precompiles.get(&ACCOUNT_KEYCHAIN_ADDRESS);
1272        assert!(
1273            keychain_precompile.is_some(),
1274            "AccountKeychain should be registered"
1275        );
1276
1277        // SignatureVerifier should be registered at T3
1278        let sig_verifier_precompile = precompiles.get(&SIGNATURE_VERIFIER_ADDRESS);
1279        assert!(
1280            sig_verifier_precompile.is_some(),
1281            "SignatureVerifier should be registered at T3"
1282        );
1283
1284        // Channel reserve should be registered at T5
1285        let channel_reserve_precompile = precompiles.get(&TIP20_CHANNEL_RESERVE_ADDRESS);
1286        assert!(
1287            channel_reserve_precompile.is_none(),
1288            "TIP20 channel reserve should not be registered before T5"
1289        );
1290
1291        // TIP20 tokens with prefix should be registered
1292        let tip20_precompile = precompiles.get(&PATH_USD_ADDRESS);
1293        assert!(
1294            tip20_precompile.is_some(),
1295            "TIP20 tokens should be registered"
1296        );
1297
1298        // Random address without TIP20 prefix should NOT be registered
1299        let random_address = Address::random();
1300        let random_precompile = precompiles.get(&random_address);
1301        assert!(
1302            random_precompile.is_none(),
1303            "Random address should not be a precompile"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_signature_verifier_not_registered_pre_t3() {
1309        let cfg = CfgEnv::<TempoHardfork>::default();
1310        let precompiles = tempo_precompiles(&cfg);
1311
1312        assert!(
1313            precompiles.get(&SIGNATURE_VERIFIER_ADDRESS).is_none(),
1314            "SignatureVerifier should NOT be registered before T3"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_channel_reserve_registered_at_t5_only() {
1320        let pre_t5 = CfgEnv::<TempoHardfork>::default();
1321        assert!(
1322            tempo_precompiles(&pre_t5)
1323                .get(&TIP20_CHANNEL_RESERVE_ADDRESS)
1324                .is_none(),
1325            "TIP20 channel reserve should NOT be registered before T5"
1326        );
1327
1328        let mut t5 = CfgEnv::<TempoHardfork>::default();
1329        t5.set_spec_and_mainnet_gas_params(TempoHardfork::T5);
1330        assert!(
1331            tempo_precompiles(&t5)
1332                .get(&TIP20_CHANNEL_RESERVE_ADDRESS)
1333                .is_some(),
1334            "TIP20 channel reserve should be registered at T5"
1335        );
1336    }
1337
1338    #[test]
1339    fn test_is_precompile_address() {
1340        for &(address, activated) in SYSTEM_PRECOMPILES {
1341            assert!(is_precompile_address(address, activated));
1342            assert!(is_precompile_address(address, TempoHardfork::T7));
1343
1344            if activated != TempoHardfork::Genesis {
1345                assert!(!is_precompile_address(address, TempoHardfork::Genesis));
1346            }
1347        }
1348
1349        // Assert TIP20 prefixed addresses are classified as precompiles
1350        assert!(PATH_USD_ADDRESS.is_tip20());
1351        assert!(is_precompile_address(
1352            PATH_USD_ADDRESS,
1353            TempoHardfork::Genesis
1354        ));
1355    }
1356
1357    #[test]
1358    fn test_p256verify_availability_across_t1c_boundary() {
1359        let has_p256 = |spec: TempoHardfork| -> bool {
1360            // P256VERIFY lives at address 0x100 (256), added in Osaka
1361            let p256_addr = Address::from_word(U256::from(256).into());
1362
1363            let mut cfg = CfgEnv::<TempoHardfork>::default();
1364            cfg.set_spec_and_mainnet_gas_params(spec);
1365            tempo_precompiles(&cfg).get(&p256_addr).is_some()
1366        };
1367
1368        // Pre-T1C hardforks should use Prague precompiles (no P256VERIFY)
1369        for spec in [
1370            TempoHardfork::Genesis,
1371            TempoHardfork::T0,
1372            TempoHardfork::T1,
1373            TempoHardfork::T1A,
1374            TempoHardfork::T1B,
1375        ] {
1376            assert!(
1377                !has_p256(spec),
1378                "P256VERIFY should NOT be available at {spec:?} (pre-T1C)"
1379            );
1380        }
1381
1382        // T1C+ hardforks should use Osaka precompiles (P256VERIFY available)
1383        for spec in [TempoHardfork::T1C, TempoHardfork::T2] {
1384            assert!(
1385                has_p256(spec),
1386                "P256VERIFY should be available at {spec:?} (T1C+)"
1387            );
1388        }
1389    }
1390}