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 nonce;
14pub mod stablecoin_dex;
15pub mod tip20;
16pub mod tip20_factory;
17pub mod tip403_registry;
18pub mod tip_fee_manager;
19pub mod validator_config;
20pub mod validator_config_v2;
21
22#[cfg(any(test, feature = "test-utils"))]
23pub mod test_util;
24
25use crate::{
26    account_keychain::AccountKeychain,
27    nonce::NonceManager,
28    stablecoin_dex::StablecoinDEX,
29    storage::StorageCtx,
30    tip_fee_manager::TipFeeManager,
31    tip20::{TIP20Token, is_tip20_prefix},
32    tip20_factory::TIP20Factory,
33    tip403_registry::TIP403Registry,
34    validator_config::ValidatorConfig,
35    validator_config_v2::ValidatorConfigV2,
36};
37use tempo_chainspec::hardfork::TempoHardfork;
38
39#[cfg(test)]
40use alloy::sol_types::SolInterface;
41use alloy::{
42    primitives::{Address, Bytes},
43    sol,
44    sol_types::{SolCall, SolError},
45};
46use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap};
47use revm::{
48    context::CfgEnv,
49    handler::EthPrecompiles,
50    precompile::{PrecompileError, PrecompileId, PrecompileOutput, PrecompileResult},
51    primitives::hardfork::SpecId,
52};
53
54pub use tempo_contracts::precompiles::{
55    ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS,
56    STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP20_FACTORY_ADDRESS,
57    TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS,
58};
59
60// Re-export storage layout helpers for read-only contexts (e.g., pool validation)
61pub use account_keychain::AuthorizedKey;
62
63/// Input per word cost. It covers abi decoding and cloning of input into call data.
64///
65/// Being careful and pricing it twice as COPY_COST to mitigate different abi decodings.
66pub const INPUT_PER_WORD_COST: u64 = 6;
67
68/// Gas cost for `ecrecover` signature verification (used by KeyAuthorization and Permit).
69pub const ECRECOVER_GAS: u64 = 3_000;
70
71/// Returns the gas cost for decoding calldata of the given length, rounded up to word boundaries.
72#[inline]
73pub fn input_cost(calldata_len: usize) -> u64 {
74    calldata_len
75        .div_ceil(32)
76        .saturating_mul(INPUT_PER_WORD_COST as usize) as u64
77}
78
79/// Trait implemented by all Tempo precompile contract types.
80///
81/// Precompiles must provide a dispatcher that decodes the 4-byte function selector from calldata,
82/// ABI-decodes the arguments, and routes to the corresponding method.
83pub trait Precompile {
84    /// Dispatches an EVM call to this precompile.
85    ///
86    /// Implementations should deduct calldata gas upfront via [`input_cost`], then decode the
87    /// 4-byte function selector from `calldata` and route to the matching method using
88    /// `dispatch_call` combined with the `view`, `mutate`, or `mutate_void` helpers.
89    ///
90    /// Business-logic errors are returned as reverted [`PrecompileOutput`]s with ABI-encoded
91    /// error data, while fatal failures (e.g. out-of-gas) are returned as [`PrecompileError`].
92    fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult;
93}
94
95/// Returns the full Tempo precompiles for the given config.
96///
97/// Pre-T1C hardforks use Prague precompiles, T1C+ uses Osaka precompiles.
98/// Tempo-specific precompiles are also registered via [`extend_tempo_precompiles`].
99pub fn tempo_precompiles(cfg: &CfgEnv<TempoHardfork>) -> PrecompilesMap {
100    let spec = if cfg.spec.is_t1c() {
101        cfg.spec.into()
102    } else {
103        SpecId::PRAGUE
104    };
105    let mut precompiles = PrecompilesMap::from_static(EthPrecompiles::new(spec).precompiles);
106    extend_tempo_precompiles(&mut precompiles, cfg);
107    precompiles
108}
109
110/// Registers Tempo-specific precompiles into an existing [`PrecompilesMap`] by installing a
111/// lookup function that matches addresses to their precompile: TIP-20 tokens (by prefix),
112/// TIP20Factory, TIP403Registry, TipFeeManager, StablecoinDEX, NonceManager, ValidatorConfig,
113/// AccountKeychain, and ValidatorConfigV2. Each precompile is wrapped via the `tempo_precompile!`
114/// macro which enforces direct-call-only (no delegatecall) and sets up the storage context.
115pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv<TempoHardfork>) {
116    let cfg = cfg.clone();
117
118    precompiles.set_precompile_lookup(move |address: &Address| {
119        if is_tip20_prefix(*address) {
120            Some(TIP20Token::create_precompile(*address, &cfg))
121        } else if *address == TIP20_FACTORY_ADDRESS {
122            Some(TIP20Factory::create_precompile(&cfg))
123        } else if *address == TIP403_REGISTRY_ADDRESS {
124            Some(TIP403Registry::create_precompile(&cfg))
125        } else if *address == TIP_FEE_MANAGER_ADDRESS {
126            Some(TipFeeManager::create_precompile(&cfg))
127        } else if *address == STABLECOIN_DEX_ADDRESS {
128            Some(StablecoinDEX::create_precompile(&cfg))
129        } else if *address == NONCE_PRECOMPILE_ADDRESS {
130            Some(NonceManager::create_precompile(&cfg))
131        } else if *address == VALIDATOR_CONFIG_ADDRESS {
132            Some(ValidatorConfig::create_precompile(&cfg))
133        } else if *address == ACCOUNT_KEYCHAIN_ADDRESS {
134            Some(AccountKeychain::create_precompile(&cfg))
135        } else if *address == VALIDATOR_CONFIG_V2_ADDRESS {
136            Some(ValidatorConfigV2::create_precompile(&cfg))
137        } else {
138            None
139        }
140    });
141}
142
143sol! {
144    error DelegateCallNotAllowed();
145    error StaticCallNotAllowed();
146}
147
148macro_rules! tempo_precompile {
149    ($id:expr, $cfg:expr, |$input:ident| $impl:expr) => {{
150        let spec = $cfg.spec;
151        let gas_params = $cfg.gas_params.clone();
152        DynPrecompile::new_stateful(PrecompileId::Custom($id.into()), move |$input| {
153            if !$input.is_direct_call() {
154                return Ok(PrecompileOutput::new_reverted(
155                    0,
156                    DelegateCallNotAllowed {}.abi_encode().into(),
157                ));
158            }
159            let mut storage = crate::storage::evm::EvmPrecompileStorageProvider::new(
160                $input.internals,
161                $input.gas,
162                spec,
163                $input.is_static,
164                gas_params.clone(),
165            );
166            crate::storage::StorageCtx::enter(&mut storage, || {
167                $impl.call($input.data, $input.caller)
168            })
169        })
170    }};
171}
172
173impl TipFeeManager {
174    /// Creates the EVM precompile for this type.
175    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
176        tempo_precompile!("TipFeeManager", cfg, |input| { Self::new() })
177    }
178}
179
180impl TIP403Registry {
181    /// Creates the EVM precompile for this type.
182    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
183        tempo_precompile!("TIP403Registry", cfg, |input| { Self::new() })
184    }
185}
186
187impl TIP20Factory {
188    /// Creates the EVM precompile for this type.
189    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
190        tempo_precompile!("TIP20Factory", cfg, |input| { Self::new() })
191    }
192}
193
194impl TIP20Token {
195    /// Creates the EVM precompile for this type.
196    pub fn create_precompile(address: Address, cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
197        tempo_precompile!("TIP20Token", cfg, |input| {
198            Self::from_address(address).expect("TIP20 prefix already verified")
199        })
200    }
201}
202
203impl StablecoinDEX {
204    /// Creates the EVM precompile for this type.
205    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
206        tempo_precompile!("StablecoinDEX", cfg, |input| { Self::new() })
207    }
208}
209
210impl NonceManager {
211    /// Creates the EVM precompile for this type.
212    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
213        tempo_precompile!("NonceManager", cfg, |input| { Self::new() })
214    }
215}
216
217impl AccountKeychain {
218    /// Creates the EVM precompile for this type.
219    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
220        tempo_precompile!("AccountKeychain", cfg, |input| { Self::new() })
221    }
222}
223
224impl ValidatorConfig {
225    /// Creates the EVM precompile for this type.
226    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
227        tempo_precompile!("ValidatorConfig", cfg, |input| { Self::new() })
228    }
229}
230
231impl ValidatorConfigV2 {
232    /// Creates the EVM precompile for this type.
233    pub fn create_precompile(cfg: &CfgEnv<TempoHardfork>) -> DynPrecompile {
234        tempo_precompile!("ValidatorConfigV2", cfg, |input| { Self::new() })
235    }
236}
237
238/// Dispatches a parameterless view call, encoding the return via `T`.
239#[inline]
240fn metadata<T: SolCall>(f: impl FnOnce() -> Result<T::Return>) -> PrecompileResult {
241    f().into_precompile_result(0, |ret| T::abi_encode_returns(&ret).into())
242}
243
244/// Dispatches a read-only call with decoded arguments, encoding the return via `T`.
245#[inline]
246fn view<T: SolCall>(call: T, f: impl FnOnce(T) -> Result<T::Return>) -> PrecompileResult {
247    f(call).into_precompile_result(0, |ret| T::abi_encode_returns(&ret).into())
248}
249
250/// Dispatches a state-mutating call that returns ABI-encoded data.
251///
252/// Rejects static calls with [`StaticCallNotAllowed`].
253#[inline]
254fn mutate<T: SolCall>(
255    call: T,
256    sender: Address,
257    f: impl FnOnce(Address, T) -> Result<T::Return>,
258) -> PrecompileResult {
259    if StorageCtx.is_static() {
260        return Ok(PrecompileOutput::new_reverted(
261            0,
262            StaticCallNotAllowed {}.abi_encode().into(),
263        ));
264    }
265    f(sender, call).into_precompile_result(0, |ret| T::abi_encode_returns(&ret).into())
266}
267
268/// Dispatches a state-mutating call that returns no data (e.g. `approve`, `transfer`).
269///
270/// Rejects static calls with [`StaticCallNotAllowed`].
271#[inline]
272fn mutate_void<T: SolCall>(
273    call: T,
274    sender: Address,
275    f: impl FnOnce(Address, T) -> Result<()>,
276) -> PrecompileResult {
277    if StorageCtx.is_static() {
278        return Ok(PrecompileOutput::new_reverted(
279            0,
280            StaticCallNotAllowed {}.abi_encode().into(),
281        ));
282    }
283    f(sender, call).into_precompile_result(0, |()| Bytes::new())
284}
285
286/// Fills gas accounting fields on a [`PrecompileOutput`] from the storage context.
287#[inline]
288fn fill_precompile_output(mut output: PrecompileOutput, storage: &StorageCtx) -> PrecompileOutput {
289    output.gas_used = storage.gas_used();
290
291    // add refund only if it is not reverted
292    if !output.reverted {
293        output.gas_refunded = storage.gas_refunded();
294    }
295    output
296}
297
298/// Returns an ABI-encoded `UnknownFunctionSelector` revert for the given 4-byte selector.
299#[inline]
300pub fn unknown_selector(selector: [u8; 4], gas: u64) -> PrecompileResult {
301    error::TempoPrecompileError::UnknownFunctionSelector(selector).into_precompile_result(gas)
302}
303
304/// Decodes calldata via `decode`, then dispatches to `f`.
305///
306/// Handles missing selectors (revert on T1+, error on earlier forks), unknown selectors
307/// (ABI-encoded `UnknownFunctionSelector`), and malformed ABI data (empty revert).
308///
309/// Gas accounting is applied via [`fill_precompile_output`].
310#[inline]
311fn dispatch_call<T>(
312    calldata: &[u8],
313    decode: impl FnOnce(&[u8]) -> core::result::Result<T, alloy::sol_types::Error>,
314    f: impl FnOnce(T) -> PrecompileResult,
315) -> PrecompileResult {
316    let storage = StorageCtx::default();
317
318    if calldata.len() < 4 {
319        if storage.spec().is_t1() {
320            return Ok(fill_precompile_output(
321                PrecompileOutput::new_reverted(0, Bytes::new()),
322                &storage,
323            ));
324        } else {
325            return Err(PrecompileError::Other(
326                "Invalid input: missing function selector".into(),
327            ));
328        }
329    }
330    let result = decode(calldata);
331
332    match result {
333        Ok(call) => f(call).map(|res| fill_precompile_output(res, &storage)),
334        Err(alloy::sol_types::Error::UnknownSelector { selector, .. }) => {
335            unknown_selector(*selector, storage.gas_used())
336                .map(|res| fill_precompile_output(res, &storage))
337        }
338        Err(_) => Ok(fill_precompile_output(
339            PrecompileOutput::new_reverted(0, Bytes::new()),
340            &storage,
341        )),
342    }
343}
344
345/// Asserts that `result` is a reverted output whose bytes decode to `expected_error`.
346#[cfg(test)]
347pub fn expect_precompile_revert<E>(result: &PrecompileResult, expected_error: E)
348where
349    E: SolInterface + PartialEq + std::fmt::Debug,
350{
351    match result {
352        Ok(result) => {
353            assert!(result.reverted);
354            let decoded = E::abi_decode(&result.bytes).unwrap();
355            assert_eq!(decoded, expected_error);
356        }
357        Err(other) => {
358            panic!("expected reverted output, got: {other:?}");
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::tip20::TIP20Token;
367    use alloy::primitives::{Address, Bytes, U256, bytes};
368    use alloy_evm::{
369        EthEvmFactory, EvmEnv, EvmFactory, EvmInternals,
370        precompiles::{Precompile as AlloyEvmPrecompile, PrecompileInput},
371    };
372    use revm::{
373        context::{ContextTr, TxEnv},
374        database::{CacheDB, EmptyDB},
375        state::{AccountInfo, Bytecode},
376    };
377    use tempo_contracts::precompiles::ITIP20;
378
379    #[test]
380    fn test_precompile_delegatecall() {
381        let cfg = CfgEnv::<TempoHardfork>::default();
382        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
383            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
384        });
385
386        let db = CacheDB::new(EmptyDB::new());
387        let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
388        let block = evm.block.clone();
389        let tx = TxEnv::default();
390        let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
391
392        let target_address = Address::random();
393        let bytecode_address = Address::random();
394        let input = PrecompileInput {
395            data: &Bytes::new(),
396            caller: Address::ZERO,
397            internals: evm_internals,
398            gas: 0,
399            value: U256::ZERO,
400            is_static: false,
401            target_address,
402            bytecode_address,
403        };
404
405        let result = AlloyEvmPrecompile::call(&precompile, input);
406
407        match result {
408            Ok(output) => {
409                assert!(output.reverted);
410                let decoded = DelegateCallNotAllowed::abi_decode(&output.bytes).unwrap();
411                assert!(matches!(decoded, DelegateCallNotAllowed {}));
412            }
413            Err(_) => panic!("expected reverted output"),
414        }
415    }
416
417    #[test]
418    fn test_precompile_static_call() {
419        let cfg = CfgEnv::<TempoHardfork>::default();
420        let tx = TxEnv::default();
421        let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
422            TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
423        });
424
425        let token_address = PATH_USD_ADDRESS;
426
427        let call_static = |calldata: Bytes| {
428            let mut db = CacheDB::new(EmptyDB::new());
429            db.insert_account_info(
430                token_address,
431                AccountInfo {
432                    code: Some(Bytecode::new_raw(bytes!("0xEF"))),
433                    ..Default::default()
434                },
435            );
436            let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
437            let block = evm.block.clone();
438            let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
439
440            let input = PrecompileInput {
441                data: &calldata,
442                caller: Address::ZERO,
443                internals: evm_internals,
444                gas: 1_000_000,
445                is_static: true,
446                value: U256::ZERO,
447                target_address: token_address,
448                bytecode_address: token_address,
449            };
450
451            AlloyEvmPrecompile::call(&precompile, input)
452        };
453
454        // Static calls into mutating functions should fail
455        let result = call_static(Bytes::from(
456            ITIP20::transferCall {
457                to: Address::random(),
458                amount: U256::from(100),
459            }
460            .abi_encode(),
461        ));
462        let output = result.expect("expected Ok");
463        assert!(output.reverted);
464        assert!(StaticCallNotAllowed::abi_decode(&output.bytes).is_ok());
465
466        // Static calls into mutate void functions should fail
467        let result = call_static(Bytes::from(
468            ITIP20::approveCall {
469                spender: Address::random(),
470                amount: U256::from(100),
471            }
472            .abi_encode(),
473        ));
474        let output = result.expect("expected Ok");
475        assert!(output.reverted);
476        assert!(StaticCallNotAllowed::abi_decode(&output.bytes).is_ok());
477
478        // Static calls into view functions should succeed
479        let result = call_static(Bytes::from(
480            ITIP20::balanceOfCall {
481                account: Address::random(),
482            }
483            .abi_encode(),
484        ));
485        let output = result.expect("expected Ok");
486        assert!(
487            !output.reverted,
488            "view function should not revert in static context"
489        );
490    }
491
492    #[test]
493    fn test_invalid_calldata_hardfork_behavior() {
494        let call_with_spec = |calldata: Bytes, spec: TempoHardfork| {
495            let mut cfg = CfgEnv::<TempoHardfork>::default();
496            cfg.set_spec(spec);
497            let tx = TxEnv::default();
498            let precompile = tempo_precompile!("TIP20Token", &cfg, |input| {
499                TIP20Token::from_address(PATH_USD_ADDRESS).expect("PATH_USD_ADDRESS is valid")
500            });
501
502            let mut db = CacheDB::new(EmptyDB::new());
503            db.insert_account_info(
504                PATH_USD_ADDRESS,
505                AccountInfo {
506                    code: Some(Bytecode::new_raw(bytes!("0xEF"))),
507                    ..Default::default()
508                },
509            );
510            let mut evm = EthEvmFactory::default().create_evm(db, EvmEnv::default());
511            let block = evm.block.clone();
512            let evm_internals = EvmInternals::new(evm.journal_mut(), &block, &cfg, &tx);
513
514            let input = PrecompileInput {
515                data: &calldata,
516                caller: Address::ZERO,
517                internals: evm_internals,
518                gas: 1_000_000,
519                is_static: false,
520                value: U256::ZERO,
521                target_address: PATH_USD_ADDRESS,
522                bytecode_address: PATH_USD_ADDRESS,
523            };
524
525            AlloyEvmPrecompile::call(&precompile, input)
526        };
527
528        // T1: empty calldata (missing selector) should return a reverted output
529        let empty = call_with_spec(Bytes::new(), TempoHardfork::T1)
530            .expect("T1: expected Ok with reverted output");
531        assert!(empty.reverted, "T1: expected reverted output");
532        assert!(empty.bytes.is_empty());
533        assert!(empty.gas_used != 0);
534        assert_eq!(empty.gas_refunded, 0);
535
536        // T1: unknown selector should return a reverted output with UnknownFunctionSelector error
537        let unknown = call_with_spec(Bytes::from([0xAA; 4]), TempoHardfork::T1)
538            .expect("T1: expected Ok with reverted output");
539        assert!(unknown.reverted, "T1: expected reverted output");
540
541        // Verify it's an UnknownFunctionSelector error with the correct selector
542        let decoded =
543            tempo_contracts::precompiles::UnknownFunctionSelector::abi_decode(&unknown.bytes)
544                .expect("T1: expected UnknownFunctionSelector error");
545        assert_eq!(decoded.selector.as_slice(), &[0xAA, 0xAA, 0xAA, 0xAA]);
546
547        // Verify gas is tracked for both cases (unknown selector may cost slightly more due `INPUT_PER_WORD_COST`)
548        assert!(unknown.gas_used >= empty.gas_used);
549        assert_eq!(unknown.gas_refunded, empty.gas_refunded);
550
551        // Pre-T1 (T0): invalid calldata should return PrecompileError
552        let result = call_with_spec(Bytes::new(), TempoHardfork::T0);
553        assert!(
554            matches!(
555                &result,
556                Err(PrecompileError::Other(msg)) if msg.contains("missing function selector")
557            ),
558            "T0: expected PrecompileError for invalid calldata, got {result:?}"
559        );
560    }
561
562    #[test]
563    fn test_input_cost_returns_non_zero_for_input() {
564        // Empty input should cost 0
565        assert_eq!(input_cost(0), 0);
566
567        // 1 byte should cost INPUT_PER_WORD_COST (rounds up to 1 word)
568        assert_eq!(input_cost(1), INPUT_PER_WORD_COST);
569
570        // 32 bytes (1 word) should cost INPUT_PER_WORD_COST
571        assert_eq!(input_cost(32), INPUT_PER_WORD_COST);
572
573        // 33 bytes (2 words) should cost 2 * INPUT_PER_WORD_COST
574        assert_eq!(input_cost(33), INPUT_PER_WORD_COST * 2);
575    }
576
577    #[test]
578    fn test_extend_tempo_precompiles_registers_precompiles() {
579        let cfg = CfgEnv::<TempoHardfork>::default();
580        let precompiles = tempo_precompiles(&cfg);
581
582        // TIP20Factory should be registered
583        let factory_precompile = precompiles.get(&TIP20_FACTORY_ADDRESS);
584        assert!(
585            factory_precompile.is_some(),
586            "TIP20Factory should be registered"
587        );
588
589        // TIP403Registry should be registered
590        let registry_precompile = precompiles.get(&TIP403_REGISTRY_ADDRESS);
591        assert!(
592            registry_precompile.is_some(),
593            "TIP403Registry should be registered"
594        );
595
596        // TipFeeManager should be registered
597        let fee_manager_precompile = precompiles.get(&TIP_FEE_MANAGER_ADDRESS);
598        assert!(
599            fee_manager_precompile.is_some(),
600            "TipFeeManager should be registered"
601        );
602
603        // StablecoinDEX should be registered
604        let dex_precompile = precompiles.get(&STABLECOIN_DEX_ADDRESS);
605        assert!(
606            dex_precompile.is_some(),
607            "StablecoinDEX should be registered"
608        );
609
610        // NonceManager should be registered
611        let nonce_precompile = precompiles.get(&NONCE_PRECOMPILE_ADDRESS);
612        assert!(
613            nonce_precompile.is_some(),
614            "NonceManager should be registered"
615        );
616
617        // ValidatorConfig should be registered
618        let validator_precompile = precompiles.get(&VALIDATOR_CONFIG_ADDRESS);
619        assert!(
620            validator_precompile.is_some(),
621            "ValidatorConfig should be registered"
622        );
623
624        // ValidatorConfigV2 should be registered
625        let validator_v2_precompile = precompiles.get(&VALIDATOR_CONFIG_V2_ADDRESS);
626        assert!(
627            validator_v2_precompile.is_some(),
628            "ValidatorConfigV2 should be registered"
629        );
630
631        // AccountKeychain should be registered
632        let keychain_precompile = precompiles.get(&ACCOUNT_KEYCHAIN_ADDRESS);
633        assert!(
634            keychain_precompile.is_some(),
635            "AccountKeychain should be registered"
636        );
637
638        // TIP20 tokens with prefix should be registered
639        let tip20_precompile = precompiles.get(&PATH_USD_ADDRESS);
640        assert!(
641            tip20_precompile.is_some(),
642            "TIP20 tokens should be registered"
643        );
644
645        // Random address without TIP20 prefix should NOT be registered
646        let random_address = Address::random();
647        let random_precompile = precompiles.get(&random_address);
648        assert!(
649            random_precompile.is_none(),
650            "Random address should not be a precompile"
651        );
652    }
653
654    #[test]
655    fn test_p256verify_availability_across_t1c_boundary() {
656        let has_p256 = |spec: TempoHardfork| -> bool {
657            // P256VERIFY lives at address 0x100 (256), added in Osaka
658            let p256_addr = Address::from_word(U256::from(256).into());
659
660            let mut cfg = CfgEnv::<TempoHardfork>::default();
661            cfg.set_spec(spec);
662            tempo_precompiles(&cfg).get(&p256_addr).is_some()
663        };
664
665        // Pre-T1C hardforks should use Prague precompiles (no P256VERIFY)
666        for spec in [
667            TempoHardfork::Genesis,
668            TempoHardfork::T0,
669            TempoHardfork::T1,
670            TempoHardfork::T1A,
671            TempoHardfork::T1B,
672        ] {
673            assert!(
674                !has_p256(spec),
675                "P256VERIFY should NOT be available at {spec:?} (pre-T1C)"
676            );
677        }
678
679        // T1C+ hardforks should use Osaka precompiles (P256VERIFY available)
680        for spec in [TempoHardfork::T1C, TempoHardfork::T2] {
681            assert!(
682                has_p256(spec),
683                "P256VERIFY should be available at {spec:?} (T1C+)"
684            );
685        }
686    }
687}