From 7c77ad32b8cccbcd295ddc45ea0ee09316c7cbf5 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 13 Jul 2026 22:29:30 +0200 Subject: [PATCH 01/30] feat(precompiles): l1-backed storage provider --- crates/l1/src/state/provider.rs | 11 + crates/precompiles/src/aes_gcm/mod.rs | 34 +- crates/precompiles/src/lib.rs | 7 +- crates/precompiles/src/storage.rs | 526 ++++++++++++++++++++++++++ crates/precompiles/src/tempo_state.rs | 92 +---- crates/precompiles/src/test_utils.rs | 125 ++++++ crates/precompiles/src/ztip20/mod.rs | 38 +- 7 files changed, 696 insertions(+), 137 deletions(-) create mode 100644 crates/precompiles/src/storage.rs diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index a01d0b092..a968c45e1 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -291,6 +291,17 @@ impl L1StorageReader for L1StateProvider { )) }) } + + fn hardfork_at( + &self, + block_number: u64, + ) -> std::result::Result { + self.get_hardfork(block_number).map_err(|e| { + zone_precompiles::zone_rpc_error(format!( + "L1 hardfork unavailable for block={block_number}: {e}" + )) + }) + } } impl SequencerExt for L1StateProvider { diff --git a/crates/precompiles/src/aes_gcm/mod.rs b/crates/precompiles/src/aes_gcm/mod.rs index 1960972b7..41024494b 100644 --- a/crates/precompiles/src/aes_gcm/mod.rs +++ b/crates/precompiles/src/aes_gcm/mod.rs @@ -117,33 +117,16 @@ pub fn decrypt_aes_gcm( #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{test_context, test_storage_provider}; use alloy_evm::{ EvmInternals, precompiles::{Precompile, PrecompileInput}, }; use alloy_primitives::{Bytes, U256}; use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - precompile::PrecompileOutput, - }; + use revm::precompile::PrecompileOutput; use tempo_chainspec::hardfork::TempoHardfork; - use tempo_precompiles::{ - charge_input_cost, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - - fn test_context() -> TestContext { - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) - } + use tempo_precompiles::{charge_input_cost, storage::StorageCtx}; fn encrypt(plaintext: &[u8], aad: &[u8]) -> decryptCall { let key = [0x42u8; 32]; @@ -191,16 +174,7 @@ mod tests { fn charged_input_gas(calldata: &[u8]) -> u64 { let mut ctx = test_context(); - let cfg = revm::context::CfgEnv::::default(); - let mut provider = EvmPrecompileStorageProvider::new( - EvmInternals::from_context(&mut ctx), - u64::MAX, - 0, - cfg.spec, - cfg.enable_amsterdam_eip8037, - true, - cfg.gas_params, - ); + let mut provider = test_storage_provider(&mut ctx, u64::MAX, true); StorageCtx::enter(&mut provider, || { let mut storage = StorageCtx::default(); let gas_before = storage.gas_used(); diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 32427d908..f42fead97 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -22,9 +22,6 @@ extern crate alloc; -// Required by the `#[contract]` proc macro expansion (references `crate::storage`). -pub(crate) use tempo_precompiles::storage; - pub mod error; pub use error::{Result, ZonePrecompileError, ZoneResult}; @@ -41,6 +38,7 @@ pub mod dispatch { } pub mod policy; +pub mod storage; pub mod tempo_state; pub mod tip20_factory; pub mod tip403_proxy; @@ -48,7 +46,8 @@ pub mod ztip20; pub use aes_gcm::{AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt}; pub use chaum_pedersen::{CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify}; -pub use tempo_state::{L1StorageReader, TempoState}; +pub use storage::L1StorageReader; +pub use tempo_state::TempoState; pub use tip20_factory::{ZONE_TIP20_FACTORY_ADDRESS, ZoneTokenFactory}; pub use tip403_proxy::{ZONE_TIP403_PROXY_ADDRESS, ZoneTip403ProxyRegistry}; pub use ztip20::{SequencerExt, ZoneTip20Token}; diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs new file mode 100644 index 000000000..d5049cd61 --- /dev/null +++ b/crates/precompiles/src/storage.rs @@ -0,0 +1,526 @@ +//! Zone precompile storage provider backed by finalized Tempo L1 state. +//! +//! Ordinary operations use the zone's local EVM state. Selected policy reads are overlaid from +//! the Tempo L1 block recorded in `TempoState`. +//! +//! # Read behavior +//! +//! - TIP-403 registry slots return the corresponding L1 value. +//! - TIP-20 transfer-policy slots replace only the L1-owned policy-ID field, preserving the +//! remaining zone-local fields in the packed slot. +//! - All other slots return their zone-local value unchanged. +//! +//! Each mirrored read performs the local SLOAD first to preserve EVM warming, gas charging, and +//! storage-action accounting. Every L1 read during a precompile call uses the same block anchor. +//! +//! # Write behavior +//! +//! Persistent writes, increments, and decrements targeting mirrored state are rejected before +//! reaching the local EVM provider. Writes to all other slots delegate unchanged. + +use alloc::format; + +pub(crate) use tempo_precompiles::storage::*; + +use crate::tempo_state::slots as tempo_state_slots; +use alloy_primitives::{Address, B256, LogData, U256}; +use revm::{ + context::journaled_state::JournalCheckpoint, + precompile::PrecompileError, + state::{AccountInfo, Bytecode}, +}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS; +use tempo_precompiles::{ + error::{Result, TempoPrecompileError}, + storage::evm::EvmPrecompileStorageProvider, + tip20::tip20_slots, +}; +use tempo_primitives::{TempoAddressExt, TempoBlockEnv}; +use zone_primitives::constants::TEMPO_STATE_ADDRESS; + +/// L1 storage access needed by zone precompile storage overlays and `TempoState` reads. +pub trait L1StorageReader: Clone + Send + Sync + 'static { + /// Read `account[slot]` at `block_number` on Tempo L1. + fn read_l1_storage( + &self, + account: Address, + slot: B256, + block_number: u64, + ) -> core::result::Result; + + /// Resolve the Tempo hardfork active at `block_number` on L1. + fn hardfork_at( + &self, + block_number: u64, + ) -> core::result::Result; +} + +/// Precompile storage that overlays finalized Tempo L1 policy state onto zone-local EVM state. +/// +/// TIP-403 reads use L1 values, while TIP-20 policy reads replace only the policy-ID field. +/// Ordinary operations remain local, and persistent writes to mirrored state are rejected. +pub struct ZonePrecompileStorageProvider<'a, P> { + inner: EvmPrecompileStorageProvider<'a>, + l1_block_number: u64, + l1_spec: TempoHardfork, + l1: P, +} + +impl<'a, P: L1StorageReader> ZonePrecompileStorageProvider<'a, P> { + /// Wrap `inner` with an L1 reader bound to `l1_block_number` for this precompile call. + /// + /// The L1 hardfork is resolved from the same block here so callers cannot accidentally pair + /// storage from one anchor with execution rules from another. + pub fn new( + inner: EvmPrecompileStorageProvider<'a>, + l1: P, + l1_block_number: u64, + ) -> Result { + let l1_spec = l1 + .hardfork_at(l1_block_number) + .map_err(fatal_reader_error)?; + Ok(Self { + inner, + l1, + l1_block_number, + l1_spec, + }) + } +} + +/// Read the finalized Tempo/L1 block number once before constructing the zone provider. +pub fn read_l1_anchor(inner: &mut EvmPrecompileStorageProvider<'_>) -> Result { + let value = inner.sload(TEMPO_STATE_ADDRESS, tempo_state_slots::TEMPO_BLOCK_NUMBER)?; + value.try_into().map_err(|_| { + TempoPrecompileError::Fatal(format!( + "invalid Tempo L1 block anchor (does not fit in u64): {value}" + )) + }) +} + +impl ZonePrecompileStorageProvider<'_, P> { + fn read_l1_slot(&self, address: Address, key: U256) -> Result { + let block_number = self.l1_block_number; + self.l1 + .read_l1_storage(address, key.into(), block_number) + .map(|value| value.into()) + .map_err(|err| trace_err(err, address, key, block_number)) + } +} + +impl PrecompileStorageProvider for ZonePrecompileStorageProvider<'_, P> { + fn chain_id(&self) -> u64 { + self.inner.chain_id() + } + + fn block_env(&self) -> &TempoBlockEnv { + self.inner.block_env() + } + + fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> { + self.inner.set_code(address, code) + } + + fn with_account_info( + &mut self, + address: Address, + f: &mut dyn FnMut(&AccountInfo), + ) -> Result<()> { + self.inner.with_account_info(address, f) + } + + fn sload(&mut self, address: Address, key: U256) -> Result { + // Run the local SLOAD first to preserve EVM warm/cold state, gas charging, and storage-action + // recording; mirrored L1 state overrides only the value observed by TIP-20/TIP-403 logic. + let local = self.inner.sload(address, key)?; + if address == TIP403_REGISTRY_ADDRESS { + return self.read_l1_slot(address, key); + } + if is_tip20_policy_id_slot(address, key) { + let l1 = self.read_l1_slot(address, key)?; + return Ok(merge_transfer_policy_id(local, l1)); + } + Ok(local) + } + + fn tload(&mut self, address: Address, key: U256) -> Result { + self.inner.tload(address, key) + } + + fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { + if is_l1_slot(address, key) { + return Err(l1_write_err(address, key)); + } + self.inner.sstore(address, key, value) + } + + fn sinc(&mut self, address: Address, key: U256, delta: U256) -> Result<()> { + if is_l1_slot(address, key) { + return Err(l1_write_err(address, key)); + } + self.inner.sinc(address, key, delta) + } + + fn sdec(&mut self, address: Address, key: U256, delta: U256) -> Result<()> { + if is_l1_slot(address, key) { + return Err(l1_write_err(address, key)); + } + self.inner.sdec(address, key, delta) + } + + fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { + self.inner.tstore(address, key, value) + } + + fn emit_event(&mut self, address: Address, event: LogData) -> Result<()> { + self.inner.emit_event(address, event) + } + + fn deduct_gas(&mut self, gas: u64) -> Result<()> { + self.inner.deduct_gas(gas) + } + + fn refund_gas(&mut self, gas: i64) { + self.inner.refund_gas(gas) + } + + fn gas_limit(&self) -> u64 { + self.inner.gas_limit() + } + + fn gas_used(&self) -> u64 { + self.inner.gas_used() + } + + fn state_gas_used(&self) -> u64 { + self.inner.state_gas_used() + } + + fn gas_refunded(&self) -> i64 { + self.inner.gas_refunded() + } + + fn reservoir(&self) -> u64 { + self.inner.reservoir() + } + + fn spec(&self) -> TempoHardfork { + self.l1_spec + } + + fn storage_actions(&self) -> StorageActions { + self.inner.storage_actions() + } + + fn amsterdam_eip8037_enabled(&self) -> bool { + self.inner.amsterdam_eip8037_enabled() + } + + fn is_static(&self) -> bool { + self.inner.is_static() + } + + fn checkpoint(&mut self) -> JournalCheckpoint { + self.inner.checkpoint() + } + + fn checkpoint_commit(&mut self, checkpoint: JournalCheckpoint) { + self.inner.checkpoint_commit(checkpoint) + } + + fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint) { + self.inner.checkpoint_revert(checkpoint) + } + + fn set_tip1060_storage_credits(&mut self, enabled: bool) { + self.inner.set_tip1060_storage_credits(enabled) + } + + fn set_tip1060_storage_credit_minting(&mut self, enabled: bool) { + self.inner.set_tip1060_storage_credit_minting(enabled) + } +} + +// TODO(rusowsky): Remove TIP20 policy-slot detection, write protection, merge logic, +// and related tests once Tempo L1 migrates transfer policy IDs into TIP403. +fn is_l1_slot(address: Address, key: U256) -> bool { + address == TIP403_REGISTRY_ADDRESS || is_tip20_policy_id_slot(address, key) +} + +fn is_tip20_policy_id_slot(address: Address, key: U256) -> bool { + address.is_tip20() && key == tip20_slots::TRANSFER_POLICY_ID +} + +fn l1_write_err(address: Address, key: U256) -> TempoPrecompileError { + TempoPrecompileError::Fatal(format!( + "attempted to write L1-backed storage slot address={address} key={key}" + )) +} + +pub(super) fn trace_err( + err: PrecompileError, + address: Address, + key: U256, + block_number: u64, +) -> TempoPrecompileError { + TempoPrecompileError::Fatal(format!( + "Tempo L1 storage read failed address={address} key={key} block={block_number}: {}", + precompile_error_message(err) + )) +} + +fn fatal_reader_error(err: PrecompileError) -> TempoPrecompileError { + TempoPrecompileError::Fatal(precompile_error_message(err)) +} + +fn precompile_error_message(err: PrecompileError) -> alloc::string::String { + match err { + PrecompileError::Fatal(msg) => msg, + other => format!("{other:?}"), + } +} + +// TODO(rusowsky): Remove once Tempo L1 stores transfer policy IDs in the TIP403 precompile. +fn merge_transfer_policy_id(local_slot: U256, l1_slot: U256) -> U256 { + let offset_bits = tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8; + let field_bits = core::mem::size_of::() * 8; + let field_mask = ((U256::ONE << field_bits) - U256::ONE) << offset_bits; + (local_slot & !field_mask) | (l1_slot & field_mask) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{MockL1Reader, TestCtx, test_context, test_storage_provider}; + use tempo_precompiles::{ + PATH_USD_ADDRESS, + storage::{StorageAction, actions::StorageActions}, + }; + + fn with_zone_provider( + ctx: &mut TestCtx, + l1: MockL1Reader, + actions: StorageActions, + f: impl FnOnce(&mut ZonePrecompileStorageProvider<'_, MockL1Reader>) -> T, + ) -> T { + let mut inner = test_storage_provider(ctx, u64::MAX, false).with_actions(actions); + inner + .sstore( + TEMPO_STATE_ADDRESS, + tempo_state_slots::TEMPO_BLOCK_NUMBER, + U256::from(123u64), + ) + .expect("anchor write succeeds"); + let l1_block_number = read_l1_anchor(&mut inner).expect("anchor read succeeds"); + let mut provider = ZonePrecompileStorageProvider::new(inner, l1, l1_block_number) + .expect("hardfork resolution succeeds"); + f(&mut provider) + } + + #[test] + fn provider_resolves_hardfork_at_storage_anchor_and_fails_closed() { + let mut ctx = test_context(); + let l1 = MockL1Reader::default(); + let provider = ZonePrecompileStorageProvider::new( + test_storage_provider(&mut ctx, u64::MAX, false), + l1.clone(), + 77, + ) + .expect("anchored hardfork resolves"); + assert_eq!(provider.spec(), TempoHardfork::T8); + assert_eq!(l1.hardfork_requests(), vec![77]); + drop(provider); + + let result = ZonePrecompileStorageProvider::new( + test_storage_provider(&mut ctx, u64::MAX, false), + MockL1Reader::failing_hardfork(), + 77, + ); + let err = match result { + Err(err) => err, + Ok(_) => panic!("missing anchored hardfork must fail closed"), + }; + assert!(matches!( + err, + TempoPrecompileError::Fatal(message) if message.contains("hardfork unavailable") + )); + } + + #[test] + fn read_l1_anchor_rejects_values_larger_than_u64() { + let mut ctx = test_context(); + let mut inner = test_storage_provider(&mut ctx, u64::MAX, false); + let oversized = U256::from(u64::MAX) + U256::ONE; + inner + .sstore( + TEMPO_STATE_ADDRESS, + tempo_state_slots::TEMPO_BLOCK_NUMBER, + oversized, + ) + .expect("anchor write succeeds"); + + let err = read_l1_anchor(&mut inner).expect_err("oversized anchor must be rejected"); + assert!( + matches!(err, TempoPrecompileError::Fatal(ref msg) if msg.contains("does not fit in u64") && msg.contains(&oversized.to_string())) + ); + } + + #[test] + fn l1_read_failure_includes_storage_context() { + let mut ctx = test_context(); + with_zone_provider( + &mut ctx, + MockL1Reader::failing_storage(), + StorageActions::disabled(), + |provider| { + let err = provider + .sload(TIP403_REGISTRY_ADDRESS, U256::ONE) + .expect_err("L1 failures must fail closed"); + assert!(matches!( + err, + TempoPrecompileError::Fatal(msg) + if msg.contains("RPC unavailable") + && msg.contains(&TIP403_REGISTRY_ADDRESS.to_string()) + && msg.contains("block=123") + )); + }, + ); + } + + #[test] + fn sstore_sinc_sdec_reject_l1_slots() { + let mut ctx = test_context(); + with_zone_provider( + &mut ctx, + MockL1Reader::default(), + StorageActions::disabled(), + |provider| { + let write_actions = [ + ZonePrecompileStorageProvider::sstore, + ZonePrecompileStorageProvider::sinc, + ZonePrecompileStorageProvider::sdec, + ]; + let l1_slots = [ + (PATH_USD_ADDRESS, tip20_slots::TRANSFER_POLICY_ID), + (TIP403_REGISTRY_ADDRESS, U256::ZERO), + ]; + + for action in write_actions { + for (address, key) in l1_slots { + assert!(action(provider, address, key, U256::ONE).is_err()); + } + } + + let local = (Address::with_last_byte(0x99), U256::from(88)); + for action in write_actions { + assert!(action(provider, local.0, local.1, U256::ONE).is_ok()) + } + }, + ); + } + + #[test] + fn sload_routes_registry_and_only_tip20_policy_field_to_l1() { + let mut ctx = test_context(); + let l1 = MockL1Reader::default(); + let offset_bits = tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8; + let local_low_bits = U256::from(0xdead_u64); + let local_policy = U256::from(1u64) << offset_bits; + let l1_policy = U256::from(99u64) << offset_bits; + l1.set_u256( + PATH_USD_ADDRESS, + tip20_slots::TRANSFER_POLICY_ID, + 123, + l1_policy, + ); + l1.set_u256(TIP403_REGISTRY_ADDRESS, U256::from(7), 123, U256::from(8)); + + with_zone_provider( + &mut ctx, + l1.clone(), + StorageActions::disabled(), + |provider| { + provider + .inner + .sstore( + PATH_USD_ADDRESS, + tip20_slots::TRANSFER_POLICY_ID, + local_low_bits | local_policy, + ) + .unwrap(); + let overlaid = provider + .sload(PATH_USD_ADDRESS, tip20_slots::TRANSFER_POLICY_ID) + .unwrap(); + assert_eq!(overlaid & U256::from(0xffff_u64), local_low_bits); + assert_eq!((overlaid >> offset_bits).to::(), 99); + assert_eq!( + provider + .sload(TIP403_REGISTRY_ADDRESS, U256::from(7)) + .unwrap(), + U256::from(8) + ); + + // The same packed slot at an ordinary address, and other slots at a TIP-20 + // address, remain entirely zone-local. + let local_address = Address::with_last_byte(0x55); + provider + .sstore( + local_address, + tip20_slots::TRANSFER_POLICY_ID, + U256::from(6), + ) + .unwrap(); + assert_eq!( + provider + .sload(local_address, tip20_slots::TRANSFER_POLICY_ID) + .unwrap(), + U256::from(6) + ); + let adjacent_tip20_slot = tip20_slots::TRANSFER_POLICY_ID + U256::ONE; + provider + .sstore(PATH_USD_ADDRESS, adjacent_tip20_slot, U256::from(7)) + .unwrap(); + assert_eq!( + provider + .sload(PATH_USD_ADDRESS, adjacent_tip20_slot) + .unwrap(), + U256::from(7) + ); + }, + ); + assert!(l1.storage_requests().iter().all(|request| request.2 == 123)); + assert_eq!(l1.storage_requests().len(), 2); + assert_eq!(l1.hardfork_requests(), vec![123]); + } + + #[test] + fn mirrored_reads_preserve_warming_gas_and_storage_actions() { + let mut ctx = test_context(); + let l1 = MockL1Reader::default(); + let actions = StorageActions::enabled(); + l1.set_u256(TIP403_REGISTRY_ADDRESS, U256::ONE, 123, U256::from(5)); + + with_zone_provider(&mut ctx, l1, actions.clone(), |provider| { + let gas_before = provider.gas_used(); + assert_eq!( + provider.sload(TIP403_REGISTRY_ADDRESS, U256::ONE).unwrap(), + U256::from(5) + ); + let cold_cost = provider.gas_used() - gas_before; + let gas_before_warm = provider.gas_used(); + provider.sload(TIP403_REGISTRY_ADDRESS, U256::ONE).unwrap(); + let warm_cost = provider.gas_used() - gas_before_warm; + assert!( + cold_cost > warm_cost, + "first local SLOAD must warm the mirrored slot" + ); + }); + + let recorded = actions.take().unwrap(); + assert!(recorded.ends_with(&[ + StorageAction::Sload(TIP403_REGISTRY_ADDRESS, U256::ONE, U256::ZERO), + StorageAction::Sload(TIP403_REGISTRY_ADDRESS, U256::ONE, U256::ZERO), + ])); + } +} diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 555cf2ba5..3216ac39d 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -3,14 +3,15 @@ //! Replaces the Solidity TempoState predeploy at `0x1c00...0000` while //! preserving the zone-facing checkpoint and Tempo storage read ABI. -use alloc::vec::Vec; +use alloc::{format, vec::Vec}; +use crate::storage::L1StorageReader; use alloy_consensus::BlockHeader; use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_rlp::Decodable as _; use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileError, PrecompileId, PrecompileOutput, PrecompileResult}; +use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult}; use tempo_precompiles::{ DelegateCallNotAllowed, charge_input_cost, dispatch, error::TempoPrecompileError, @@ -29,17 +30,6 @@ alloy_sol_types::sol! { error StaticCallNotAllowed(); } -/// L1 storage access needed by `readTempoStorageSlot(s)`. -pub trait L1StorageReader: Clone + Send + Sync + 'static { - /// Read `account[slot]` at `block_number` on Tempo L1. - fn read_l1_storage( - &self, - account: Address, - slot: B256, - block_number: u64, - ) -> Result; -} - #[contract(addr = TEMPO_STATE_ADDRESS)] pub struct TempoState { tempo_block_hash: B256, @@ -255,6 +245,7 @@ impl TempoState { mod tests { use super::*; + use crate::test_utils::{MockL1Reader, TestCtx, test_context, test_storage_provider}; use alloy_evm::{ EvmInternals, precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, @@ -262,66 +253,23 @@ mod tests { use alloy_primitives::{U256, address, b256}; use alloy_rlp::Encodable as _; use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - }; - use tempo_chainspec::hardfork::TempoHardfork; - - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; type TestResult = Result>; - #[derive(Clone)] - struct MockL1Reader { - value: B256, - } - - impl L1StorageReader for MockL1Reader { - fn read_l1_storage( - &self, - _account: Address, - _slot: B256, - _block_number: u64, - ) -> Result { - Ok(self.value) - } - } - fn encode_header(header: &TempoHeader) -> Bytes { let mut encoded = Vec::new(); header.encode(&mut encoded); encoded.into() } - fn test_context() -> TestContext { - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) - } - - fn initialize(ctx: &mut TestContext, header: &[u8]) -> TestResult { - let spec = ctx.cfg.spec; - let amsterdam_eip8037_enabled = ctx.cfg.enable_amsterdam_eip8037; - let gas_params = ctx.cfg.gas_params.clone(); - let mut storage = EvmPrecompileStorageProvider::new( - EvmInternals::from_context(ctx), - u64::MAX, - 0, - spec, - amsterdam_eip8037_enabled, - false, - gas_params, - ); + fn initialize(ctx: &mut TestCtx, header: &[u8]) -> TestResult { + let mut storage = test_storage_provider(ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || TempoState::new().initialize(header))?; Ok(()) } fn call( - ctx: &mut TestContext, + ctx: &mut TestCtx, precompile: &DynPrecompile, caller: Address, calldata: Bytes, @@ -338,7 +286,7 @@ mod tests { } fn call_with_bytecode_address( - ctx: &mut TestContext, + ctx: &mut TestCtx, precompile: &DynPrecompile, caller: Address, calldata: Bytes, @@ -398,7 +346,7 @@ mod tests { } fn assert_checkpoint( - ctx: &mut TestContext, + ctx: &mut TestCtx, precompile: &DynPrecompile, expected_hash: B256, expected_number: u64, @@ -436,7 +384,7 @@ mod tests { let mut ctx = test_context(); initialize(&mut ctx, &header_rlp)?; - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); assert_checkpoint(&mut ctx, &precompile, keccak256(&header_rlp), 42)?; Ok(()) @@ -453,7 +401,7 @@ mod tests { let child = child_header(genesis_hash, 1); let child_rlp = encode_header(&child); let child_hash = keccak256(&child_rlp); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, @@ -477,7 +425,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, @@ -498,7 +446,7 @@ mod tests { let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call_with_bytecode_address( &mut ctx, &precompile, @@ -522,7 +470,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, @@ -545,7 +493,7 @@ mod tests { let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, @@ -571,7 +519,7 @@ mod tests { let child_rlp = encode_header(&child_header(genesis_hash, 1)); let mut malformed = child_rlp.to_vec(); malformed.push(0); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, @@ -595,7 +543,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(B256::ZERO, 1)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, @@ -619,7 +567,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(genesis_hash, 2)); - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, @@ -641,7 +589,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let expected = b256!("0xabababababababababababababababababababababababababababababababab"); - let precompile = TempoState::create(MockL1Reader { value: expected }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::returning(expected), &ctx.cfg.clone()); let calldata: Bytes = TempoStateAbi::readTempoStorageSlotCall { account: address!("0x0000000000000000000000000000000000009999"), slot: B256::ZERO, @@ -674,7 +622,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let expected = b256!("0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"); - let precompile = TempoState::create(MockL1Reader { value: expected }, &ctx.cfg.clone()); + let precompile = TempoState::create(MockL1Reader::returning(expected), &ctx.cfg.clone()); let output = call( &mut ctx, &precompile, diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index fcfd11cad..aba82ffc6 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -1,18 +1,143 @@ //! Shared test utilities for precompile tests. +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use alloy_evm::EvmInternals; use alloy_primitives::{Address, B256, U256}; use k256::{ AffinePoint, ProjectivePoint, Scalar, elliptic_curve::{ops::Reduce, sec1::ToEncodedPoint}, }; +use revm::{ + Context, + context::{BlockEnv, CfgEnv, TxEnv}, + database::{CacheDB, EmptyDB}, + precompile::PrecompileError, +}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::storage::evm::EvmPrecompileStorageProvider; use crate::{ + L1StorageReader, chaum_pedersen::{challenge_hash, recover_point}, ecies::DecryptedDeposit, }; pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; +/// EVM context used by precompile tests. +pub(crate) type TestCtx = Context, CacheDB>; +type L1Slot = (Address, B256, u64); +type Shared = Arc>; + +/// Create an empty test EVM context at the default Tempo hardfork. +pub(crate) fn test_context() -> TestCtx { + Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) +} + +/// Create an EVM-backed precompile storage provider over `ctx`. +pub(crate) fn test_storage_provider( + ctx: &mut TestCtx, + gas_limit: u64, + is_static: bool, +) -> EvmPrecompileStorageProvider<'_> { + let cfg = ctx.cfg.clone(); + EvmPrecompileStorageProvider::new( + EvmInternals::from_context(ctx), + gas_limit, + 0, + cfg.spec, + cfg.enable_amsterdam_eip8037, + is_static, + cfg.gas_params, + ) +} + +/// In-memory exact-block L1 reader shared by precompile tests. +#[derive(Clone, Default)] +pub(crate) struct MockL1Reader { + slots: Shared>, + storage_requests: Shared>, + hardfork_requests: Shared>, + fallback: B256, + fail_storage: bool, + fail_hardfork: bool, +} + +impl MockL1Reader { + pub(crate) fn returning(value: B256) -> Self { + Self { + fallback: value, + ..Default::default() + } + } + + pub(crate) fn failing_storage() -> Self { + Self { + fail_storage: true, + ..Default::default() + } + } + + pub(crate) fn failing_hardfork() -> Self { + Self { + fail_hardfork: true, + ..Default::default() + } + } + + pub(crate) fn set_u256(&self, address: Address, slot: U256, block: u64, value: U256) { + self.slots.lock().unwrap().insert( + (address, B256::from(slot.to_be_bytes()), block), + B256::from(value.to_be_bytes()), + ); + } + + pub(crate) fn storage_requests(&self) -> Vec { + self.storage_requests.lock().unwrap().clone() + } + + pub(crate) fn hardfork_requests(&self) -> Vec { + self.hardfork_requests.lock().unwrap().clone() + } +} + +impl L1StorageReader for MockL1Reader { + fn read_l1_storage( + &self, + account: Address, + slot: B256, + block_number: u64, + ) -> Result { + self.storage_requests + .lock() + .unwrap() + .push((account, slot, block_number)); + if self.fail_storage { + return Err(PrecompileError::Fatal("RPC unavailable".into())); + } + Ok(self + .slots + .lock() + .unwrap() + .get(&(account, slot, block_number)) + .copied() + .unwrap_or(self.fallback)) + } + + fn hardfork_at(&self, block_number: u64) -> Result { + self.hardfork_requests.lock().unwrap().push(block_number); + if self.fail_hardfork { + Err(PrecompileError::Fatal("hardfork unavailable".into())) + } else { + Ok(TempoHardfork::T8) + } + } +} + /// Assert that the Chaum-Pedersen proof inside a [`DecryptedDeposit`] is valid. pub(crate) fn assert_cp_proof_valid( dec: &DecryptedDeposit, diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index b64901a27..2cc073462 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -233,18 +233,14 @@ impl ZoneTip20Token

{ #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{TestCtx, test_context, test_storage_provider}; use alloy::primitives::{Address, Bytes, U256, address}; use alloy_evm::{ EvmInternals, precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, }; use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - precompile::{PrecompileHalt, PrecompileResult}, - }; - use tempo_chainspec::hardfork::TempoHardfork; + use revm::precompile::{PrecompileHalt, PrecompileResult}; use tempo_precompiles::{ PATH_USD_ADDRESS, storage::evm::EvmPrecompileStorageProvider, @@ -252,12 +248,6 @@ mod tests { }; type TestResult = Result>; - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; #[derive(Clone, Default)] struct MockPolicyProvider { @@ -342,7 +332,7 @@ mod tests { } struct PrecompileHarness { - ctx: TestContext, + ctx: TestCtx, token: Address, alice: Address, bob: Address, @@ -369,7 +359,7 @@ mod tests { let spender = address!("0x00000000000000000000000000000000000000a4"); let issuer = address!("0x00000000000000000000000000000000000000a5"); let sequencer = address!("0x00000000000000000000000000000000000000a6"); - let mut ctx = Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); + let mut ctx = test_context(); Self::with_storage(&mut ctx, u64::MAX, |storage| { StorageCtx::enter(storage, || -> TestResult { @@ -434,24 +424,11 @@ mod tests { } fn with_storage( - ctx: &mut TestContext, + ctx: &mut TestCtx, gas_limit: u64, f: impl FnOnce(&mut EvmPrecompileStorageProvider<'_>) -> TestResult, ) -> TestResult { - let spec = ctx.cfg.spec; - let amsterdam_eip8037_enabled = ctx.cfg.enable_amsterdam_eip8037; - let gas_params = ctx.cfg.gas_params.clone(); - let internals = EvmInternals::from_context(ctx); - let mut storage = EvmPrecompileStorageProvider::new( - internals, - gas_limit, - 0, - spec, - amsterdam_eip8037_enabled, - false, - gas_params, - ); - f(&mut storage) + f(&mut test_storage_provider(ctx, gas_limit, false)) } fn call( @@ -602,8 +579,7 @@ mod tests { let token = address!("20C0000000000000000000000000000000000999"); let caller = address!("0x00000000000000000000000000000000000000a2"); let to = address!("0x00000000000000000000000000000000000000a3"); - let mut ctx: TestContext = - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); + let mut ctx = test_context(); let precompile = ZoneTip20Token::create( token, &ctx.cfg, From 329b18582c3981a5aaeadfd4898714f191c4c907 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 13 Jul 2026 22:41:34 +0200 Subject: [PATCH 02/30] style: minimize diff --- crates/precompiles/src/storage.rs | 4 +- crates/precompiles/src/tempo_state.rs | 10 +-- crates/precompiles/src/test_utils.rs | 6 +- crates/precompiles/src/ztip20/mod.rs | 107 ++++++++++++-------------- 4 files changed, 58 insertions(+), 69 deletions(-) diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index d5049cd61..e4a4ea58b 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -292,14 +292,14 @@ fn merge_transfer_policy_id(local_slot: U256, l1_slot: U256) -> U256 { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{MockL1Reader, TestCtx, test_context, test_storage_provider}; + use crate::test_utils::{MockL1Reader, TestContext, test_context, test_storage_provider}; use tempo_precompiles::{ PATH_USD_ADDRESS, storage::{StorageAction, actions::StorageActions}, }; fn with_zone_provider( - ctx: &mut TestCtx, + ctx: &mut TestContext, l1: MockL1Reader, actions: StorageActions, f: impl FnOnce(&mut ZonePrecompileStorageProvider<'_, MockL1Reader>) -> T, diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 3216ac39d..70f4c022d 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -245,7 +245,7 @@ impl TempoState { mod tests { use super::*; - use crate::test_utils::{MockL1Reader, TestCtx, test_context, test_storage_provider}; + use crate::test_utils::{MockL1Reader, TestContext, test_context, test_storage_provider}; use alloy_evm::{ EvmInternals, precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, @@ -261,7 +261,7 @@ mod tests { encoded.into() } - fn initialize(ctx: &mut TestCtx, header: &[u8]) -> TestResult { + fn initialize(ctx: &mut TestContext, header: &[u8]) -> TestResult { let mut storage = test_storage_provider(ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || TempoState::new().initialize(header))?; @@ -269,7 +269,7 @@ mod tests { } fn call( - ctx: &mut TestCtx, + ctx: &mut TestContext, precompile: &DynPrecompile, caller: Address, calldata: Bytes, @@ -286,7 +286,7 @@ mod tests { } fn call_with_bytecode_address( - ctx: &mut TestCtx, + ctx: &mut TestContext, precompile: &DynPrecompile, caller: Address, calldata: Bytes, @@ -346,7 +346,7 @@ mod tests { } fn assert_checkpoint( - ctx: &mut TestCtx, + ctx: &mut TestContext, precompile: &DynPrecompile, expected_hash: B256, expected_number: u64, diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index aba82ffc6..64bed4656 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -29,18 +29,18 @@ use crate::{ pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; /// EVM context used by precompile tests. -pub(crate) type TestCtx = Context, CacheDB>; +pub(crate) type TestContext = Context, CacheDB>; type L1Slot = (Address, B256, u64); type Shared = Arc>; /// Create an empty test EVM context at the default Tempo hardfork. -pub(crate) fn test_context() -> TestCtx { +pub(crate) fn test_context() -> TestContext { Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()) } /// Create an EVM-backed precompile storage provider over `ctx`. pub(crate) fn test_storage_provider( - ctx: &mut TestCtx, + ctx: &mut TestContext, gas_limit: u64, is_static: bool, ) -> EvmPrecompileStorageProvider<'_> { diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 2cc073462..9ded246d3 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -233,7 +233,7 @@ impl ZoneTip20Token

{ #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{TestCtx, test_context, test_storage_provider}; + use crate::test_utils::{TestContext, test_context, test_storage_provider}; use alloy::primitives::{Address, Bytes, U256, address}; use alloy_evm::{ EvmInternals, @@ -332,7 +332,7 @@ mod tests { } struct PrecompileHarness { - ctx: TestCtx, + ctx: TestContext, token: Address, alice: Address, bob: Address, @@ -361,45 +361,44 @@ mod tests { let sequencer = address!("0x00000000000000000000000000000000000000a6"); let mut ctx = test_context(); - Self::with_storage(&mut ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || -> TestResult { - let mut token_contract = - TIP20Token::from_address(token).expect("PATH_USD must be valid"); - token_contract.initialize( - admin, - "Zone USD", - "zUSD", - "USD", - Address::ZERO, - admin, - )?; - token_contract.grant_role_internal(admin, *ISSUER_ROLE)?; - token_contract.grant_role_internal(issuer, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_INBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: alice, - amount: U256::from(1_000_000u64), - }, - )?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: ZONE_OUTBOX_ADDRESS, - amount: U256::from(10_000u64), - }, - )?; - token_contract.approve( - alice, - ITIP20::approveCall { - spender, - amount: U256::from(300_000u64), - }, - )?; - Ok(()) - }) + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || -> TestResult { + let mut token_contract = + TIP20Token::from_address(token).expect("PATH_USD must be valid"); + token_contract.initialize( + admin, + "Zone USD", + "zUSD", + "USD", + Address::ZERO, + admin, + )?; + token_contract.grant_role_internal(admin, *ISSUER_ROLE)?; + token_contract.grant_role_internal(issuer, *ISSUER_ROLE)?; + token_contract.grant_role_internal(ZONE_INBOX_ADDRESS, *ISSUER_ROLE)?; + token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; + token_contract.mint( + admin, + ITIP20::mintCall { + to: alice, + amount: U256::from(1_000_000u64), + }, + )?; + token_contract.mint( + admin, + ITIP20::mintCall { + to: ZONE_OUTBOX_ADDRESS, + amount: U256::from(10_000u64), + }, + )?; + token_contract.approve( + alice, + ITIP20::approveCall { + spender, + amount: U256::from(300_000u64), + }, + )?; + Ok(()) })?; let precompile = ZoneTip20Token::create( @@ -423,14 +422,6 @@ mod tests { }) } - fn with_storage( - ctx: &mut TestCtx, - gas_limit: u64, - f: impl FnOnce(&mut EvmPrecompileStorageProvider<'_>) -> TestResult, - ) -> TestResult { - f(&mut test_storage_provider(ctx, gas_limit, false)) - } - fn call( &mut self, caller: Address, @@ -455,20 +446,18 @@ mod tests { } fn balance_of(&mut self, account: Address) -> TestResult { - Self::with_storage(&mut self.ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || { - let token = TIP20Token::from_address(self.token).expect("token must exist"); - Ok(token.balance_of(ITIP20::balanceOfCall { account })?) - }) + let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || { + let token = TIP20Token::from_address(self.token).expect("token must exist"); + Ok(token.balance_of(ITIP20::balanceOfCall { account })?) }) } fn allowance(&mut self, owner: Address, spender: Address) -> TestResult { - Self::with_storage(&mut self.ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || { - let token = TIP20Token::from_address(self.token).expect("token must exist"); - Ok(token.allowance(ITIP20::allowanceCall { owner, spender })?) - }) + let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || { + let token = TIP20Token::from_address(self.token).expect("token must exist"); + Ok(token.allowance(ITIP20::allowanceCall { owner, spender })?) }) } } From a78687772f16f5e706d31bda8d2c515e987dddac Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 13 Jul 2026 22:55:53 +0200 Subject: [PATCH 03/30] fix: clippy --- crates/precompiles/src/ztip20/mod.rs | 81 ++++++++++++++-------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 9ded246d3..83c5503be 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -243,7 +243,6 @@ mod tests { use revm::precompile::{PrecompileHalt, PrecompileResult}; use tempo_precompiles::{ PATH_USD_ADDRESS, - storage::evm::EvmPrecompileStorageProvider, tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, TIP20Token}, }; @@ -361,45 +360,47 @@ mod tests { let sequencer = address!("0x00000000000000000000000000000000000000a6"); let mut ctx = test_context(); - let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); - StorageCtx::enter(&mut storage, || -> TestResult { - let mut token_contract = - TIP20Token::from_address(token).expect("PATH_USD must be valid"); - token_contract.initialize( - admin, - "Zone USD", - "zUSD", - "USD", - Address::ZERO, - admin, - )?; - token_contract.grant_role_internal(admin, *ISSUER_ROLE)?; - token_contract.grant_role_internal(issuer, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_INBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: alice, - amount: U256::from(1_000_000u64), - }, - )?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: ZONE_OUTBOX_ADDRESS, - amount: U256::from(10_000u64), - }, - )?; - token_contract.approve( - alice, - ITIP20::approveCall { - spender, - amount: U256::from(300_000u64), - }, - )?; - Ok(()) - })?; + { + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || -> TestResult { + let mut token_contract = + TIP20Token::from_address(token).expect("PATH_USD must be valid"); + token_contract.initialize( + admin, + "Zone USD", + "zUSD", + "USD", + Address::ZERO, + admin, + )?; + token_contract.grant_role_internal(admin, *ISSUER_ROLE)?; + token_contract.grant_role_internal(issuer, *ISSUER_ROLE)?; + token_contract.grant_role_internal(ZONE_INBOX_ADDRESS, *ISSUER_ROLE)?; + token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; + token_contract.mint( + admin, + ITIP20::mintCall { + to: alice, + amount: U256::from(1_000_000u64), + }, + )?; + token_contract.mint( + admin, + ITIP20::mintCall { + to: ZONE_OUTBOX_ADDRESS, + amount: U256::from(10_000u64), + }, + )?; + token_contract.approve( + alice, + ITIP20::approveCall { + spender, + amount: U256::from(300_000u64), + }, + )?; + Ok(()) + })?; + } let precompile = ZoneTip20Token::create( token, From f2478df8fd0dd3e80345666725a6ac2686b49b6a Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 13:17:05 +0200 Subject: [PATCH 04/30] fix: preserve revert and RPC error semantics --- crates/precompiles/src/storage.rs | 21 ++++++++++++--------- crates/precompiles/src/test_utils.rs | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index e4a4ea58b..a4230d605 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -30,11 +30,11 @@ use revm::{ state::{AccountInfo, Bytecode}, }; use tempo_chainspec::hardfork::TempoHardfork; -use tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS; +use tempo_contracts::precompiles::{TIP403_REGISTRY_ADDRESS, TIP403RegistryError}; use tempo_precompiles::{ error::{Result, TempoPrecompileError}, storage::evm::EvmPrecompileStorageProvider, - tip20::tip20_slots, + tip20::{TIP20Error, tip20_slots}, }; use tempo_primitives::{TempoAddressExt, TempoBlockEnv}; use zone_primitives::constants::TEMPO_STATE_ADDRESS; @@ -253,9 +253,11 @@ fn is_tip20_policy_id_slot(address: Address, key: U256) -> bool { } fn l1_write_err(address: Address, key: U256) -> TempoPrecompileError { - TempoPrecompileError::Fatal(format!( - "attempted to write L1-backed storage slot address={address} key={key}" - )) + if is_tip20_policy_id_slot(address, key) { + TIP20Error::invalid_transfer_policy_id().into() + } else { + TIP403RegistryError::unauthorized().into() + } } pub(super) fn trace_err( @@ -265,7 +267,7 @@ pub(super) fn trace_err( block_number: u64, ) -> TempoPrecompileError { TempoPrecompileError::Fatal(format!( - "Tempo L1 storage read failed address={address} key={key} block={block_number}: {}", + "{}; Tempo L1 storage read failed address={address} key={key} block={block_number}", precompile_error_message(err) )) } @@ -380,7 +382,7 @@ mod tests { assert!(matches!( err, TempoPrecompileError::Fatal(msg) - if msg.contains("RPC unavailable") + if crate::is_zone_rpc_error(&msg) && msg.contains(&TIP403_REGISTRY_ADDRESS.to_string()) && msg.contains("block=123") )); @@ -389,7 +391,7 @@ mod tests { } #[test] - fn sstore_sinc_sdec_reject_l1_slots() { + fn sstore_sinc_sdec_reject_l1_slots_with_precompile_reverts() { let mut ctx = test_context(); with_zone_provider( &mut ctx, @@ -408,7 +410,8 @@ mod tests { for action in write_actions { for (address, key) in l1_slots { - assert!(action(provider, address, key, U256::ONE).is_err()); + let err = action(provider, address, key, U256::ONE).unwrap_err(); + assert!(err.into_precompile_result(0, 0).unwrap().is_revert()); } } diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index 64bed4656..825068e53 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -117,7 +117,7 @@ impl L1StorageReader for MockL1Reader { .unwrap() .push((account, slot, block_number)); if self.fail_storage { - return Err(PrecompileError::Fatal("RPC unavailable".into())); + return Err(crate::zone_rpc_error("RPC unavailable")); } Ok(self .slots From 4937b113b09f8c8d133b1bea91d135d37eba1d37 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 13:25:46 +0200 Subject: [PATCH 05/30] fix: make TIP-20 packed slot writes field-aware --- crates/precompiles/src/storage.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index a4230d605..d8333e081 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -149,7 +149,10 @@ impl PrecompileStorageProvider for ZonePrecompileStorageProv } fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { - if is_l1_slot(address, key) { + if address == TIP403_REGISTRY_ADDRESS + || is_tip20_policy_id_slot(address, key) + && value != merge_transfer_policy_id(value, self.read_l1_slot(address, key)?) + { return Err(l1_write_err(address, key)); } self.inner.sstore(address, key, value) @@ -410,7 +413,8 @@ mod tests { for action in write_actions { for (address, key) in l1_slots { - let err = action(provider, address, key, U256::ONE).unwrap_err(); + let value = U256::ONE << (tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8); + let err = action(provider, address, key, value).unwrap_err(); assert!(err.into_precompile_result(0, 0).unwrap().is_revert()); } } @@ -457,6 +461,14 @@ mod tests { .unwrap(); assert_eq!(overlaid & U256::from(0xffff_u64), local_low_bits); assert_eq!((overlaid >> offset_bits).to::(), 99); + // Allow setNextQuoteToken's RMW when its policy bits match L1. + provider + .sstore( + PATH_USD_ADDRESS, + tip20_slots::TRANSFER_POLICY_ID, + U256::from(0xbeef_u64) | l1_policy, + ) + .unwrap(); assert_eq!( provider .sload(TIP403_REGISTRY_ADDRESS, U256::from(7)) @@ -493,7 +505,7 @@ mod tests { }, ); assert!(l1.storage_requests().iter().all(|request| request.2 == 123)); - assert_eq!(l1.storage_requests().len(), 2); + assert_eq!(l1.storage_requests().len(), 3); assert_eq!(l1.hardfork_requests(), vec![123]); } From 2b233bc3da705951fd6ec90b445cf0bc30bc8fcd Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 08:20:27 +0200 Subject: [PATCH 06/30] refactor(precompiles): use composed EVM hardfork --- crates/l1/src/state/provider.rs | 11 ----- crates/precompiles/src/storage.rs | 60 +++++----------------------- crates/precompiles/src/test_utils.rs | 22 ---------- 3 files changed, 10 insertions(+), 83 deletions(-) diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index a968c45e1..a01d0b092 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -291,17 +291,6 @@ impl L1StorageReader for L1StateProvider { )) }) } - - fn hardfork_at( - &self, - block_number: u64, - ) -> std::result::Result { - self.get_hardfork(block_number).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "L1 hardfork unavailable for block={block_number}: {e}" - )) - }) - } } impl SequencerExt for L1StateProvider { diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index d8333e081..95704b751 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -48,12 +48,6 @@ pub trait L1StorageReader: Clone + Send + Sync + 'static { slot: B256, block_number: u64, ) -> core::result::Result; - - /// Resolve the Tempo hardfork active at `block_number` on L1. - fn hardfork_at( - &self, - block_number: u64, - ) -> core::result::Result; } /// Precompile storage that overlays finalized Tempo L1 policy state onto zone-local EVM state. @@ -63,29 +57,17 @@ pub trait L1StorageReader: Clone + Send + Sync + 'static { pub struct ZonePrecompileStorageProvider<'a, P> { inner: EvmPrecompileStorageProvider<'a>, l1_block_number: u64, - l1_spec: TempoHardfork, l1: P, } impl<'a, P: L1StorageReader> ZonePrecompileStorageProvider<'a, P> { /// Wrap `inner` with an L1 reader bound to `l1_block_number` for this precompile call. - /// - /// The L1 hardfork is resolved from the same block here so callers cannot accidentally pair - /// storage from one anchor with execution rules from another. - pub fn new( - inner: EvmPrecompileStorageProvider<'a>, - l1: P, - l1_block_number: u64, - ) -> Result { - let l1_spec = l1 - .hardfork_at(l1_block_number) - .map_err(fatal_reader_error)?; - Ok(Self { + pub fn new(inner: EvmPrecompileStorageProvider<'a>, l1: P, l1_block_number: u64) -> Self { + Self { inner, l1, l1_block_number, - l1_spec, - }) + } } } @@ -209,7 +191,7 @@ impl PrecompileStorageProvider for ZonePrecompileStorageProv } fn spec(&self) -> TempoHardfork { - self.l1_spec + self.inner.spec() } fn storage_actions(&self) -> StorageActions { @@ -275,10 +257,6 @@ pub(super) fn trace_err( )) } -fn fatal_reader_error(err: PrecompileError) -> TempoPrecompileError { - TempoPrecompileError::Fatal(precompile_error_message(err)) -} - fn precompile_error_message(err: PrecompileError) -> alloc::string::String { match err { PrecompileError::Fatal(msg) => msg, @@ -318,38 +296,21 @@ mod tests { ) .expect("anchor write succeeds"); let l1_block_number = read_l1_anchor(&mut inner).expect("anchor read succeeds"); - let mut provider = ZonePrecompileStorageProvider::new(inner, l1, l1_block_number) - .expect("hardfork resolution succeeds"); + let mut provider = ZonePrecompileStorageProvider::new(inner, l1, l1_block_number); f(&mut provider) } #[test] - fn provider_resolves_hardfork_at_storage_anchor_and_fails_closed() { + fn provider_uses_composed_evm_hardfork() { let mut ctx = test_context(); - let l1 = MockL1Reader::default(); + ctx.cfg.spec = TempoHardfork::T8; let provider = ZonePrecompileStorageProvider::new( test_storage_provider(&mut ctx, u64::MAX, false), - l1.clone(), - 77, - ) - .expect("anchored hardfork resolves"); - assert_eq!(provider.spec(), TempoHardfork::T8); - assert_eq!(l1.hardfork_requests(), vec![77]); - drop(provider); - - let result = ZonePrecompileStorageProvider::new( - test_storage_provider(&mut ctx, u64::MAX, false), - MockL1Reader::failing_hardfork(), + MockL1Reader::default(), 77, ); - let err = match result { - Err(err) => err, - Ok(_) => panic!("missing anchored hardfork must fail closed"), - }; - assert!(matches!( - err, - TempoPrecompileError::Fatal(message) if message.contains("hardfork unavailable") - )); + + assert_eq!(provider.spec(), TempoHardfork::T8); } #[test] @@ -506,7 +467,6 @@ mod tests { ); assert!(l1.storage_requests().iter().all(|request| request.2 == 123)); assert_eq!(l1.storage_requests().len(), 3); - assert_eq!(l1.hardfork_requests(), vec![123]); } #[test] diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index 825068e53..00f5df2b5 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -61,10 +61,8 @@ pub(crate) fn test_storage_provider( pub(crate) struct MockL1Reader { slots: Shared>, storage_requests: Shared>, - hardfork_requests: Shared>, fallback: B256, fail_storage: bool, - fail_hardfork: bool, } impl MockL1Reader { @@ -82,13 +80,6 @@ impl MockL1Reader { } } - pub(crate) fn failing_hardfork() -> Self { - Self { - fail_hardfork: true, - ..Default::default() - } - } - pub(crate) fn set_u256(&self, address: Address, slot: U256, block: u64, value: U256) { self.slots.lock().unwrap().insert( (address, B256::from(slot.to_be_bytes()), block), @@ -99,10 +90,6 @@ impl MockL1Reader { pub(crate) fn storage_requests(&self) -> Vec { self.storage_requests.lock().unwrap().clone() } - - pub(crate) fn hardfork_requests(&self) -> Vec { - self.hardfork_requests.lock().unwrap().clone() - } } impl L1StorageReader for MockL1Reader { @@ -127,15 +114,6 @@ impl L1StorageReader for MockL1Reader { .copied() .unwrap_or(self.fallback)) } - - fn hardfork_at(&self, block_number: u64) -> Result { - self.hardfork_requests.lock().unwrap().push(block_number); - if self.fail_hardfork { - Err(PrecompileError::Fatal("hardfork unavailable".into())) - } else { - Ok(TempoHardfork::T8) - } - } } /// Assert that the Chaum-Pedersen proof inside a [`DecryptedDeposit`] is valid. From 137027965ad4c0b87fe08581d6dae0c90579512e Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 08:50:53 +0200 Subject: [PATCH 07/30] refactor(precompiles): encapsulate L1 anchor loading --- crates/precompiles/src/storage.rs | 61 ++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 95704b751..84e8b28dd 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -26,7 +26,7 @@ use crate::tempo_state::slots as tempo_state_slots; use alloy_primitives::{Address, B256, LogData, U256}; use revm::{ context::journaled_state::JournalCheckpoint, - precompile::PrecompileError, + precompile::{PrecompileError, PrecompileResult}, state::{AccountInfo, Bytecode}, }; use tempo_chainspec::hardfork::TempoHardfork; @@ -60,9 +60,43 @@ pub struct ZonePrecompileStorageProvider<'a, P> { l1: P, } +/// Failure to initialize L1-backed precompile storage at the finalized Tempo anchor. +#[derive(Debug)] +pub struct ZonePrecompileStorageProviderInitError { + pub(crate) error: TempoPrecompileError, + pub(crate) gas_used: u64, + pub(crate) reservoir: u64, +} + +impl ZonePrecompileStorageProviderInitError { + /// Convert the initialization failure using the gas accounting of the anchor read. + pub fn into_precompile_result(self) -> PrecompileResult { + self.error + .into_precompile_result(self.gas_used, self.reservoir) + } +} + impl<'a, P: L1StorageReader> ZonePrecompileStorageProvider<'a, P> { - /// Wrap `inner` with an L1 reader bound to `l1_block_number` for this precompile call. - pub fn new(inner: EvmPrecompileStorageProvider<'a>, l1: P, l1_block_number: u64) -> Self { + /// Read the finalized Tempo anchor from `inner` and construct an L1-backed provider. + pub fn try_new( + mut inner: EvmPrecompileStorageProvider<'a>, + l1: P, + ) -> core::result::Result { + let l1_block_number = + read_l1_anchor(&mut inner).map_err(|error| ZonePrecompileStorageProviderInitError { + error, + gas_used: inner.gas_used(), + reservoir: inner.reservoir(), + })?; + Ok(Self::new_at_block(inner, l1, l1_block_number)) + } + + /// Construct a provider at an explicitly supplied, previously validated Tempo block. + pub fn new_at_block( + inner: EvmPrecompileStorageProvider<'a>, + l1: P, + l1_block_number: u64, + ) -> Self { Self { inner, l1, @@ -72,7 +106,7 @@ impl<'a, P: L1StorageReader> ZonePrecompileStorageProvider<'a, P> { } /// Read the finalized Tempo/L1 block number once before constructing the zone provider. -pub fn read_l1_anchor(inner: &mut EvmPrecompileStorageProvider<'_>) -> Result { +fn read_l1_anchor(inner: &mut EvmPrecompileStorageProvider<'_>) -> Result { let value = inner.sload(TEMPO_STATE_ADDRESS, tempo_state_slots::TEMPO_BLOCK_NUMBER)?; value.try_into().map_err(|_| { TempoPrecompileError::Fatal(format!( @@ -295,8 +329,8 @@ mod tests { U256::from(123u64), ) .expect("anchor write succeeds"); - let l1_block_number = read_l1_anchor(&mut inner).expect("anchor read succeeds"); - let mut provider = ZonePrecompileStorageProvider::new(inner, l1, l1_block_number); + let mut provider = + ZonePrecompileStorageProvider::try_new(inner, l1).expect("anchor read succeeds"); f(&mut provider) } @@ -304,7 +338,7 @@ mod tests { fn provider_uses_composed_evm_hardfork() { let mut ctx = test_context(); ctx.cfg.spec = TempoHardfork::T8; - let provider = ZonePrecompileStorageProvider::new( + let provider = ZonePrecompileStorageProvider::new_at_block( test_storage_provider(&mut ctx, u64::MAX, false), MockL1Reader::default(), 77, @@ -326,10 +360,19 @@ mod tests { ) .expect("anchor write succeeds"); - let err = read_l1_anchor(&mut inner).expect_err("oversized anchor must be rejected"); + let err = match ZonePrecompileStorageProvider::try_new(inner, MockL1Reader::default()) { + Ok(_) => panic!("oversized anchor must be rejected"), + Err(err) => err, + }; assert!( - matches!(err, TempoPrecompileError::Fatal(ref msg) if msg.contains("does not fit in u64") && msg.contains(&oversized.to_string())) + matches!(&err.error, TempoPrecompileError::Fatal(msg) if msg.contains("does not fit in u64") && msg.contains(&oversized.to_string())) ); + assert!(err.gas_used > 0); + assert_eq!(err.reservoir, 0); + assert!(matches!( + err.into_precompile_result(), + Err(PrecompileError::Fatal(msg)) if msg.contains("does not fit in u64") + )); } #[test] From 4c59b6c909c965b2deaf23a6769f4317bfdaff7f Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 00:00:48 +0200 Subject: [PATCH 08/30] feat(precompiles): centralize zone precompile execution --- crates/evm/src/lib.rs | 77 +--- crates/precompiles/src/aes_gcm/mod.rs | 68 +-- crates/precompiles/src/chaum_pedersen/mod.rs | 39 -- crates/precompiles/src/execution.rs | 461 +++++++++++++++++++ crates/precompiles/src/lib.rs | 156 ++++++- crates/precompiles/src/tempo_state.rs | 93 +--- crates/precompiles/src/test_utils.rs | 31 +- crates/precompiles/src/tip20_factory/mod.rs | 43 -- 8 files changed, 685 insertions(+), 283 deletions(-) create mode 100644 crates/precompiles/src/execution.rs diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 39d30dbdf..7206f841f 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -16,11 +16,7 @@ pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; use crate::{ executor::ZoneBlockExecutor, - precompiles::{ - AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt, CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify, - SequencerExt, TempoState, ZONE_TIP20_FACTORY_ADDRESS, ZONE_TIP403_PROXY_ADDRESS, - ZoneTip20Token, ZoneTip403ProxyRegistry, ZoneTokenFactory, - }, + precompiles::{SequencerExt, extend_zone_precompiles}, tx_context::ZoneTxContext, }; use alloy_evm::{ @@ -47,16 +43,11 @@ use tempo_evm::{ evm::{TempoEvm, TempoEvmFactory}, }; use tempo_payload_types::TempoExecutionData; -use tempo_precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, PrecompileEnv, STABLECOIN_DEX_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, account_keychain::AccountKeychain, nonce::NonceManager, - storage::actions::StorageActions, storage_credits::NonCreditableSlots, - tip_fee_manager::TipFeeManager, tip20::is_tip20_prefix, -}; +use tempo_precompiles::{storage::actions::StorageActions, storage_credits::NonCreditableSlots}; use tempo_primitives::{ Block, TempoHeader, TempoPrimitives, TempoReceipt, TempoTxEnvelope, TempoTxType, }; -use tempo_zone_contracts::{TEMPO_STATE_ADDRESS, ZONE_TX_CONTEXT_ADDRESS}; +use tempo_zone_contracts::ZONE_TX_CONTEXT_ADDRESS; use zone_chainspec::ZoneChainSpec; use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig, PolicyProvider}; @@ -91,67 +82,17 @@ impl ZoneEvmFactory { ) -> TempoEvm { let cfg = evm.ctx().cfg.clone(); let (_, _, precompiles) = evm.components_mut(); - precompiles.apply_precompile(&TEMPO_STATE_ADDRESS, |_| { - Some(TempoState::create(self.l1_provider.clone(), &cfg)) - }); - precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| Some(ZoneTxContext::create())); - precompiles.apply_precompile(&CHAUM_PEDERSEN_VERIFY_ADDRESS, |_| { - Some(ChaumPedersenVerify::create(&cfg)) - }); - precompiles.apply_precompile(&AES_GCM_DECRYPT_ADDRESS, |_| { - Some(AesGcmDecrypt::create(&cfg)) - }); - precompiles.apply_precompile(&ZONE_TIP20_FACTORY_ADDRESS, |_| { - Some(ZoneTokenFactory::create(&cfg)) - }); - let registry = self - .policy_provider - .clone() - .map(ZoneTip403ProxyRegistry::new); let sequencer: Arc = Arc::new(self.l1_provider.clone()); - - if let Some(provider) = self.policy_provider.clone() { - precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, |_| { - Some(ZoneTip403ProxyRegistry::create(provider.clone(), &cfg)) - }); - } - - // Override the TIP-20 precompile lookup so that all TIP-20 token - // calls go through ZoneTip20Token. When a live policy provider is - // available, the wrapper also enforces TIP-403 policy checks; without - // one, it still applies privacy, fixed-gas, and bridge-auth rules. - // - // This replaces the upstream `extend_tempo_precompiles` lookup, so we - // must also handle the non-TIP-20 Tempo precompiles that are zone-relevant - // (FeeManager, NonceManager, AccountKeychain). - // Zone-specific overrides (TIP20Factory, TIP403Proxy) are in the - // static map via `apply_precompile` and take priority over this. - let zone_cfg = cfg.clone(); - let zone_env = PrecompileEnv::new( + extend_zone_precompiles( + precompiles, &cfg, + self.l1_provider.clone(), + self.policy_provider.clone(), + sequencer, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), ); - precompiles.set_precompile_lookup(move |address: &alloy_primitives::Address| { - if is_tip20_prefix(*address) { - Some(ZoneTip20Token::create( - *address, - &zone_cfg, - registry.clone(), - sequencer.clone(), - )) - } else if *address == TIP_FEE_MANAGER_ADDRESS { - Some(TipFeeManager::create_precompile(&zone_env)) - } else if *address == STABLECOIN_DEX_ADDRESS { - None - } else if *address == NONCE_PRECOMPILE_ADDRESS { - Some(NonceManager::create_precompile(&zone_env)) - } else if *address == ACCOUNT_KEYCHAIN_ADDRESS { - Some(AccountKeychain::create_precompile(&zone_env)) - } else { - None - } - }); + precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| Some(ZoneTxContext::create())); evm } } diff --git a/crates/precompiles/src/aes_gcm/mod.rs b/crates/precompiles/src/aes_gcm/mod.rs index 41024494b..c2840f7a8 100644 --- a/crates/precompiles/src/aes_gcm/mod.rs +++ b/crates/precompiles/src/aes_gcm/mod.rs @@ -15,9 +15,7 @@ use aes_gcm::{ }; mod dispatch; -use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, address}; -use revm::precompile::PrecompileId; /// AES-256-GCM Decrypt precompile address on Zone L2. pub const AES_GCM_DECRYPT_ADDRESS: Address = address!("0x1C00000000000000000000000000000000000101"); @@ -50,39 +48,6 @@ pub use IAesGcmDecrypt::{decryptCall, decryptReturn}; /// `(empty, false)` if tag verification fails. pub struct AesGcmDecrypt; -impl AesGcmDecrypt { - /// Wrap this precompile in a [`DynPrecompile`] with the Tempo storage context - /// required by the upstream dispatch macro. - pub fn create( - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - use tempo_precompiles::{ - Precompile as _, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - DynPrecompile::new_stateful(PrecompileId::Custom("AesGcmDecrypt".into()), move |input| { - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let mut precompile = Self; - precompile.call(input.data, input.caller) - }) - }) - } -} - /// Decrypt AES-256-GCM ciphertext with tag verification. /// /// The ciphertext, AAD, and tag are passed separately (matching the Solidity interface). @@ -118,14 +83,9 @@ pub fn decrypt_aes_gcm( mod tests { use super::*; use crate::test_utils::{test_context, test_storage_provider}; - use alloy_evm::{ - EvmInternals, - precompiles::{Precompile, PrecompileInput}, - }; - use alloy_primitives::{Bytes, U256}; + use alloy_primitives::Bytes; use alloy_sol_types::SolCall; use revm::precompile::PrecompileOutput; - use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{charge_input_cost, storage::StorageCtx}; fn encrypt(plaintext: &[u8], aad: &[u8]) -> decryptCall { @@ -156,20 +116,18 @@ mod tests { fn call_precompile(calldata: Bytes) -> PrecompileOutput { let mut ctx = test_context(); - let cfg = revm::context::CfgEnv::::default(); - AesGcmDecrypt::create(&cfg) - .call(PrecompileInput { - data: &calldata, - gas: u64::MAX, - reservoir: 0, - caller: Address::ZERO, - value: U256::ZERO, - target_address: AES_GCM_DECRYPT_ADDRESS, - is_static: true, - bytecode_address: AES_GCM_DECRYPT_ADDRESS, - internals: EvmInternals::from_context(&mut ctx), - }) - .expect("precompile call succeeds") + let precompile = AesGcmDecrypt::create(&ctx.cfg.clone()); + crate::test_utils::call_precompile( + &mut ctx, + &precompile, + Address::ZERO, + &calldata, + u64::MAX, + true, + AES_GCM_DECRYPT_ADDRESS, + AES_GCM_DECRYPT_ADDRESS, + ) + .expect("precompile call succeeds") } fn charged_input_gas(calldata: &[u8]) -> u64 { diff --git a/crates/precompiles/src/chaum_pedersen/mod.rs b/crates/precompiles/src/chaum_pedersen/mod.rs index bc05d3548..bb175a60a 100644 --- a/crates/precompiles/src/chaum_pedersen/mod.rs +++ b/crates/precompiles/src/chaum_pedersen/mod.rs @@ -12,7 +12,6 @@ use alloc::vec::Vec; mod dispatch; -use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, address}; use k256::{ AffinePoint, ProjectivePoint, Scalar, @@ -21,8 +20,6 @@ use k256::{ sec1::{FromEncodedPoint, ToEncodedPoint}, }, }; -use revm::precompile::PrecompileId; - /// Chaum-Pedersen Verify precompile address on Zone L2. pub const CHAUM_PEDERSEN_VERIFY_ADDRESS: Address = address!("0x1C00000000000000000000000000000000000100"); @@ -66,42 +63,6 @@ pub use IChaumPedersenVerify::verifyProofCall; /// - Check: `c == c'` pub struct ChaumPedersenVerify; -impl ChaumPedersenVerify { - /// Wrap this precompile in a [`DynPrecompile`] with the Tempo storage context - /// required by the upstream dispatch macro. - pub fn create( - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - use tempo_precompiles::{ - Precompile as _, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - DynPrecompile::new_stateful( - PrecompileId::Custom("ChaumPedersenVerify".into()), - move |input| { - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let mut precompile = Self; - precompile.call(input.data, input.caller) - }) - }, - ) - } -} - /// Recover a secp256k1 affine point from compressed form (x coordinate + y parity). /// /// `y_parity` follows SEC1: `0x02` for even y, `0x03` for odd y. diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs new file mode 100644 index 000000000..871887e1d --- /dev/null +++ b/crates/precompiles/src/execution.rs @@ -0,0 +1,461 @@ +//! Shared execution for zone-native and L1-backed Tempo precompiles. +//! +//! Both helpers install an EVM-backed [`StorageCtx`], apply zone-specific [`CallRules`], and +//! forward admitted calls without changing their calldata or caller. +//! +//! # Execution modes +//! +//! - [`create_local_precompile`] executes against ordinary zone-local EVM state. +//! - [`create_l1_backed_precompile`] reads the finalized Tempo block recorded in `TempoState`, +//! resolves the Tempo hardfork at that same block, and overlays selected policy storage from L1. +//! +//! # Call ordering +//! +//! 1. Reject disallowed delegate calls before constructing storage or reading the L1 anchor. +//! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. +//! 3. Install the appropriate storage provider. L1-backed calls read their anchor once and fail +//! closed if the anchor, hardfork, or overlay cannot be resolved. +//! 4. Apply [`CallRules`]. Admitted calls receive the original calldata and caller; rejected calls +//! are returned without invoking the supplied implementation. +//! 5. Apply the configured fixed gas charge to successful precompile results. +//! +//! Rule-level rejections include calldata input gas. Calls without a fixed charge retain normal +//! provider metering, while successful fixed-price calls report exactly the configured charge. + +use alloc::rc::Rc; +use core::cell::RefCell; + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::Address; +use alloy_sol_types::SolError; +use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + DelegateCallNotAllowed, charge_input_cost, + dispatch::selector_from_calldata, + storage::{ + PrecompileStorageProvider, StorageCtx, actions::StorageActions, + evm::EvmPrecompileStorageProvider, + }, + storage_credits::NonCreditableSlots, +}; + +use crate::storage::{L1StorageReader, ZonePrecompileStorageProvider, read_l1_anchor}; + +/// Shared inputs for precompiles executing over finalized Tempo state. +/// +/// Each call combines zone EVM configuration and accounting state with an L1 reader. The exact +/// L1 block and active hardfork are resolved from the local `TempoState` anchor during execution. +#[derive(Clone)] +pub(crate) struct L1BackedPrecompileEnv

{ + cfg: revm::context::CfgEnv, + l1_reader: P, + actions: StorageActions, + non_creditable_slots: Rc>, +} + +impl

L1BackedPrecompileEnv

{ + /// Capture the configuration and providers shared by L1-backed calls. + pub(crate) fn new( + cfg: &revm::context::CfgEnv, + l1_reader: P, + actions: StorageActions, + non_creditable_slots: Rc>, + ) -> Self { + Self { + cfg: cfg.clone(), + l1_reader, + actions, + non_creditable_slots, + } + } +} + +/// Selector- and caller-dependent admission rules applied inside a [`StorageCtx`]. +/// +/// Returning `Some(result)` from [`Self::check_call`] rejects the call without invoking the +/// forwarded implementation. The helper adds calldata input gas before returning that result. +pub(crate) trait CallRules: 'static { + /// Whether delegate calls must be rejected before storage setup. + fn direct_call_only(&self) -> bool { + false + } + + /// Return the fixed gas charge for this selector, if one applies. + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + None + } + + /// Return a rejection result, or `None` to forward the original call. + fn check_call( + &self, + _data: &[u8], + _selector: Option<[u8; 4]>, + _caller: Address, + _is_direct: bool, + ) -> Option { + None + } +} + +/// Rules for precompiles that require no zone-specific admission or fixed gas handling. +pub(crate) struct NoCallRules; +impl CallRules for NoCallRules {} + +/// Rules for precompiles whose semantics require execution at their registered address. +pub(crate) struct DirectCallOnly; +impl CallRules for DirectCallOnly { + fn direct_call_only(&self) -> bool { + true + } +} + +/// Create a precompile with zone call rules and ordinary local EVM storage. +/// +/// This helper neither reads a Tempo anchor nor installs an L1 overlay. Calls admitted by `rules` +/// are forwarded to `execute` with their original calldata and caller. +pub(crate) fn create_local_precompile( + id: &'static str, + cfg: &revm::context::CfgEnv, + rules: Rules, + execute: Execute, +) -> DynPrecompile +where + Rules: CallRules, + Execute: Fn(&[u8], Address) -> PrecompileResult + 'static, +{ + let spec = cfg.spec; + let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; + let gas_params = cfg.gas_params.clone(); + + DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { + if rules.direct_call_only() && !input.is_direct_call() { + return delegate_call_not_allowed(input.reservoir); + } + + let selector = selector_from_calldata(input.data); + let fixed_gas = rules.fixed_gas(selector); + if fixed_gas.is_some_and(|gas| input.gas < gas) { + return Ok(PrecompileOutput::halt( + PrecompileHalt::OutOfGas, + input.reservoir, + )); + } + + let is_direct = input.is_direct_call(); + let mut storage = EvmPrecompileStorageProvider::new( + input.internals, + fixed_gas.map_or(input.gas, |_| u64::MAX), + input.reservoir, + spec, + amsterdam_eip8037_enabled, + input.is_static, + gas_params.clone(), + ); + + StorageCtx::enter(&mut storage, || { + let result = match rules.check_call(input.data, selector, input.caller, is_direct) { + Some(result) => add_input_cost(input.data, result), + None => execute(input.data, input.caller), + }; + apply_fixed_gas(result, fixed_gas) + }) + }) +} + +/// Create a direct-call-only precompile backed by the finalized Tempo L1 anchor. +/// +/// The helper rejects delegate calls before any storage access, reads the `TempoState` anchor once, +/// and constructs [`ZonePrecompileStorageProvider`] with that exact block. Construction is +/// fallible because the provider resolves the active hardfork from the same anchor. Any anchor, +/// hardfork, or L1 storage failure is returned as a precompile error rather than falling back to +/// local or latest state. +/// +/// Calls admitted by `rules` are forwarded to `execute` with their original calldata and caller +/// while the L1 overlay is active. +pub(crate) fn create_l1_backed_precompile( + id: &'static str, + env: L1BackedPrecompileEnv

, + rules: Rules, + execute: Execute, +) -> DynPrecompile +where + P: L1StorageReader, + Rules: CallRules, + Execute: Fn(&[u8], Address) -> PrecompileResult + 'static, +{ + let zone_spec = env.cfg.spec; + let amsterdam_eip8037_enabled = env.cfg.enable_amsterdam_eip8037; + let gas_params = env.cfg.gas_params; + let actions = env.actions; + let non_creditable_slots = env.non_creditable_slots; + let l1_reader = env.l1_reader; + + DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { + if !input.is_direct_call() { + return delegate_call_not_allowed(input.reservoir); + } + + let selector = selector_from_calldata(input.data); + let fixed_gas = rules.fixed_gas(selector); + if fixed_gas.is_some_and(|gas| input.gas < gas) { + return Ok(PrecompileOutput::halt( + PrecompileHalt::OutOfGas, + input.reservoir, + )); + } + + let mut inner = EvmPrecompileStorageProvider::new( + input.internals, + fixed_gas.map_or(input.gas, |_| u64::MAX), + input.reservoir, + zone_spec, + amsterdam_eip8037_enabled, + input.is_static, + gas_params.clone(), + ) + .with_actions(actions.clone()) + .with_non_creditable_slots(non_creditable_slots.clone()); + + let l1_block_number = match read_l1_anchor(&mut inner) { + Ok(block_number) => block_number, + Err(err) => { + return err.into_precompile_result(inner.gas_used(), inner.reservoir()); + } + }; + let gas_used = inner.gas_used(); + let reservoir = inner.reservoir(); + let mut storage = + match ZonePrecompileStorageProvider::new(inner, l1_reader.clone(), l1_block_number) { + Ok(storage) => storage, + Err(err) => return err.into_precompile_result(gas_used, reservoir), + }; + + StorageCtx::enter(&mut storage, || { + let result = match rules.check_call(input.data, selector, input.caller, true) { + Some(result) => add_input_cost(input.data, result), + None => execute(input.data, input.caller), + }; + apply_fixed_gas(result, fixed_gas) + }) + }) +} + +fn delegate_call_not_allowed(reservoir: u64) -> PrecompileResult { + Ok(PrecompileOutput::revert( + 0, + SolError::abi_encode(&DelegateCallNotAllowed {}).into(), + reservoir, + )) +} + +fn add_input_cost(calldata: &[u8], result: PrecompileResult) -> PrecompileResult { + let mut storage = StorageCtx::default(); + let gas_before = storage.gas_used(); + if let Some(err) = charge_input_cost(&mut storage, calldata) { + return err; + } + let input_gas = storage.gas_used().saturating_sub(gas_before); + result.map(|mut output| { + output.gas_used = output.gas_used.saturating_add(input_gas); + output + }) +} + +fn apply_fixed_gas(result: PrecompileResult, fixed_gas: Option) -> PrecompileResult { + match (result, fixed_gas) { + (Ok(mut output), Some(gas)) => { + output.gas_used = gas; + Ok(output) + } + (result, _) => result, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + tempo_state::slots as tempo_state_slots, + test_utils::{MockL1Reader, test_context, test_storage_provider}, + }; + use alloy_evm::{ + EvmInternals, + precompiles::{Precompile as _, PrecompileInput}, + }; + use alloy_primitives::{Bytes, U256}; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; + + const FIXED_GAS: u64 = 123; + type RuleRecord = Rc, Address)>>>; + + struct RecordingRules(RuleRecord); + + impl CallRules for RecordingRules { + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + Some(FIXED_GAS) + } + + fn check_call( + &self, + data: &[u8], + selector: Option<[u8; 4]>, + caller: Address, + _is_direct: bool, + ) -> Option { + *self.0.borrow_mut() = Some((Bytes::copy_from_slice(data), selector, caller)); + None + } + } + + fn input<'a>( + ctx: &'a mut crate::test_utils::TestContext, + data: &'a [u8], + caller: Address, + gas: u64, + ) -> PrecompileInput<'a> { + let target = Address::repeat_byte(0x11); + PrecompileInput { + data, + gas, + reservoir: 0, + caller, + value: U256::ZERO, + target_address: target, + is_static: false, + bytecode_address: target, + internals: EvmInternals::from_context(ctx), + } + } + + #[test] + fn forwards_original_call_applies_rules_and_restores_storage_context() { + let recorded_rule = Rc::new(RefCell::new(None)); + let recorded_execute = Rc::new(RefCell::new(None)); + let execute_record = recorded_execute.clone(); + let cfg = revm::context::CfgEnv::::default(); + let precompile = create_local_precompile( + "ForwardingTest", + &cfg, + RecordingRules(recorded_rule.clone()), + move |data, caller| { + *execute_record.borrow_mut() = Some((Bytes::copy_from_slice(data), caller)); + Ok(StorageCtx::default().success_output(Bytes::new())) + }, + ); + + let mut outer_ctx = test_context(); + let mut inner_ctx = test_context(); + let mut outer = test_storage_provider(&mut outer_ctx, 777, false); + let calldata = [0xde, 0xad, 0xbe, 0xef, 0x01]; + let caller = Address::repeat_byte(0x22); + let output = StorageCtx::enter(&mut outer, || { + let output = precompile + .call(input(&mut inner_ctx, &calldata, caller, FIXED_GAS)) + .unwrap(); + assert_eq!(StorageCtx::default().gas_limit(), 777); + output + }); + + assert_eq!(output.gas_used, FIXED_GAS); + assert_eq!( + *recorded_rule.borrow(), + Some((calldata.into(), Some([0xde, 0xad, 0xbe, 0xef]), caller)) + ); + assert_eq!(*recorded_execute.borrow(), Some((calldata.into(), caller))); + } + + #[test] + fn l1_backed_execution_uses_the_anchor_for_its_fallible_provider() { + let anchor = 42; + let reader = MockL1Reader::default(); + let observed_spec = Rc::new(Cell::new(None)); + let execute_spec = observed_spec.clone(); + let cfg = revm::context::CfgEnv::::default(); + let env = L1BackedPrecompileEnv::new( + &cfg, + reader.clone(), + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ); + let precompile = + create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { + execute_spec.set(Some(StorageCtx::default().spec())); + Ok(StorageCtx::default().success_output(Bytes::new())) + }); + let mut ctx = test_context(); + test_storage_provider(&mut ctx, u64::MAX, false) + .sstore( + tempo_zone_contracts::TEMPO_STATE_ADDRESS, + tempo_state_slots::TEMPO_BLOCK_NUMBER, + U256::from(anchor), + ) + .unwrap(); + + precompile + .call(input(&mut ctx, &[], Address::ZERO, u64::MAX)) + .unwrap(); + + assert_eq!(reader.hardfork_requests(), vec![anchor]); + assert_eq!(observed_spec.get(), Some(TempoHardfork::T8)); + } + + struct RejectRules(Rc>); + + impl CallRules for RejectRules { + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + Some(FIXED_GAS) + } + + fn check_call( + &self, + _data: &[u8], + _selector: Option<[u8; 4]>, + _caller: Address, + _is_direct: bool, + ) -> Option { + self.0.set(true); + Some(Ok( + StorageCtx::default().revert_output(Bytes::from_static(b"denied")) + )) + } + } + + #[test] + fn admission_and_fixed_gas_run_before_forwarded_execution() { + let checked = Rc::new(Cell::new(false)); + let executed = Rc::new(Cell::new(false)); + let execute_flag = executed.clone(); + let cfg = revm::context::CfgEnv::::default(); + let precompile = create_local_precompile( + "AdmissionTest", + &cfg, + RejectRules(checked.clone()), + move |_, _| { + execute_flag.set(true); + Ok(StorageCtx::default().success_output(Bytes::new())) + }, + ); + let mut ctx = test_context(); + let calldata = [1, 2, 3, 4]; + + let out_of_gas = precompile + .call(input(&mut ctx, &calldata, Address::ZERO, FIXED_GAS - 1)) + .unwrap(); + assert!(out_of_gas.is_halt()); + assert_eq!(out_of_gas.halt_reason(), Some(&PrecompileHalt::OutOfGas)); + assert!(!checked.get()); + assert!(!executed.get()); + + let rejected = precompile + .call(input(&mut ctx, &calldata, Address::ZERO, FIXED_GAS)) + .unwrap(); + assert!(checked.get()); + assert!(!executed.get()); + assert_eq!(rejected.gas_used, FIXED_GAS); + assert_eq!(rejected.bytes, Bytes::from_static(b"denied")); + } +} diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index f42fead97..f0221340c 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -1,4 +1,13 @@ -//! Zone-specific precompile implementations. +//! Zone-native precompiles and shared execution for Tempo precompiles on a zone. +//! +//! Zone-native implementations execute against ordinary local EVM storage. Tempo implementations +//! that require finalized L1 state execute through a storage overlay anchored at the block recorded +//! in `TempoState`. Zone admission, delegate-call, fixed-gas, and privacy rules remain outside the +//! forwarded business logic. +//! +//! [`extend_zone_precompiles`] centralizes registration while the progressive policy migration is +//! underway. The legacy zone TIP-20 and TIP-403 implementations remain active until their dedicated +//! cutovers; `TipFeeManager` is currently the low-risk Tempo wrapper using anchored L1 execution. //! //! This crate is `no_std` compatible so these precompiles can run inside the //! SP1 prover guest (RISC-V) as well as in the zone node. @@ -37,6 +46,7 @@ pub mod dispatch { }; } +mod execution; pub mod policy; pub mod storage; pub mod tempo_state; @@ -52,7 +62,149 @@ pub use tip20_factory::{ZONE_TIP20_FACTORY_ADDRESS, ZoneTokenFactory}; pub use tip403_proxy::{ZONE_TIP403_PROXY_ADDRESS, ZoneTip403ProxyRegistry}; pub use ztip20::{SequencerExt, ZoneTip20Token}; -use revm::precompile::PrecompileError; +use alloc::{rc::Rc, sync::Arc}; +use core::cell::RefCell; + +use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap}; +use revm::{context::CfgEnv, precompile::PrecompileError}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, Precompile as _, PrecompileEnv, + STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, account_keychain::AccountKeychain, + nonce::NonceManager, storage::actions::StorageActions, storage_credits::NonCreditableSlots, + tip_fee_manager::TipFeeManager, tip20::is_tip20_prefix, +}; +use zone_primitives::constants::TEMPO_STATE_ADDRESS; + +use crate::policy::PolicyCheck; + +/// Register zone-native and currently supported Tempo precompiles. +/// +/// - **Local and legacy execution:** AES-GCM, Chaum-Pedersen, `TempoState`, and the zone token +/// factory use shared local execution; nonce and account-keychain retain Tempo's ordinary +/// environment; and the policy-cache-backed [`ZoneTip20Token`] and +/// [`ZoneTip403ProxyRegistry`] remain active until migrated. +/// - **Anchored L1 execution:** `TipFeeManager` uses the exact finalized Tempo anchor through +/// [`storage::ZonePrecompileStorageProvider`]. +pub fn extend_zone_precompiles( + precompiles: &mut PrecompilesMap, + cfg: &CfgEnv, + l1_reader: L1, + policy_provider: Option, + sequencer: Arc, + actions: StorageActions, + non_creditable_slots: Rc>, +) where + L1: L1StorageReader, + Policy: PolicyCheck + Clone + Send + Sync + 'static, +{ + let l1_env = execution::L1BackedPrecompileEnv::new( + cfg, + l1_reader.clone(), + actions.clone(), + non_creditable_slots.clone(), + ); + let tempo_env = PrecompileEnv::new(cfg, actions, non_creditable_slots); + + precompiles.apply_precompile(&TEMPO_STATE_ADDRESS, |_| { + Some(TempoState::create(l1_reader.clone(), cfg)) + }); + precompiles.apply_precompile(&CHAUM_PEDERSEN_VERIFY_ADDRESS, |_| { + Some(ChaumPedersenVerify::create(cfg)) + }); + precompiles.apply_precompile(&AES_GCM_DECRYPT_ADDRESS, |_| { + Some(AesGcmDecrypt::create(cfg)) + }); + precompiles.apply_precompile(&ZONE_TIP20_FACTORY_ADDRESS, |_| { + Some(ZoneTokenFactory::create(cfg)) + }); + + if let Some(provider) = policy_provider.clone() { + precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, |_| { + Some(ZoneTip403ProxyRegistry::create(provider.clone(), cfg)) + }); + } + let registry = policy_provider.map(ZoneTip403ProxyRegistry::new); + + // Static zone entries above take priority. The dynamic lookup preserves the legacy token + // wrapper while sharing registration for the remaining Tempo precompiles. + let zone_cfg = cfg.clone(); + precompiles.set_precompile_lookup(move |address: &alloy_primitives::Address| { + if is_tip20_prefix(*address) { + Some(ZoneTip20Token::create( + *address, + &zone_cfg, + registry.clone(), + sequencer.clone(), + )) + } else if *address == TIP_FEE_MANAGER_ADDRESS { + Some(execution::create_l1_backed_precompile( + "TipFeeManager", + l1_env.clone(), + execution::NoCallRules, + |data, caller| TipFeeManager::new().call(data, caller), + )) + } else if *address == STABLECOIN_DEX_ADDRESS { + None + } else if *address == NONCE_PRECOMPILE_ADDRESS { + Some(NonceManager::create_precompile(&tempo_env)) + } else if *address == ACCOUNT_KEYCHAIN_ADDRESS { + Some(AccountKeychain::create_precompile(&tempo_env)) + } else { + None + } + }); +} + +impl AesGcmDecrypt { + /// Create the AES-GCM precompile with ordinary zone-local execution. + pub fn create(cfg: &CfgEnv) -> DynPrecompile { + execution::create_local_precompile( + "AesGcmDecrypt", + cfg, + execution::NoCallRules, + |data, caller| Self.call(data, caller), + ) + } +} + +impl ChaumPedersenVerify { + /// Create the Chaum-Pedersen precompile with ordinary zone-local execution. + pub fn create(cfg: &CfgEnv) -> DynPrecompile { + execution::create_local_precompile( + "ChaumPedersenVerify", + cfg, + execution::NoCallRules, + |data, caller| Self.call(data, caller), + ) + } +} + +impl TempoState { + /// Create the `TempoState` precompile with local storage and direct-call-only execution. + /// + /// Storage-slot RPC reads are delegated to `reader` at the checkpoint recorded in local state. + pub fn create(reader: P, cfg: &CfgEnv) -> DynPrecompile { + execution::create_local_precompile( + "TempoState", + cfg, + execution::DirectCallOnly, + move |data, caller| Self::new().call_with_provider(&reader, data, caller), + ) + } +} + +impl ZoneTokenFactory { + /// Create the zone token factory with local storage and direct-call-only execution. + pub fn create(cfg: &CfgEnv) -> DynPrecompile { + execution::create_local_precompile( + "ZoneTokenFactory", + cfg, + execution::DirectCallOnly, + |data, caller| Self::new().call(data, caller), + ) + } +} const ZONE_RPC_ERROR_PREFIX: &str = "[zone rpc]"; diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 70f4c022d..99df91c26 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -7,16 +7,12 @@ use alloc::{format, vec::Vec}; use crate::storage::L1StorageReader; use alloy_consensus::BlockHeader; -use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_rlp::Decodable as _; use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult}; +use revm::precompile::PrecompileResult; use tempo_precompiles::{ - DelegateCallNotAllowed, charge_input_cost, dispatch, - error::TempoPrecompileError, - storage::{Handler, StorageCtx, evm::EvmPrecompileStorageProvider}, - view, + charge_input_cost, dispatch, error::TempoPrecompileError, storage::Handler, view, }; use tempo_precompiles_macros::contract; use tempo_primitives::TempoHeader; @@ -178,41 +174,8 @@ impl TempoState { )) } - /// Wraps this precompile for registration in the zone EVM. - pub fn create( - provider: P, - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - - DynPrecompile::new_stateful(PrecompileId::Custom("TempoState".into()), move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - Self::new().call_with_provider(&provider, input.data, input.caller) - }) - }) - } - - fn call_with_provider( + /// Dispatch a `TempoState` call using `provider` for anchored Tempo storage reads. + pub(crate) fn call_with_provider( &mut self, provider: &P, calldata: &[u8], @@ -245,14 +208,14 @@ impl TempoState { mod tests { use super::*; - use crate::test_utils::{MockL1Reader, TestContext, test_context, test_storage_provider}; - use alloy_evm::{ - EvmInternals, - precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, + use crate::test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_storage_provider, }; - use alloy_primitives::{U256, address, b256}; + use alloy_evm::precompiles::DynPrecompile; + use alloy_primitives::{address, b256}; use alloy_rlp::Encodable as _; use alloy_sol_types::SolCall; + use tempo_precompiles::storage::StorageCtx; type TestResult = Result>; fn encode_header(header: &TempoHeader) -> Bytes { @@ -275,37 +238,15 @@ mod tests { calldata: Bytes, is_static: bool, ) -> PrecompileResult { - call_with_bytecode_address( + call_precompile( ctx, precompile, caller, - calldata, + &calldata, + u64::MAX, is_static, TEMPO_STATE_ADDRESS, - ) - } - - fn call_with_bytecode_address( - ctx: &mut TestContext, - precompile: &DynPrecompile, - caller: Address, - calldata: Bytes, - is_static: bool, - bytecode_address: Address, - ) -> PrecompileResult { - AlloyEvmPrecompile::call( - precompile, - PrecompileInput { - data: &calldata, - gas: u64::MAX, - reservoir: 0, - caller, - value: U256::ZERO, - target_address: TEMPO_STATE_ADDRESS, - is_static, - bytecode_address, - internals: EvmInternals::from_context(ctx), - }, + TEMPO_STATE_ADDRESS, ) } @@ -447,16 +388,20 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); - let output = call_with_bytecode_address( + let calldata = TempoStateAbi::tempoBlockHashCall {}.abi_encode(); + let output = call_precompile( &mut ctx, &precompile, Address::ZERO, - TempoStateAbi::tempoBlockHashCall {}.abi_encode().into(), + &calldata, + u64::MAX, true, + TEMPO_STATE_ADDRESS, address!("0x000000000000000000000000000000000000dEaD"), )?; assert!(output.is_revert()); + assert_eq!(output.gas_used, 0); Ok(()) } diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index 00f5df2b5..4cc654e28 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -5,7 +5,10 @@ use std::{ sync::{Arc, Mutex}, }; -use alloy_evm::EvmInternals; +use alloy_evm::{ + EvmInternals, + precompiles::{DynPrecompile, Precompile as _, PrecompileInput}, +}; use alloy_primitives::{Address, B256, U256}; use k256::{ AffinePoint, ProjectivePoint, Scalar, @@ -15,7 +18,7 @@ use revm::{ Context, context::{BlockEnv, CfgEnv, TxEnv}, database::{CacheDB, EmptyDB}, - precompile::PrecompileError, + precompile::{PrecompileError, PrecompileResult}, }; use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::storage::evm::EvmPrecompileStorageProvider; @@ -56,6 +59,30 @@ pub(crate) fn test_storage_provider( ) } +/// Call a dynamic precompile with test defaults for value and reservoir. +pub(crate) fn call_precompile( + ctx: &mut TestContext, + precompile: &DynPrecompile, + caller: Address, + data: &[u8], + gas: u64, + is_static: bool, + target: Address, + bytecode_address: Address, +) -> PrecompileResult { + precompile.call(PrecompileInput { + data, + gas, + reservoir: 0, + caller, + value: U256::ZERO, + target_address: target, + is_static, + bytecode_address, + internals: EvmInternals::from_context(ctx), + }) +} + /// In-memory exact-block L1 reader shared by precompile tests. #[derive(Clone, Default)] pub(crate) struct MockL1Reader { diff --git a/crates/precompiles/src/tip20_factory/mod.rs b/crates/precompiles/src/tip20_factory/mod.rs index acdbad3d6..d8394ffd2 100644 --- a/crates/precompiles/src/tip20_factory/mod.rs +++ b/crates/precompiles/src/tip20_factory/mod.rs @@ -18,7 +18,6 @@ mod dispatch; pub use IZoneTokenFactory::IZoneTokenFactoryErrors as ZoneTokenFactoryError; use alloy_primitives::Address; -use alloy_sol_types::SolError; use tempo_precompiles::{ PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, tip20::{ISSUER_ROLE, TIP20Token}, @@ -83,46 +82,4 @@ impl ZoneTokenFactory { Ok(()) } - - /// Wraps this precompile in a [`DynPrecompile`] for registration in the zone EVM. - /// - /// The returned precompile handles delegate-call rejection, EVM storage - /// context setup, and dispatches to [`TempoPrecompile::call`]. - pub fn create( - cfg: &revm::context::CfgEnv, - ) -> alloy_evm::precompiles::DynPrecompile { - use revm::precompile::{PrecompileId, PrecompileOutput}; - use tempo_precompiles::{ - DelegateCallNotAllowed, Precompile as _, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - }; - - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - alloy_evm::precompiles::DynPrecompile::new_stateful( - PrecompileId::Custom("ZoneTokenFactory".into()), - move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || Self::new().call(input.data, input.caller)) - }, - ) - } } From bc219b00416474e1ca8d81a13bda80a65329ee82 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 09:24:04 +0200 Subject: [PATCH 09/30] refactor(precompiles): model zone call rules explicitly --- crates/precompiles/src/execution.rs | 128 ++++++++++++++++---------- crates/precompiles/src/tempo_state.rs | 5 +- 2 files changed, 83 insertions(+), 50 deletions(-) diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index 871887e1d..fc2f92c28 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -11,12 +11,12 @@ //! //! # Call ordering //! -//! 1. Reject disallowed delegate calls before constructing storage or reading the L1 anchor. +//! 1. L1-backed execution rejects delegate calls before storage access. //! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. //! 3. Install the appropriate storage provider. L1-backed calls read their anchor once and fail //! closed if the anchor, hardfork, or overlay cannot be resolved. -//! 4. Apply [`CallRules`]. Admitted calls receive the original calldata and caller; rejected calls -//! are returned without invoking the supplied implementation. +//! 4. Apply [`CallRules`], including any local direct-call rule. Admitted calls receive the original +//! calldata and caller; rejected calls are returned without invoking the implementation. //! 5. Apply the configured fixed gas charge to successful precompile results. //! //! Rule-level rejections include calldata input gas. Calls without a fixed charge retain normal @@ -71,30 +71,55 @@ impl

L1BackedPrecompileEnv

{ } } -/// Selector- and caller-dependent admission rules applied inside a [`StorageCtx`]. +/// Call metadata, independent of EVM internals, for [`CallRules`] running in a [`StorageCtx`]. +/// Provider-free precompiles can inspect `PrecompileInput` directly. /// -/// Returning `Some(result)` from [`Self::check_call`] rejects the call without invoking the -/// forwarded implementation. The helper adds calldata input gas before returning that result. -pub(crate) trait CallRules: 'static { - /// Whether delegate calls must be rejected before storage setup. - fn direct_call_only(&self) -> bool { - false - } +/// **MOTIVATION:** Execution helpers move `PrecompileInput::internals` into the +/// [`EvmPrecompileStorageProvider`] before calling [`CallRules::check_call`]. The full input +/// cannot be borrowed after that partial move, so [`ZoneCall`] carries only the metadata +/// needed by [`CallRules`]. +#[derive(Debug, Clone, Copy)] +#[allow( + dead_code, + reason = "consumed by call rules in the stacked policy cutover" +)] +pub(crate) struct ZoneCall<'a> { + /// Input calldata. + pub(crate) data: &'a [u8], + /// Decoded 4-byte selector, when calldata is long enough. + pub(crate) selector: Option<[u8; 4]>, + /// EVM caller. + pub(crate) caller: Address, + /// Whether target and bytecode addresses match. + pub(crate) is_direct: bool, +} + +/// Result of applying zone-specific pre-execution rules. +pub(crate) enum CallCheck { + /// Allow the call and invoke the supplied precompile implementation. + /// + /// For L1-backed precompiles, this forwards to upstream Tempo with the zone's finalized L1 + /// storage overlay active. + Continue, + /// Reject the call without invoking the supplied implementation. + /// + /// The execution helper charges calldata input gas before returning the result. + Return(PrecompileResult), +} +/// Selector- and caller-dependent pre-execution rules for a storage-backed precompile. +/// +/// Checks receive [`ZoneCall`] because they run inside [`StorageCtx`], after the input's EVM +/// internals have moved into the storage provider. +pub(crate) trait CallRules: 'static { /// Return the fixed gas charge for this selector, if one applies. fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { None } - /// Return a rejection result, or `None` to forward the original call. - fn check_call( - &self, - _data: &[u8], - _selector: Option<[u8; 4]>, - _caller: Address, - _is_direct: bool, - ) -> Option { - None + /// Decide whether execution may proceed to the forwarded implementation. + fn check_call(&self, _call: ZoneCall<'_>) -> CallCheck { + CallCheck::Continue } } @@ -105,8 +130,13 @@ impl CallRules for NoCallRules {} /// Rules for precompiles whose semantics require execution at their registered address. pub(crate) struct DirectCallOnly; impl CallRules for DirectCallOnly { - fn direct_call_only(&self) -> bool { - true + fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + if call.is_direct { + CallCheck::Continue + } else { + CallCheck::Return(Ok(StorageCtx::default() + .revert_output(SolError::abi_encode(&DelegateCallNotAllowed {}).into()))) + } } } @@ -129,10 +159,6 @@ where let gas_params = cfg.gas_params.clone(); DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { - if rules.direct_call_only() && !input.is_direct_call() { - return delegate_call_not_allowed(input.reservoir); - } - let selector = selector_from_calldata(input.data); let fixed_gas = rules.fixed_gas(selector); if fixed_gas.is_some_and(|gas| input.gas < gas) { @@ -154,9 +180,15 @@ where ); StorageCtx::enter(&mut storage, || { - let result = match rules.check_call(input.data, selector, input.caller, is_direct) { - Some(result) => add_input_cost(input.data, result), - None => execute(input.data, input.caller), + let call = ZoneCall { + data: input.data, + selector, + caller: input.caller, + is_direct, + }; + let result = match rules.check_call(call) { + CallCheck::Continue => execute(input.data, input.caller), + CallCheck::Return(result) => add_input_cost(input.data, result), }; apply_fixed_gas(result, fixed_gas) }) @@ -232,9 +264,15 @@ where }; StorageCtx::enter(&mut storage, || { - let result = match rules.check_call(input.data, selector, input.caller, true) { - Some(result) => add_input_cost(input.data, result), - None => execute(input.data, input.caller), + let call = ZoneCall { + data: input.data, + selector, + caller: input.caller, + is_direct: true, + }; + let result = match rules.check_call(call) { + CallCheck::Continue => execute(input.data, input.caller), + CallCheck::Return(result) => add_input_cost(input.data, result), }; apply_fixed_gas(result, fixed_gas) }) @@ -299,15 +337,13 @@ mod tests { Some(FIXED_GAS) } - fn check_call( - &self, - data: &[u8], - selector: Option<[u8; 4]>, - caller: Address, - _is_direct: bool, - ) -> Option { - *self.0.borrow_mut() = Some((Bytes::copy_from_slice(data), selector, caller)); - None + fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + *self.0.borrow_mut() = Some(( + Bytes::copy_from_slice(call.data), + call.selector, + call.caller, + )); + CallCheck::Continue } } @@ -410,15 +446,9 @@ mod tests { Some(FIXED_GAS) } - fn check_call( - &self, - _data: &[u8], - _selector: Option<[u8; 4]>, - _caller: Address, - _is_direct: bool, - ) -> Option { + fn check_call(&self, _call: ZoneCall<'_>) -> CallCheck { self.0.set(true); - Some(Ok( + CallCheck::Return(Ok( StorageCtx::default().revert_output(Bytes::from_static(b"denied")) )) } diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 99df91c26..f7dccad0f 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -401,7 +401,10 @@ mod tests { )?; assert!(output.is_revert()); - assert_eq!(output.gas_used, 0); + assert_eq!( + output.gas_used, + tempo_precompiles::input_cost(calldata.len()) + ); Ok(()) } From b8022cd1efe52bc0d4820e32646172b3a5ff88ef Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 11:37:57 +0200 Subject: [PATCH 10/30] style: cleanup --- crates/precompiles/src/execution.rs | 132 ++++++++++++---------------- 1 file changed, 58 insertions(+), 74 deletions(-) diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index fc2f92c28..1de91c823 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -25,7 +25,7 @@ use alloc::rc::Rc; use core::cell::RefCell; -use alloy_evm::precompiles::DynPrecompile; +use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; use alloy_primitives::Address; use alloy_sol_types::SolError; use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; @@ -86,14 +86,26 @@ impl

L1BackedPrecompileEnv

{ pub(crate) struct ZoneCall<'a> { /// Input calldata. pub(crate) data: &'a [u8], - /// Decoded 4-byte selector, when calldata is long enough. - pub(crate) selector: Option<[u8; 4]>, /// EVM caller. pub(crate) caller: Address, /// Whether target and bytecode addresses match. pub(crate) is_direct: bool, } +impl<'a> ZoneCall<'a> { + pub(crate) fn new(input: &PrecompileInput<'a>) -> Self { + Self { + data: input.data, + caller: input.caller, + is_direct: input.is_direct_call(), + } + } + + pub(crate) fn selector(&self) -> Option<[u8; 4]> { + selector_from_calldata(self.data) + } +} + /// Result of applying zone-specific pre-execution rules. pub(crate) enum CallCheck { /// Allow the call and invoke the supplied precompile implementation. @@ -144,23 +156,19 @@ impl CallRules for DirectCallOnly { /// /// This helper neither reads a Tempo anchor nor installs an L1 overlay. Calls admitted by `rules` /// are forwarded to `execute` with their original calldata and caller. -pub(crate) fn create_local_precompile( +pub(crate) fn create_local_precompile( id: &'static str, cfg: &revm::context::CfgEnv, - rules: Rules, - execute: Execute, -) -> DynPrecompile -where - Rules: CallRules, - Execute: Fn(&[u8], Address) -> PrecompileResult + 'static, -{ + rules: impl CallRules, + execute: impl Fn(&[u8], Address) -> PrecompileResult + 'static, +) -> DynPrecompile { let spec = cfg.spec; let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; let gas_params = cfg.gas_params.clone(); DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { - let selector = selector_from_calldata(input.data); - let fixed_gas = rules.fixed_gas(selector); + let call = ZoneCall::new(&input); + let fixed_gas = rules.fixed_gas(call.selector()); if fixed_gas.is_some_and(|gas| input.gas < gas) { return Ok(PrecompileOutput::halt( PrecompileHalt::OutOfGas, @@ -168,7 +176,6 @@ where )); } - let is_direct = input.is_direct_call(); let mut storage = EvmPrecompileStorageProvider::new( input.internals, fixed_gas.map_or(input.gas, |_| u64::MAX), @@ -179,19 +186,7 @@ where gas_params.clone(), ); - StorageCtx::enter(&mut storage, || { - let call = ZoneCall { - data: input.data, - selector, - caller: input.caller, - is_direct, - }; - let result = match rules.check_call(call) { - CallCheck::Continue => execute(input.data, input.caller), - CallCheck::Return(result) => add_input_cost(input.data, result), - }; - apply_fixed_gas(result, fixed_gas) - }) + execute_with_storage(&mut storage, call, fixed_gas, &rules, &execute) }) } @@ -205,17 +200,12 @@ where /// /// Calls admitted by `rules` are forwarded to `execute` with their original calldata and caller /// while the L1 overlay is active. -pub(crate) fn create_l1_backed_precompile( +pub(crate) fn create_l1_backed_precompile( id: &'static str, env: L1BackedPrecompileEnv

, - rules: Rules, - execute: Execute, -) -> DynPrecompile -where - P: L1StorageReader, - Rules: CallRules, - Execute: Fn(&[u8], Address) -> PrecompileResult + 'static, -{ + rules: impl CallRules, + execute: impl Fn(&[u8], Address) -> PrecompileResult + 'static, +) -> DynPrecompile { let zone_spec = env.cfg.spec; let amsterdam_eip8037_enabled = env.cfg.enable_amsterdam_eip8037; let gas_params = env.cfg.gas_params; @@ -224,12 +214,16 @@ where let l1_reader = env.l1_reader; DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { - if !input.is_direct_call() { - return delegate_call_not_allowed(input.reservoir); + let call = ZoneCall::new(&input); + if !call.is_direct { + return Ok(PrecompileOutput::revert( + 0, + SolError::abi_encode(&DelegateCallNotAllowed {}).into(), + input.reservoir, + )); } - let selector = selector_from_calldata(input.data); - let fixed_gas = rules.fixed_gas(selector); + let fixed_gas = rules.fixed_gas(call.selector()); if fixed_gas.is_some_and(|gas| input.gas < gas) { return Ok(PrecompileOutput::halt( PrecompileHalt::OutOfGas, @@ -263,51 +257,41 @@ where Err(err) => return err.into_precompile_result(gas_used, reservoir), }; - StorageCtx::enter(&mut storage, || { - let call = ZoneCall { - data: input.data, - selector, - caller: input.caller, - is_direct: true, - }; - let result = match rules.check_call(call) { - CallCheck::Continue => execute(input.data, input.caller), - CallCheck::Return(result) => add_input_cost(input.data, result), - }; - apply_fixed_gas(result, fixed_gas) - }) + execute_with_storage(&mut storage, call, fixed_gas, &rules, &execute) }) } -fn delegate_call_not_allowed(reservoir: u64) -> PrecompileResult { - Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - reservoir, - )) +/// Executes a call within the supplied storage context, enforcing call rules and gas accounting. +fn execute_with_storage( + storage: &mut S, + call: ZoneCall<'_>, + fixed_gas: Option, + rules: &impl CallRules, + execute: &impl Fn(&[u8], Address) -> PrecompileResult, +) -> PrecompileResult { + StorageCtx::enter(storage, || { + let mut result = match rules.check_call(call) { + CallCheck::Continue => execute(call.data, call.caller), + CallCheck::Return(result) => add_input_cost(call.data, result), + }; + if let (Ok(output), Some(gas)) = (&mut result, fixed_gas) { + output.gas_used = gas; + } + result + }) } -fn add_input_cost(calldata: &[u8], result: PrecompileResult) -> PrecompileResult { +fn add_input_cost(calldata: &[u8], mut result: PrecompileResult) -> PrecompileResult { let mut storage = StorageCtx::default(); let gas_before = storage.gas_used(); if let Some(err) = charge_input_cost(&mut storage, calldata) { return err; } - let input_gas = storage.gas_used().saturating_sub(gas_before); - result.map(|mut output| { + if let Ok(output) = &mut result { + let input_gas = storage.gas_used().saturating_sub(gas_before); output.gas_used = output.gas_used.saturating_add(input_gas); - output - }) -} - -fn apply_fixed_gas(result: PrecompileResult, fixed_gas: Option) -> PrecompileResult { - match (result, fixed_gas) { - (Ok(mut output), Some(gas)) => { - output.gas_used = gas; - Ok(output) - } - (result, _) => result, } + result } #[cfg(test)] @@ -340,7 +324,7 @@ mod tests { fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { *self.0.borrow_mut() = Some(( Bytes::copy_from_slice(call.data), - call.selector, + call.selector(), call.caller, )); CallCheck::Continue From 8ab6ae33bc623357138726421ac719b4efadb013 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 16:12:55 +0200 Subject: [PATCH 11/30] fix: run CallRule checks before execution --- crates/precompiles/src/execution.rs | 81 ++++++++++++++++++----------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index 1de91c823..a7d4cad9f 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -13,11 +13,9 @@ //! //! 1. L1-backed execution rejects delegate calls before storage access. //! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. -//! 3. Install the appropriate storage provider. L1-backed calls read their anchor once and fail -//! closed if the anchor, hardfork, or overlay cannot be resolved. -//! 4. Apply [`CallRules`], including any local direct-call rule. Admitted calls receive the original -//! calldata and caller; rejected calls are returned without invoking the implementation. -//! 5. Apply the configured fixed gas charge to successful precompile results. +//! 3. Apply [`CallRules`] against local EVM storage. Rejected calls return without touching L1. +//! 4. For admitted L1-backed calls, resolve the anchor, hardfork, and storage overlay. +//! 5. Forward the original calldata and caller, applying any configured fixed gas charge. //! //! Rule-level rejections include calldata input gas. Calls without a fixed charge retain normal //! provider metering, while successful fixed-price calls report exactly the configured charge. @@ -121,8 +119,9 @@ pub(crate) enum CallCheck { /// Selector- and caller-dependent pre-execution rules for a storage-backed precompile. /// -/// Checks receive [`ZoneCall`] because they run inside [`StorageCtx`], after the input's EVM -/// internals have moved into the storage provider. +/// Checks receive [`ZoneCall`] because they run inside a local [`StorageCtx`], after the input's EVM +/// internals have moved into the storage provider. They run before any anchored L1 provider is +/// constructed and therefore must not depend on the L1 storage overlay. pub(crate) trait CallRules: 'static { /// Return the fixed gas charge for this selector, if one applies. fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { @@ -186,7 +185,17 @@ pub(crate) fn create_local_precompile( gas_params.clone(), ); - execute_with_storage(&mut storage, call, fixed_gas, &rules, &execute) + if let Some(check_result) = + StorageCtx::enter(&mut storage, || match rules.check_call(call) { + CallCheck::Continue => None, + CallCheck::Return(result) => Some(add_input_cost(call.data, result)), + }) + { + return apply_fixed_gas(check_result, fixed_gas); + } + + let exec_result = StorageCtx::enter(&mut storage, || execute(call.data, call.caller)); + apply_fixed_gas(exec_result, fixed_gas) }) } @@ -243,42 +252,36 @@ pub(crate) fn create_l1_backed_precompile( .with_actions(actions.clone()) .with_non_creditable_slots(non_creditable_slots.clone()); + if let Some(check_result) = StorageCtx::enter(&mut inner, || match rules.check_call(call) { + CallCheck::Continue => None, + CallCheck::Return(result) => Some(add_input_cost(call.data, result)), + }) { + return apply_fixed_gas(check_result, fixed_gas); + } + let l1_block_number = match read_l1_anchor(&mut inner) { Ok(block_number) => block_number, Err(err) => { return err.into_precompile_result(inner.gas_used(), inner.reservoir()); } }; - let gas_used = inner.gas_used(); - let reservoir = inner.reservoir(); + let (gas_used, reservoir) = (inner.gas_used(), inner.reservoir()); let mut storage = match ZonePrecompileStorageProvider::new(inner, l1_reader.clone(), l1_block_number) { Ok(storage) => storage, Err(err) => return err.into_precompile_result(gas_used, reservoir), }; - execute_with_storage(&mut storage, call, fixed_gas, &rules, &execute) + let exec_result = StorageCtx::enter(&mut storage, || execute(call.data, call.caller)); + apply_fixed_gas(exec_result, fixed_gas) }) } -/// Executes a call within the supplied storage context, enforcing call rules and gas accounting. -fn execute_with_storage( - storage: &mut S, - call: ZoneCall<'_>, - fixed_gas: Option, - rules: &impl CallRules, - execute: &impl Fn(&[u8], Address) -> PrecompileResult, -) -> PrecompileResult { - StorageCtx::enter(storage, || { - let mut result = match rules.check_call(call) { - CallCheck::Continue => execute(call.data, call.caller), - CallCheck::Return(result) => add_input_cost(call.data, result), - }; - if let (Ok(output), Some(gas)) = (&mut result, fixed_gas) { - output.gas_used = gas; - } - result - }) +fn apply_fixed_gas(mut result: PrecompileResult, fixed_gas: Option) -> PrecompileResult { + if let (Ok(output), Some(gas)) = (&mut result, fixed_gas) { + output.gas_used = gas; + } + result } fn add_input_cost(calldata: &[u8], mut result: PrecompileResult) -> PrecompileResult { @@ -389,7 +392,7 @@ mod tests { } #[test] - fn l1_backed_execution_uses_the_anchor_for_its_fallible_provider() { + fn l1_backed_admission_precedes_anchored_provider() { let anchor = 42; let reader = MockL1Reader::default(); let observed_spec = Rc::new(Cell::new(None)); @@ -401,12 +404,28 @@ mod tests { StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), ); + let checked = Rc::new(Cell::new(false)); + let rejected = create_l1_backed_precompile( + "L1AdmissionTest", + env.clone(), + RejectRules(checked.clone()), + |_, _| panic!("rejected call must not execute"), + ); + let mut ctx = test_context(); + assert!( + rejected + .call(input(&mut ctx, &[1, 2, 3, 4], Address::ZERO, FIXED_GAS)) + .unwrap() + .is_revert() + ); + assert!(checked.get()); + assert!(reader.hardfork_requests().is_empty()); + let precompile = create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { execute_spec.set(Some(StorageCtx::default().spec())); Ok(StorageCtx::default().success_output(Bytes::new())) }); - let mut ctx = test_context(); test_storage_provider(&mut ctx, u64::MAX, false) .sstore( tempo_zone_contracts::TEMPO_STATE_ADDRESS, From 5e6bb7145893b37b26bb8878743d99e6bc655fd0 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 17:27:15 +0200 Subject: [PATCH 12/30] feat: make `CallRules` future-proof --- crates/precompiles/src/execution.rs | 43 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index a7d4cad9f..d8c1464d7 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -73,7 +73,7 @@ impl

L1BackedPrecompileEnv

{ /// Provider-free precompiles can inspect `PrecompileInput` directly. /// /// **MOTIVATION:** Execution helpers move `PrecompileInput::internals` into the -/// [`EvmPrecompileStorageProvider`] before calling [`CallRules::check_call`]. The full input +/// [`EvmPrecompileStorageProvider`] before calling [`CallRules`]'s checks. The full input /// cannot be borrowed after that partial move, so [`ZoneCall`] carries only the metadata /// needed by [`CallRules`]. #[derive(Debug, Clone, Copy)] @@ -118,18 +118,19 @@ pub(crate) enum CallCheck { } /// Selector- and caller-dependent pre-execution rules for a storage-backed precompile. -/// -/// Checks receive [`ZoneCall`] because they run inside a local [`StorageCtx`], after the input's EVM -/// internals have moved into the storage provider. They run before any anchored L1 provider is -/// constructed and therefore must not depend on the L1 storage overlay. pub(crate) trait CallRules: 'static { /// Return the fixed gas charge for this selector, if one applies. fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { None } - /// Decide whether execution may proceed to the forwarded implementation. - fn check_call(&self, _call: ZoneCall<'_>) -> CallCheck { + /// Runs checks that only depend on ordinary zone-local state. Evaluated before any L1 access. + fn check_with_local_state(&self, _call: ZoneCall<'_>) -> CallCheck { + CallCheck::Continue + } + + /// Runs checks that depend on the finalized Tempo L1-backed state overlay. + fn check_with_l1_backed_state(&self, _call: ZoneCall<'_>) -> CallCheck { CallCheck::Continue } } @@ -141,7 +142,7 @@ impl CallRules for NoCallRules {} /// Rules for precompiles whose semantics require execution at their registered address. pub(crate) struct DirectCallOnly; impl CallRules for DirectCallOnly { - fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { if call.is_direct { CallCheck::Continue } else { @@ -186,7 +187,7 @@ pub(crate) fn create_local_precompile( ); if let Some(check_result) = - StorageCtx::enter(&mut storage, || match rules.check_call(call) { + StorageCtx::enter(&mut storage, || match rules.check_with_local_state(call) { CallCheck::Continue => None, CallCheck::Return(result) => Some(add_input_cost(call.data, result)), }) @@ -252,11 +253,12 @@ pub(crate) fn create_l1_backed_precompile( .with_actions(actions.clone()) .with_non_creditable_slots(non_creditable_slots.clone()); - if let Some(check_result) = StorageCtx::enter(&mut inner, || match rules.check_call(call) { - CallCheck::Continue => None, - CallCheck::Return(result) => Some(add_input_cost(call.data, result)), - }) { - return apply_fixed_gas(check_result, fixed_gas); + match StorageCtx::enter(&mut inner, || rules.check_with_local_state(call)) { + CallCheck::Continue => {} + CallCheck::Return(result) => { + let result = StorageCtx::enter(&mut inner, || add_input_cost(call.data, result)); + return apply_fixed_gas(result, fixed_gas); + } } let l1_block_number = match read_l1_anchor(&mut inner) { @@ -272,6 +274,15 @@ pub(crate) fn create_l1_backed_precompile( Err(err) => return err.into_precompile_result(gas_used, reservoir), }; + if let Some(check_result) = StorageCtx::enter(&mut storage, || { + match rules.check_with_l1_backed_state(call) { + CallCheck::Continue => None, + CallCheck::Return(result) => Some(add_input_cost(call.data, result)), + } + }) { + return apply_fixed_gas(check_result, fixed_gas); + } + let exec_result = StorageCtx::enter(&mut storage, || execute(call.data, call.caller)); apply_fixed_gas(exec_result, fixed_gas) }) @@ -324,7 +335,7 @@ mod tests { Some(FIXED_GAS) } - fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { *self.0.borrow_mut() = Some(( Bytes::copy_from_slice(call.data), call.selector(), @@ -449,7 +460,7 @@ mod tests { Some(FIXED_GAS) } - fn check_call(&self, _call: ZoneCall<'_>) -> CallCheck { + fn check_with_local_state(&self, _call: ZoneCall<'_>) -> CallCheck { self.0.set(true); CallCheck::Return(Ok( StorageCtx::default().revert_output(Bytes::from_static(b"denied")) From a6e898d8bf8a644ebd1626a9262c2488bade3583 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 08:53:33 +0200 Subject: [PATCH 13/30] refactor(precompiles): initialize storage from L1 anchor --- crates/precompiles/src/execution.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index d8c1464d7..45aaa3e2d 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -31,14 +31,11 @@ use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{ DelegateCallNotAllowed, charge_input_cost, dispatch::selector_from_calldata, - storage::{ - PrecompileStorageProvider, StorageCtx, actions::StorageActions, - evm::EvmPrecompileStorageProvider, - }, + storage::{StorageCtx, actions::StorageActions, evm::EvmPrecompileStorageProvider}, storage_credits::NonCreditableSlots, }; -use crate::storage::{L1StorageReader, ZonePrecompileStorageProvider, read_l1_anchor}; +use crate::storage::{L1StorageReader, ZonePrecompileStorageProvider}; /// Shared inputs for precompiles executing over finalized Tempo state. /// @@ -261,18 +258,10 @@ pub(crate) fn create_l1_backed_precompile( } } - let l1_block_number = match read_l1_anchor(&mut inner) { - Ok(block_number) => block_number, - Err(err) => { - return err.into_precompile_result(inner.gas_used(), inner.reservoir()); - } + let mut storage = match ZonePrecompileStorageProvider::try_new(inner, l1_reader.clone()) { + Ok(storage) => storage, + Err(err) => return err.into_precompile_result(), }; - let (gas_used, reservoir) = (inner.gas_used(), inner.reservoir()); - let mut storage = - match ZonePrecompileStorageProvider::new(inner, l1_reader.clone(), l1_block_number) { - Ok(storage) => storage, - Err(err) => return err.into_precompile_result(gas_used, reservoir), - }; if let Some(check_result) = StorageCtx::enter(&mut storage, || { match rules.check_with_l1_backed_state(call) { @@ -324,6 +313,7 @@ mod tests { cell::{Cell, RefCell}, rc::Rc, }; + use tempo_precompiles::storage::PrecompileStorageProvider; const FIXED_GAS: u64 = 123; type RuleRecord = Rc, Address)>>>; @@ -408,7 +398,8 @@ mod tests { let reader = MockL1Reader::default(); let observed_spec = Rc::new(Cell::new(None)); let execute_spec = observed_spec.clone(); - let cfg = revm::context::CfgEnv::::default(); + let mut cfg = revm::context::CfgEnv::::default(); + cfg.spec = TempoHardfork::T8; let env = L1BackedPrecompileEnv::new( &cfg, reader.clone(), @@ -430,7 +421,6 @@ mod tests { .is_revert() ); assert!(checked.get()); - assert!(reader.hardfork_requests().is_empty()); let precompile = create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { @@ -449,7 +439,6 @@ mod tests { .call(input(&mut ctx, &[], Address::ZERO, u64::MAX)) .unwrap(); - assert_eq!(reader.hardfork_requests(), vec![anchor]); assert_eq!(observed_spec.get(), Some(TempoHardfork::T8)); } From a545039f671c8a0b3bd0184d0517ea9e71b18f8d Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 12:44:34 +0200 Subject: [PATCH 14/30] docs(precompiles): clarify CallRules execution phases --- crates/precompiles/src/execution.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index 45aaa3e2d..f3f526c9f 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -13,8 +13,9 @@ //! //! 1. L1-backed execution rejects delegate calls before storage access. //! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. -//! 3. Apply [`CallRules`] against local EVM storage. Rejected calls return without touching L1. -//! 4. For admitted L1-backed calls, resolve the anchor, hardfork, and storage overlay. +//! 3. Apply the local phase of [`CallRules`]. Rejected calls return without touching L1. +//! 4. For admitted L1-backed calls, resolve the anchor, hardfork, and storage overlay, then apply +//! the L1-backed rules phase. //! 5. Forward the original calldata and caller, applying any configured fixed gas charge. //! //! Rule-level rejections include calldata input gas. Calls without a fixed charge retain normal @@ -114,19 +115,31 @@ pub(crate) enum CallCheck { Return(PrecompileResult), } -/// Selector- and caller-dependent pre-execution rules for a storage-backed precompile. +/// Selector-, caller-, and call-context-dependent rules evaluated by centralized precompile +/// execution before invoking the implementation. +/// +/// The local phase runs against ordinary zone state before any optional finalized-L1 resolution. +/// L1-backed execution then runs a second phase against the exact anchored overlay. Rules may +/// enforce admission policy and duplicate cheap business checks as fail-fast preflight, but the +/// precompile implementation remains responsible for its canonical business invariants. pub(crate) trait CallRules: 'static { /// Return the fixed gas charge for this selector, if one applies. fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { None } - /// Runs checks that only depend on ordinary zone-local state. Evaluated before any L1 access. + /// Apply rules using only calldata, caller, call context, and ordinary zone-local state. + /// + /// This phase always runs before optional L1 anchor resolution. It may reject invalid calls + /// early so they do not depend on L1 or RPC availability. fn check_with_local_state(&self, _call: ZoneCall<'_>) -> CallCheck { CallCheck::Continue } - /// Runs checks that depend on the finalized Tempo L1-backed state overlay. + /// Apply rules whose answer must come from the finalized Tempo L1-backed storage overlay. + /// + /// This phase runs only for L1-backed execution, after the anchor and overlay have been + /// resolved. fn check_with_l1_backed_state(&self, _call: ZoneCall<'_>) -> CallCheck { CallCheck::Continue } From fa262c476fb10890352df3cbfb2978b9b50e7bc1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 09:20:57 +0200 Subject: [PATCH 15/30] feat(precompiles): drop 403 and ZTIP20 proxy impl --- Cargo.lock | 1 + crates/evm/src/lib.rs | 21 +- crates/l1/src/state/tip403/provider.rs | 60 -- crates/node/README.md | 59 +- crates/node/src/node.rs | 22 +- crates/node/tests/it/tip403_policy.rs | 214 +++--- crates/node/tests/it/utils.rs | 188 ++++- crates/precompiles/Cargo.toml | 1 + crates/precompiles/src/lib.rs | 91 ++- crates/precompiles/src/policy.rs | 45 -- crates/precompiles/src/tempo_state.rs | 27 +- crates/precompiles/src/test_utils.rs | 156 +++- .../precompiles/src/tip403_proxy/dispatch.rs | 213 ------ crates/precompiles/src/tip403_proxy/mod.rs | 386 ++++++++-- crates/precompiles/src/ztip20/dispatch.rs | 229 ------ crates/precompiles/src/ztip20/mod.rs | 719 ++++++++---------- docs/ZONES.md | 4 +- specs/spec.md | 6 +- 18 files changed, 1162 insertions(+), 1280 deletions(-) delete mode 100644 crates/precompiles/src/policy.rs delete mode 100644 crates/precompiles/src/tip403_proxy/dispatch.rs delete mode 100644 crates/precompiles/src/ztip20/dispatch.rs diff --git a/Cargo.lock b/Cargo.lock index 42752ab4a..c7983ebfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13614,6 +13614,7 @@ dependencies = [ "alloy-sol-types", "const-hex", "derive_more", + "eyre", "k256", "paste", "rand 0.8.7", diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 7206f841f..09e5f8f43 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -49,7 +49,7 @@ use tempo_primitives::{ }; use tempo_zone_contracts::ZONE_TX_CONTEXT_ADDRESS; use zone_chainspec::ZoneChainSpec; -use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig, PolicyProvider}; +use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; type TempoCtx = ::Context; @@ -58,22 +58,12 @@ type TempoCtx = ::Context; #[derive(Debug, Clone)] pub struct ZoneEvmFactory { l1_provider: L1StateProvider, - policy_provider: Option, } impl ZoneEvmFactory { /// Create a new factory with the given L1 state provider. pub fn new(l1_provider: L1StateProvider) -> Self { - Self { - l1_provider, - policy_provider: None, - } - } - - /// Set the policy provider for the TIP-403 proxy precompile. - pub fn with_policy_provider(mut self, policy_provider: PolicyProvider) -> Self { - self.policy_provider = Some(policy_provider); - self + Self { l1_provider } } fn register_precompiles>>( @@ -87,7 +77,6 @@ impl ZoneEvmFactory { precompiles, &cfg, self.l1_provider.clone(), - self.policy_provider.clone(), sequencer, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), @@ -236,12 +225,6 @@ impl ZoneEvmConfig { Self::from_chain_spec(chain_spec, l1_provider) } - /// Set the policy provider for the TIP-403 proxy precompile. - pub fn with_policy_provider(mut self, policy_provider: PolicyProvider) -> Self { - self.zone_factory = self.zone_factory.with_policy_provider(policy_provider); - self - } - /// Returns the Zone chain specification. pub fn chain_spec(&self) -> &Arc { &self.chain_spec diff --git a/crates/l1/src/state/tip403/provider.rs b/crates/l1/src/state/tip403/provider.rs index ac9d980e4..a3a435f0a 100644 --- a/crates/l1/src/state/tip403/provider.rs +++ b/crates/l1/src/state/tip403/provider.rs @@ -12,13 +12,11 @@ use alloy_primitives::Address; use alloy_provider::DynProvider; use alloy_rpc_types_eth::BlockId; use eyre::Result; -use revm::precompile::PrecompileError; use tempo_alloy::TempoNetwork; use tempo_contracts::precompiles::{ ITIP20, ITIP403Registry, ITIP403Registry::PolicyType, TIP403_REGISTRY_ADDRESS, }; use tracing::{debug, info, warn}; -use zone_precompiles::policy::PolicyCheck; use super::builtin_authorization; @@ -605,61 +603,3 @@ impl PolicyProvider { Ok(authorized) } } - -impl PolicyCheck for PolicyProvider { - fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result { - self.is_authorized_by_policy(policy_id, user, role) - .map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "auth check failed for policy {policy_id} user {user}: {e}" - )) - }) - } - - fn resolve_transfer_policy_id(&self, token: Address) -> Result { - Self::resolve_transfer_policy_id(self, token).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "failed to resolve transfer_policy_id for {token}: {e}" - )) - }) - } - - fn policy_type_sync(&self, policy_id: u64) -> Result { - self.resolve_policy_type_sync(policy_id).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "policyData failed for policy {policy_id}: {e}" - )) - }) - } - - fn compound_policy_data(&self, policy_id: u64) -> Result<(u64, u64, u64), PrecompileError> { - let compound = self.resolve_compound_data_sync(policy_id).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "compoundPolicyData failed for policy {policy_id}: {e}" - )) - })?; - Ok(( - compound.sender_policy_id, - compound.recipient_policy_id, - compound.mint_recipient_policy_id, - )) - } - - fn policy_exists(&self, policy_id: u64) -> Result { - self.policy_exists_sync(policy_id).map_err(|e| { - zone_precompiles::zone_rpc_error(format!( - "policyExists failed for policy {policy_id}: {e}" - )) - }) - } - - fn policy_id_counter(&self) -> u64 { - let cache = self.cache.read(); - cache.policies().keys().max().map_or(2, |max| max + 1) - } -} diff --git a/crates/node/README.md b/crates/node/README.md index 574ff4f3e..1049f67b2 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -19,11 +19,11 @@ graph TD L1Sub["L1Subscriber
WebSocket + backfill"] DQ["DepositQueue"] Cache["L1StateCache"] - PolicyCache["PolicyCache"] + PolicyCache["PolicyCache
encrypted deposits only"] Engine["ZoneEngine"] Builder["PayloadBuilder
advanceTempo + pool txs"] - PolicyPrefetch["PolicyResolutionTask
pool pre-warm"] + PolicyPrefetch["PolicyResolutionTask
legacy cache pre-warm"] Monitor["ZoneMonitor"] Batch["BatchSubmitter"] @@ -124,26 +124,29 @@ header chain linking back to the target block. ## TIP-403 Policy Enforcement The zone enforces TIP-403 transfer policies (whitelist, blacklist, compound) -identically to L1. Policy state is mirrored via: - -1. **L1Subscriber** — extracts `PolicyCreated`, `WhitelistUpdated`, - `BlacklistUpdated`, `CompoundPolicyCreated`, and `TransferPolicyUpdate` - events from L1 block receipts (via `eth_getBlockReceipts`) and applies - them to the in-memory `PolicyCache`. -2. **PolicyProvider** — cache-first, RPC-fallback resolution. On cache miss - it queries L1 via `block_in_place` and populates the cache for subsequent - lookups. -3. **ZoneTip403ProxyRegistry** — a read-only precompile at the same address - as the L1 `TIP403Registry` (`0x403C…0000`). It intercepts `isAuthorized`, - `policyData`, `compoundPolicyData`, etc. and serves them from the - `PolicyProvider`. Mutating calls are reverted. -4. **Pool pre-fetching** — the `PolicyResolutionTask` pre-warms the cache for - pending pool transactions so payload building doesn't block on RPC. - -The payload builder checks sender/recipient authorization during -`advanceTempo` deposit processing. Encrypted deposits that fail policy checks -are included with a zeroed-out amount (the deposit hash chain must still -match L1). +using Tempo's upstream policy implementation over anchored raw L1 state: + +1. **L1Subscriber** — observes L1 block receipts (via + `eth_getBlockReceipts`) and conservatively invalidates raw state for the + TIP-403 registry and tracked TIP-20 tokens when their logs indicate a + possible mutation. +2. **L1StateProvider** — resolves storage slots at an explicit L1 block through + the block-versioned `L1StateCache`, falling back to an exact-block L1 RPC + read and caching the result. Mutation barriers and reorg resets prevent + stale values from being inherited across state changes. +3. **ZonePrecompileStorageProvider** — composes ordinary zone EVM storage with + the raw L1 reader at the exact finalized block recorded in `TempoState`. + TIP-403 registry state and each token's L1-owned transfer-policy field come + from L1; balances and all other TIP-20 state remain zone-local. Mirrored + reads retain normal EVM gas, warming, and storage accounting, while + persistent writes to L1-owned slots are rejected. +4. **Tempo TIP-20 and TIP-403 precompiles** — execute the upstream business + logic against that composed storage view, replacing the zone's duplicated + policy dispatch. Missing or invalid anchored state fails closed, and zone + privacy, bridge authorization, admission, and fixed-gas rules remain in the + surrounding execution layer. + +> NOTE: encrypted-deposit checks temporarily retain the legacy `PolicyProvider` and `PolicyCache`; they will move to the same anchored raw-state path before that pipeline is removed. Encrypted deposits that fail policy checks are included with a zeroed-out amount so the deposit hash chain still matches L1. ## Demo: Token Creation with Transfer Policy @@ -228,8 +231,8 @@ just send-deposit 1000000 "" $TOKEN ### 7. Test enforcement on the zone -Transfers to blacklisted addresses will be rejected by the zone's -`ZoneTip403ProxyRegistry` precompile, which mirrors L1 policy state. +Transfers to blacklisted addresses will be rejected by upstream TIP-20 policy +logic reading raw L1 policy state at the zone's finalized Tempo anchor. ```bash # Check balance on zone @@ -265,7 +268,7 @@ just set-transfer-policy $TOKEN | `0x1C00…0100` | `ChaumPedersenVerify` | Verify DLOG equality proofs for ECDH | | `0x1C00…0101` | `AesGcmDecrypt` | AES-256-GCM authenticated decryption | | `0x20FC…0000` | `ZoneTokenFactory` | Initialize TIP-20 tokens on the zone | -| `0x403C…0000` | `ZoneTip403ProxyRegistry` | Read-only proxy mirroring L1 TIP-403 policy state | +| `0x403C…0000` | `TIP403Registry` | Upstream read-only execution over exact-block raw L1 policy state | ## EVM Configuration @@ -275,9 +278,9 @@ L1 with these differences: - The **TIP20Factory** precompile is replaced by `ZoneTokenFactory`, which only supports `enableToken` (no `createToken`) since zone tokens are always bridged from L1. -- The **TIP403Registry** precompile is replaced by `ZoneTip403ProxyRegistry`, - a storage-less read-only proxy that resolves authorization from the in-memory - policy cache rather than on-chain storage. +- The **TIP403Registry** uses upstream Tempo execution through a read-only + storage overlay anchored at the finalized L1 block recorded in `TempoState`. + Policy mutations revert because registry state is owned by L1. - The **block executor** is simplified: no subblock ordering, shared-gas accounting, or end-of-block metadata system transactions — those are L1-only concerns. diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 773b4de6e..9860b84a8 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -870,7 +870,6 @@ where let executor_builder = ZoneExecutorBuilder::new( self.l1_state_provider_config.clone(), self.l1_state_cache.clone(), - self.policy_cache.clone(), ); let mut payload_factory = ZonePayloadFactory::new(self.withdrawal_batch_interval_blocks); if let Some(encryptor) = self.withdrawal_reveal_encryptor.clone() { @@ -935,20 +934,17 @@ impl PayloadAttributesBuilder for ZonePayloa pub struct ZoneExecutorBuilder { l1_state_provider_config: L1StateProviderConfig, l1_state_cache: L1StateCache, - policy_cache: PolicyCache, } impl ZoneExecutorBuilder { - /// Create a zone executor builder with the shared L1 state/policy caches. + /// Create a zone executor builder with the shared L1 state cache. pub fn new( l1_state_provider_config: L1StateProviderConfig, l1_state_cache: L1StateCache, - policy_cache: PolicyCache, ) -> Self { Self { l1_state_provider_config, l1_state_cache, - policy_cache, } } } @@ -972,20 +968,8 @@ where let tempo_chain_spec = tempo_chain_spec_for_l1(l1_chain_id) .ok_or_else(|| eyre::eyre!("unsupported parent Tempo chain ID {l1_chain_id}"))?; // Keep the Zone chain settings and use the parent L1 schedule for Tempo hardforks. - let mut evm_config = ZoneEvmConfig::new(ctx.chain_spec(), tempo_chain_spec, l1_provider); - - // Create PolicyProvider for the TIP-403 proxy precompile. - let policy_l1 = alloy_provider::ProviderBuilder::new_with_network::() - .connect_with_config( - &self.l1_state_provider_config.l1_rpc_url, - rpc_connection_config(self.l1_state_provider_config.retry_connection_interval), - ) - .await? - .erased(); - - let policy_provider = PolicyProvider::new(self.policy_cache, policy_l1, runtime_handle); - evm_config = evm_config.with_policy_provider(policy_provider); - info!(target: "reth::cli", "Zone EVM initialized with TempoState + TIP-403 proxy precompiles"); + let evm_config = ZoneEvmConfig::new(ctx.chain_spec(), tempo_chain_spec, l1_provider); + info!(target: "reth::cli", "Zone EVM initialized with L1-backed Tempo precompiles"); Ok(evm_config) } diff --git a/crates/node/tests/it/tip403_policy.rs b/crates/node/tests/it/tip403_policy.rs index 4a7ef719f..0aaa37536 100644 --- a/crates/node/tests/it/tip403_policy.rs +++ b/crates/node/tests/it/tip403_policy.rs @@ -1,18 +1,24 @@ //! E2E tests for the TIP-403 policy proxy precompile on the zone. //! -//! These tests verify that the `ZoneTip403ProxyRegistry` precompile correctly -//! serves authorization queries from the `PolicyCache` and rejects -//! mutating calls. The cache is populated directly in tests (no L1 subscriber). +//! These tests verify that the zone TIP-403 precompile correctly serves authorization queries from +//! finalized raw L1 storage via `L1StateCache` and rejects mutating calls. The cache is populated +//! directly in tests (no L1 subscriber). use alloy::primitives::{U256, address}; use alloy_provider::ProviderBuilder; use alloy_signer_local::{MnemonicBuilder, coins_bip39::English}; use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; -use tempo_contracts::precompiles::{ITIP20, ITIP403Registry}; +use tempo_contracts::precompiles::{ + ITIP20, + ITIP403Registry::{self, PolicyType}, +}; use tempo_precompiles::{PATH_USD_ADDRESS, TIP403_REGISTRY_ADDRESS}; -use zone_l1::state::tip403::{CompoundData, PolicyEvent}; +use zone_l1::state::tip403::PolicyEvent; -use crate::utils::{DEFAULT_TIMEOUT, TEST_MNEMONIC, TIP20_TX_GAS, start_local_zone_with_fixture}; +use crate::utils::{ + DEFAULT_TIMEOUT, PolicySeed, TEST_MNEMONIC, TIP20_TX_GAS, seed_raw_tip403_policy, + start_local_zone_with_fixture, +}; /// Deposit pathUSD to Alice, then transfer a portion to Bob on the zone. /// @@ -58,7 +64,10 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { .send() .await?; - // Inject an empty L1 block to trigger block production including the pool tx + // T6+ transfers also consult the recipient's address-level receive policy on L1. + fixture.seed_no_receive_policy(bob); + + // Inject an empty L1 block to trigger block production including the pool tx. fixture.inject_empty_block(zone.deposit_queue()); let receipt = pending.get_receipt().await?; @@ -100,14 +109,15 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate the cache: policy 5 = WHITELIST, Alice is in the set, Bob is not - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::WHITELIST); - w.set_policy_status(5, alice, 1, true); - w.set_policy_status(5, bob, 1, false); - } + // Populate raw L1 state: policy 5 = WHITELIST, Alice is in the set, Bob is not. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::WHITELIST, &[(alice, true)]), + PolicySeed::simple(5, PolicyType::WHITELIST, &[(bob, false)]), + ], + )?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -130,7 +140,7 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { let data = registry.policyData(5).call().await?; assert_eq!( data.policyType, - ITIP403Registry::PolicyType::WHITELIST, + PolicyType::WHITELIST, "policy 5 should be WHITELIST" ); @@ -150,14 +160,15 @@ async fn test_policy_proxy_blacklist_authorization() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate the cache: policy 5 = BLACKLIST, Alice is blacklisted, Bob is not - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::BLACKLIST); - w.set_policy_status(5, alice, 1, true); - w.set_policy_status(5, bob, 1, false); - } + // Populate raw L1 state: policy 5 = BLACKLIST, Alice is blacklisted, Bob is not. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::BLACKLIST, &[(alice, true)]), + PolicySeed::simple(5, PolicyType::BLACKLIST, &[(bob, false)]), + ], + )?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -188,24 +199,17 @@ async fn test_policy_proxy_compound_policy() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Policy 5 = sender whitelist, policy 6 = recipient blacklist - // Compound policy 10 references them - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::WHITELIST); - w.set_policy_status(5, alice, 1, true); // Alice whitelisted as sender - w.set_policy_type(6, ITIP403Registry::PolicyType::BLACKLIST); - w.set_policy_status(6, bob, 1, true); // Bob blacklisted as recipient - w.set_compound( - 10, - CompoundData { - sender_policy_id: 5, - recipient_policy_id: 6, - mint_recipient_policy_id: 1, // builtin allow - }, - ); - } + // Policy 5 = sender whitelist, policy 6 = recipient blacklist; compound policy 10 + // references them. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::WHITELIST, &[(alice, true)]), + PolicySeed::simple(6, PolicyType::BLACKLIST, &[(bob, true)]), + PolicySeed::compound(10, 5, 6, 1), + ], + )?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -259,11 +263,11 @@ async fn test_policy_proxy_builtin_policies() -> eyre::Result<()> { // Policy 0 = WHITELIST semantics (empty whitelist = reject all) let data0 = registry.policyData(0).call().await?; - assert_eq!(data0.policyType, ITIP403Registry::PolicyType::WHITELIST); + assert_eq!(data0.policyType, PolicyType::WHITELIST); // Policy 1 = BLACKLIST semantics (empty blacklist = allow all) let data1 = registry.policyData(1).call().await?; - assert_eq!(data1.policyType, ITIP403Registry::PolicyType::BLACKLIST); + assert_eq!(data1.policyType, PolicyType::BLACKLIST); Ok(()) } @@ -284,7 +288,7 @@ async fn test_policy_proxy_reverts_mutating_calls() -> eyre::Result<()> { let result = registry .createPolicy( address!("0x0000000000000000000000000000000000000001"), - ITIP403Registry::PolicyType::WHITELIST, + PolicyType::WHITELIST, ) .call() .await; @@ -308,26 +312,17 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> let bob = address!("0x0000000000000000000000000000000000000B0B"); let carol = address!("0x000000000000000000000000000000000000CA01"); - // Policy 5 = sender whitelist, policy 6 = recipient blacklist - // Compound policy 10 references them - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_type(5, ITIP403Registry::PolicyType::WHITELIST); - w.set_policy_status(5, alice, 1, true); // Alice whitelisted as sender - w.set_policy_status(5, bob, 1, false); // Bob not whitelisted as sender - w.set_policy_type(6, ITIP403Registry::PolicyType::BLACKLIST); - w.set_policy_status(6, alice, 1, false); // Alice not blacklisted as recipient - w.set_policy_status(6, bob, 1, true); // Bob blacklisted as recipient - w.set_compound( - 10, - CompoundData { - sender_policy_id: 5, - recipient_policy_id: 6, - mint_recipient_policy_id: 1, // builtin allow - }, - ); - } + // Policy 5 = sender whitelist, policy 6 = recipient blacklist; compound policy 10 + // references them. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::WHITELIST, &[(alice, true), (bob, false)]), + PolicySeed::simple(6, PolicyType::BLACKLIST, &[(alice, false), (bob, true)]), + PolicySeed::compound(10, 5, 6, 1), + ], + )?; let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); @@ -345,13 +340,15 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> "bob should NOT be authorized (not in sender whitelist)" ); - // Carol: whitelisted as sender AND in recipient blacklist - { - let cache = zone.policy_cache(); - let mut w = cache.write(); - w.set_policy_status(5, carol, 1, true); // whitelisted as sender - w.set_policy_status(6, carol, 1, true); // blacklisted as recipient - } + // Carol: whitelisted as sender AND in recipient blacklist. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[ + PolicySeed::simple(5, PolicyType::WHITELIST, &[(carol, true)]), + PolicySeed::simple(6, PolicyType::BLACKLIST, &[(carol, true)]), + ], + )?; // Carol passes sender check but fails recipient → false let carol_auth = registry.isAuthorized(10, carol).call().await?; @@ -363,9 +360,9 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> Ok(()) } -/// Applying a sequence of `PolicyEvent`s correctly updates the proxy's responses. +/// Block-versioned raw L1 policy writes update the proxy's responses. #[tokio::test(flavor = "multi_thread")] -async fn test_policy_type_change_via_events() -> eyre::Result<()> { +async fn test_policy_proxy_uses_block_versioned_raw_state() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; @@ -376,59 +373,42 @@ async fn test_policy_type_change_via_events() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); - // Step 1: Create policy 5 as WHITELIST via event, add Alice - { - let cache = zone.policy_cache(); - cache.write().apply_events( - 1, - &[ - PolicyEvent::PolicyCreated { - policy_id: 5, - policy_type: ITIP403Registry::PolicyType::WHITELIST, - }, - PolicyEvent::MembershipChanged { - policy_id: 5, - account: alice, - in_set: true, - }, - ], - ); - } + // Step 1: Create policy 5 as WHITELIST and add Alice at block 1. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 1, + &[PolicySeed::simple( + 5, + PolicyType::WHITELIST, + &[(alice, true)], + )], + )?; // Alice should be authorized (whitelisted) let authorized = registry.isAuthorized(5, alice).call().await?; assert!(authorized, "alice should be authorized (whitelisted)"); - // Step 2: Remove Alice via event at block 2 - { - let cache = zone.policy_cache(); - cache.write().apply_events( - 2, - &[PolicyEvent::MembershipChanged { - policy_id: 5, - account: alice, - in_set: false, - }], - ); - } + // Step 2: Remove Alice at block 2. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 2, + &[PolicySeed::simple( + 5, + PolicyType::WHITELIST, + &[(alice, false)], + )], + )?; // Alice should no longer be authorized let authorized = registry.isAuthorized(5, alice).call().await?; assert!(!authorized, "alice should NOT be authorized after removal"); - // Step 3: Create compound policy 10 via event - { - let cache = zone.policy_cache(); - cache.write().apply_events( - 3, - &[PolicyEvent::CompoundPolicyCreated { - policy_id: 10, - sender_policy_id: 5, - recipient_policy_id: 1, // allow all - mint_recipient_policy_id: 1, - }], - ); - } + // Step 3: Create compound policy 10 at block 3. + seed_raw_tip403_policy( + zone.l1_state_cache(), + 3, + &[PolicySeed::compound(10, 5, 1, 1)], + )?; // Compound data should be queryable let compound = registry.compoundPolicyData(10).call().await?; diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 9fe48f385..907d14e22 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -22,20 +22,33 @@ use std::{ ops::Deref, pin::Pin, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicU64, Ordering}, }, time::Duration, }; use tempo_alloy::TempoNetwork; -use tempo_chainspec::spec::{TEMPO_T0_BASE_FEE, TempoChainSpec}; +use tempo_chainspec::{ + hardfork::TempoHardfork, + spec::{TEMPO_T0_BASE_FEE, TempoChainSpec}, +}; use tempo_contracts::precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, ITIP20, + ACCOUNT_KEYCHAIN_ADDRESS, ITIP20, TIP403_REGISTRY_ADDRESS, account_keychain::IAccountKeychain::{ IAccountKeychainInstance, KeyRestrictions, SignatureType as KeyInfoSignatureType, }, }; -use tempo_precompiles::{PATH_USD_ADDRESS, tip403_registry::ALLOW_ALL_POLICY_ID}; +use tempo_precompiles::{ + PATH_USD_ADDRESS, + storage::{ + Handler, PrecompileStorageProvider, StorageCtx, StorageKey, hashmap::HashMapStorageProvider, + }, + tip20::tip20_slots, + tip403_registry::{ + ALLOW_ALL_POLICY_ID, CompoundPolicyData as RawCompoundPolicyData, PolicyData, PolicyType, + TIP403Registry, tip403_registry_slots, + }, +}; use tempo_primitives::{TempoHeader, transaction::tt_signature::TempoSignature}; use tempo_zone_contracts::{ZONE_FACTORY_ADDRESS, ZONE_OUTBOX_ADDRESS}; use zone_chainspec::ZoneChainSpec; @@ -305,18 +318,135 @@ fn seed_local_policy_cache(policy_cache: &zone_l1::PolicyCache) { ); } +// TODO(rusowsky): Remove once Tempo L1 stores transfer policy IDs in the TIP403 precompile. +fn pack_transfer_policy_id(policy_id: u64) -> U256 { + U256::from(policy_id) << (tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8) +} + +/// Seed a TIP-20 transfer policy ID in the canonical packed L1 storage slot. +pub(crate) fn seed_raw_tip20_policy_id( + cache: &mut zone_l1::state::L1StateCacheInner, + block_number: u64, + token: Address, + policy_id: u64, +) { + let packed = pack_transfer_policy_id(policy_id); + cache.set( + token, + B256::from(tip20_slots::TRANSFER_POLICY_ID.to_be_bytes()), + block_number, + B256::from(packed.to_be_bytes()), + ); +} + +/// A TIP-403 policy write for [`seed_raw_tip403_policy`]. +pub(crate) struct PolicySeed<'a> { + pub(crate) id: u64, + pub(crate) ty: PolicyType, + pub(crate) members: &'a [(Address, bool)], + pub(crate) compound: Option<(u64, u64, u64)>, +} + +impl<'a> PolicySeed<'a> { + pub(crate) fn simple(id: u64, ty: PolicyType, members: &'a [(Address, bool)]) -> Self { + Self { + id, + ty, + members, + compound: None, + } + } + + pub(crate) fn compound(id: u64, sender: u64, recipient: u64, mint_recipient: u64) -> Self { + Self { + id, + ty: PolicyType::COMPOUND, + members: &[], + compound: Some((sender, recipient, mint_recipient)), + } + } +} + +/// Materialize one or more TIP-403 policy writes into the raw L1 cache. +/// A batch shares a single storage snapshot, so multiple policy writes can reference each other. +pub(crate) fn seed_raw_tip403_policy( + cache: &L1StateCache, + block_number: u64, + policies: &[PolicySeed<'_>], +) -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T8); + let registry = TIP403Registry::new(); + let counter_slot = registry.policy_id_counter.slot(); + let existing_next_policy_id = cache + .read() + .get(TIP403_REGISTRY_ADDRESS, counter_slot.into(), block_number) + .and_then(|value| U256::from_be_bytes(value.0).try_into().ok()) + .unwrap_or(2u64); + let mut slots = vec![counter_slot]; + for policy in policies { + slots.push(registry.policy_records[policy.id].base.base_slot()); + if policy.compound.is_some() { + slots.push(registry.policy_records[policy.id].compound.base_slot()); + } + slots.extend( + policy + .members + .iter() + .map(|(account, _)| registry.policy_set[policy.id][*account].slot()), + ); + } + + StorageCtx::enter(&mut storage, || -> tempo_precompiles::Result<()> { + let mut registry = TIP403Registry::new(); + let next_policy_id = policies + .iter() + .map(|policy| policy.id + 1) + .max() + .unwrap_or(2) + .max(existing_next_policy_id); + registry.policy_id_counter.write(next_policy_id)?; + for policy in policies { + registry.policy_records[policy.id].base.write(PolicyData { + policy_type: policy.ty as u8, + admin: Address::ZERO, + })?; + if let Some((sender, recipient, mint_recipient)) = policy.compound { + registry.policy_records[policy.id] + .compound + .write(RawCompoundPolicyData { + sender_policy_id: sender, + recipient_policy_id: recipient, + mint_recipient_policy_id: mint_recipient, + })?; + } + for &(account, in_set) in policy.members { + registry.policy_set[policy.id][account].write(in_set)?; + } + } + Ok(()) + })?; + + let mut cache = cache.write(); + for slot in slots { + let value = storage.sload(TIP403_REGISTRY_ADDRESS, slot)?; + cache.set( + TIP403_REGISTRY_ADDRESS, + slot.into(), + block_number, + value.into(), + ); + } + Ok(()) +} + /// Compute the TIP-20 token address for a given sender and salt. /// /// Mirrors `compute_tip20_address` in the factory precompile. pub(crate) fn compute_tip20_address(sender: Address, salt: B256) -> Address { let hash = keccak256((sender, salt).abi_encode()); - let tip20_prefix: [u8; 12] = [ - 0x20, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]; - let mut address_bytes = [0u8; 20]; - address_bytes[..12].copy_from_slice(&tip20_prefix); + address_bytes[..12].copy_from_slice(&tempo_primitives::transaction::TIP20_PAYMENT_PREFIX); address_bytes[12..].copy_from_slice(&hash[..8]); Address::from(address_bytes) @@ -772,6 +902,9 @@ impl ZoneTestNode { let policy_cache = zone_node.policy_cache(); if is_local_dummy_l1 { seed_local_policy_cache(&policy_cache); + let mut cache = l1_state_cache.write(); + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); + cache.extend_hardfork_schedule(u64::MAX, [(0, TempoHardfork::T8)]); } let node_handle = NodeBuilder::new(node_config) @@ -3138,6 +3271,8 @@ pub(crate) struct L1Fixture { next_block_number: u64, next_timestamp: u64, last_hash: B256, + /// Raw L1 caches seeded by this fixture, updated with state implied by injected deposits. + caches: Mutex>, } impl L1Fixture { @@ -3153,6 +3288,7 @@ impl L1Fixture { next_block_number: 1, next_timestamp: 1_000_000, last_hash: genesis_hash, + caches: Mutex::new(Vec::new()), } } @@ -3164,12 +3300,12 @@ impl L1Fixture { /// for each block we plan to inject. pub(crate) fn seed_l1_cache( &self, - cache: &L1StateCache, + cache_handle: &L1StateCache, portal_address: Address, sequencer: Address, num_blocks: u64, ) { - let mut cache = cache.write(); + let mut cache = cache_handle.write(); let deposit_queue_hash_slot = B256::with_last_byte(5); let refunds_slot = B256::with_last_byte(10); let path_usd_config_slot = portal_token_config_slot(PATH_USD_ADDRESS); @@ -3200,10 +3336,38 @@ impl L1Fixture { ); } + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); + cache.extend_hardfork_schedule(u64::MAX, [(0, TempoHardfork::T8)]); cache.update_anchor(NumHash { number: num_blocks, hash: B256::ZERO, }); + drop(cache); + self.caches.lock().unwrap().push(cache_handle.clone()); + } + + /// Seed the absence of an address-level TIP-403 receive policy for the next fixture block. + pub(crate) fn seed_no_receive_policy(&self, recipient: Address) { + self.seed_no_receive_policy_at(self.next_block_number, recipient); + } + + fn seed_no_receive_policy_at(&self, block_number: u64, recipient: Address) { + // TODO(rusowsky): make `ReceivePolicy` public upstream to use the handlers + let receive_policy_slot = recipient.mapping_slot(tip403_registry_slots::RECEIVE_POLICIES); + for cache in self.caches.lock().unwrap().iter() { + cache.write().set( + TIP403_REGISTRY_ADDRESS, + B256::from(receive_policy_slot.to_be_bytes()), + block_number, + B256::ZERO, + ); + } + } + + fn seed_regular_deposit_policy_state(&self, block_number: u64, deposits: &[Deposit]) { + for deposit in deposits { + self.seed_no_receive_policy_at(block_number, deposit.to); + } } /// Build a [`TempoHeader`] for the next L1 block. @@ -3249,6 +3413,7 @@ impl L1Fixture { queue: &DepositQueue, deposits: Vec, ) { + self.seed_regular_deposit_policy_state(block.header.inner.number, &deposits); let l1_deposits = deposits.into_iter().map(L1Deposit::Regular).collect(); let events = L1PortalEvents::from_deposits(l1_deposits); queue.enqueue(block.header.clone(), events, vec![]); @@ -3313,6 +3478,7 @@ impl L1Fixture { /// Inject an L1 block with the given deposits into the queue. pub(crate) fn inject_deposits(&mut self, queue: &DepositQueue, deposits: Vec) { let header = self.next_header(); + self.seed_regular_deposit_policy_state(header.inner.number, &deposits); let l1_deposits = deposits.into_iter().map(L1Deposit::Regular).collect(); let events = L1PortalEvents::from_deposits(l1_deposits); queue.enqueue(header, events, vec![]); diff --git a/crates/precompiles/Cargo.toml b/crates/precompiles/Cargo.toml index be4d51412..599d7f63d 100644 --- a/crates/precompiles/Cargo.toml +++ b/crates/precompiles/Cargo.toml @@ -47,6 +47,7 @@ tracing = { version = "0.1.41", default-features = false } [dev-dependencies] const-hex.workspace = true +eyre.workspace = true tempo-precompiles = { workspace = true, features = ["test-utils"] } [features] diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index f0221340c..28d2b323a 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -5,9 +5,8 @@ //! in `TempoState`. Zone admission, delegate-call, fixed-gas, and privacy rules remain outside the //! forwarded business logic. //! -//! [`extend_zone_precompiles`] centralizes registration while the progressive policy migration is -//! underway. The legacy zone TIP-20 and TIP-403 implementations remain active until their dedicated -//! cutovers; `TipFeeManager` is currently the low-risk Tempo wrapper using anchored L1 execution. +//! [`extend_zone_precompiles`] centralizes registration. TIP-20, TIP-403, and `TipFeeManager` use +//! anchored upstream execution while the zone retains only its admission and gas rules. //! //! This crate is `no_std` compatible so these precompiles can run inside the //! SP1 prover guest (RISC-V) as well as in the zone node. @@ -23,8 +22,8 @@ //! ## Policy/token precompiles //! //! - **TIP-20 Factory** ([`tip20_factory`]) — zone-side TIP-20 token factory. -//! - **TIP-403 Proxy** ([`tip403_proxy`]) — read-only TIP-403 registry proxy. -//! - **Zone TIP-20** ([`ztip20`]) — policy-aware TIP-20 wrapper. +//! - **TIP-403 Registry** ([`tip403_proxy`]) — upstream registry over finalized L1 state. +//! - **Zone TIP-20** ([`ztip20`]) — upstream TIP-20 with zone call rules. #![cfg_attr(not(feature = "std"), no_std)] #![allow(clippy::too_many_arguments)] @@ -47,7 +46,6 @@ pub mod dispatch { } mod execution; -pub mod policy; pub mod storage; pub mod tempo_state; pub mod tip20_factory; @@ -59,8 +57,8 @@ pub use chaum_pedersen::{CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify}; pub use storage::L1StorageReader; pub use tempo_state::TempoState; pub use tip20_factory::{ZONE_TIP20_FACTORY_ADDRESS, ZoneTokenFactory}; -pub use tip403_proxy::{ZONE_TIP403_PROXY_ADDRESS, ZoneTip403ProxyRegistry}; -pub use ztip20::{SequencerExt, ZoneTip20Token}; +pub use tip403_proxy::ZONE_TIP403_PROXY_ADDRESS; +pub use ztip20::SequencerExt; use alloc::{rc::Rc, sync::Arc}; use core::cell::RefCell; @@ -70,34 +68,31 @@ use revm::{context::CfgEnv, precompile::PrecompileError}; use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{ ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, Precompile as _, PrecompileEnv, - STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, account_keychain::AccountKeychain, - nonce::NonceManager, storage::actions::StorageActions, storage_credits::NonCreditableSlots, - tip_fee_manager::TipFeeManager, tip20::is_tip20_prefix, + STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + account_keychain::AccountKeychain, + nonce::NonceManager, + storage::actions::StorageActions, + storage_credits::NonCreditableSlots, + tip_fee_manager::TipFeeManager, + tip20::{TIP20Token, is_tip20_prefix}, + tip403_registry::TIP403Registry, }; use zone_primitives::constants::TEMPO_STATE_ADDRESS; -use crate::policy::PolicyCheck; - /// Register zone-native and currently supported Tempo precompiles. /// -/// - **Local and legacy execution:** AES-GCM, Chaum-Pedersen, `TempoState`, and the zone token -/// factory use shared local execution; nonce and account-keychain retain Tempo's ordinary -/// environment; and the policy-cache-backed [`ZoneTip20Token`] and -/// [`ZoneTip403ProxyRegistry`] remain active until migrated. -/// - **Anchored L1 execution:** `TipFeeManager` uses the exact finalized Tempo anchor through -/// [`storage::ZonePrecompileStorageProvider`]. -pub fn extend_zone_precompiles( +/// - **Local execution:** AES-GCM, Chaum-Pedersen, `TempoState`, and the zone token factory use +/// shared local execution; nonce and account-keychain retain Tempo's ordinary environment. +/// - **Anchored L1 execution:** TIP-20, TIP-403, and `TipFeeManager` use the exact finalized Tempo +/// anchor through [`storage::ZonePrecompileStorageProvider`]. +pub fn extend_zone_precompiles( precompiles: &mut PrecompilesMap, cfg: &CfgEnv, l1_reader: L1, - policy_provider: Option, sequencer: Arc, actions: StorageActions, non_creditable_slots: Rc>, -) where - L1: L1StorageReader, - Policy: PolicyCheck + Clone + Send + Sync + 'static, -{ +) { let l1_env = execution::L1BackedPrecompileEnv::new( cfg, l1_reader.clone(), @@ -119,22 +114,18 @@ pub fn extend_zone_precompiles( Some(ZoneTokenFactory::create(cfg)) }); - if let Some(provider) = policy_provider.clone() { - precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, |_| { - Some(ZoneTip403ProxyRegistry::create(provider.clone(), cfg)) - }); - } - let registry = policy_provider.map(ZoneTip403ProxyRegistry::new); + let tip403_env = l1_env.clone(); + precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, move |_| { + Some(create_tip403_precompile(&tip403_env)) + }); - // Static zone entries above take priority. The dynamic lookup preserves the legacy token - // wrapper while sharing registration for the remaining Tempo precompiles. - let zone_cfg = cfg.clone(); + // Static zone entries above take priority. Dynamic TIP-20 entries use upstream execution with + // zone privacy, bridge-authorization, fixed-gas, and anchored policy-storage rules. precompiles.set_precompile_lookup(move |address: &alloy_primitives::Address| { if is_tip20_prefix(*address) { - Some(ZoneTip20Token::create( + Some(create_tip20_precompile( *address, - &zone_cfg, - registry.clone(), + &l1_env, sequencer.clone(), )) } else if *address == TIP_FEE_MANAGER_ADDRESS { @@ -206,6 +197,32 @@ impl ZoneTokenFactory { } } +/// Create upstream TIP-403 execution with zone read-only rules and finalized L1 state. +pub(crate) fn create_tip403_precompile( + env: &execution::L1BackedPrecompileEnv

, +) -> DynPrecompile { + execution::create_l1_backed_precompile( + "ZoneTip403Registry", + env.clone(), + tip403_proxy::Tip403Rules, + |data, caller| TIP403Registry::new().call(data, caller), + ) +} + +/// Create upstream TIP-20 execution with zone rules and finalized L1 policy reads. +pub(crate) fn create_tip20_precompile( + address: alloy_primitives::Address, + env: &execution::L1BackedPrecompileEnv

, + sequencer: Arc, +) -> DynPrecompile { + execution::create_l1_backed_precompile( + "TIP20Token", + env.clone(), + ztip20::TIP20Rules::new(sequencer), + move |data, caller| TIP20Token::from_address_unchecked(address).call(data, caller), + ) +} + const ZONE_RPC_ERROR_PREFIX: &str = "[zone rpc]"; /// Create a [`PrecompileError::Fatal`] for transient L1 RPC errors. diff --git a/crates/precompiles/src/policy.rs b/crates/precompiles/src/policy.rs deleted file mode 100644 index f88c8f152..000000000 --- a/crates/precompiles/src/policy.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Policy authorization trait for zone precompiles. -//! -//! Defines [`PolicyCheck`], an abstraction over the concrete `PolicyProvider` -//! so that the policy/token precompiles in this crate don't depend on tokio, -//! alloy providers, or any std-only infrastructure. - -use alloy_primitives::Address; -use revm::precompile::PrecompileError; -use zone_primitives::policy::AuthRole; - -/// Authorization provider used by the TIP-403 proxy and zone TIP-20 precompiles. -/// -/// Implementors resolve policy queries — either from an in-memory cache with -/// RPC fallback (zone node) or from a witness database (SP1 prover guest). -pub trait PolicyCheck { - /// Check whether `user` is authorized under `policy_id` for the given `role`. - fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result; - - /// Resolve the `transferPolicyId` for a token. - fn resolve_transfer_policy_id(&self, token: Address) -> Result; - - /// Resolve policy type and admin for a policy ID. - /// - /// Returns `Ok(Some((policy_type, admin)))` if the policy exists, `Ok(None)` otherwise. - fn policy_type_sync( - &self, - policy_id: u64, - ) -> Result; - - /// Resolve compound policy sub-IDs. - /// - /// Returns `(sender_policy_id, recipient_policy_id, mint_recipient_policy_id)`. - fn compound_policy_data(&self, policy_id: u64) -> Result<(u64, u64, u64), PrecompileError>; - - /// Check whether a policy exists. - fn policy_exists(&self, policy_id: u64) -> Result; - - /// Return the highest known policy ID counter. - fn policy_id_counter(&self) -> u64; -} diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index f7dccad0f..772c1917e 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -216,7 +216,6 @@ mod tests { use alloy_rlp::Encodable as _; use alloy_sol_types::SolCall; use tempo_precompiles::storage::StorageCtx; - type TestResult = Result>; fn encode_header(header: &TempoHeader) -> Bytes { let mut encoded = Vec::new(); @@ -224,7 +223,7 @@ mod tests { encoded.into() } - fn initialize(ctx: &mut TestContext, header: &[u8]) -> TestResult { + fn initialize(ctx: &mut TestContext, header: &[u8]) -> eyre::Result<()> { let mut storage = test_storage_provider(ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || TempoState::new().initialize(header))?; @@ -291,7 +290,7 @@ mod tests { precompile: &DynPrecompile, expected_hash: B256, expected_number: u64, - ) -> TestResult { + ) -> eyre::Result<()> { let block_hash = call( ctx, precompile, @@ -319,7 +318,7 @@ mod tests { } #[test] - fn initialize_sets_checkpoint() -> TestResult { + fn initialize_sets_checkpoint() -> eyre::Result<()> { let header = child_header(B256::repeat_byte(0xaa), 42); let header_rlp = encode_header(&header); let mut ctx = test_context(); @@ -332,7 +331,7 @@ mod tests { } #[test] - fn finalize_tempo_updates_checkpoint() -> TestResult { + fn finalize_tempo_updates_checkpoint() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -358,7 +357,7 @@ mod tests { } #[test] - fn finalize_tempo_reverts_for_non_inbox_caller() -> TestResult { + fn finalize_tempo_reverts_for_non_inbox_caller() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -382,7 +381,7 @@ mod tests { } #[test] - fn delegate_call_reverts() -> TestResult { + fn delegate_call_reverts() -> eyre::Result<()> { let genesis_rlp = encode_header(&TempoHeader::default()); let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; @@ -410,7 +409,7 @@ mod tests { } #[test] - fn finalize_tempo_reverts_on_static_call() -> TestResult { + fn finalize_tempo_reverts_on_static_call() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -434,7 +433,7 @@ mod tests { } #[test] - fn finalize_tempo_reverts_on_invalid_rlp() -> TestResult { + fn finalize_tempo_reverts_on_invalid_rlp() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -457,7 +456,7 @@ mod tests { } #[test] - fn finalize_tempo_reverts_on_trailing_header_bytes() -> TestResult { + fn finalize_tempo_reverts_on_trailing_header_bytes() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -483,7 +482,7 @@ mod tests { } #[test] - fn finalize_tempo_reverts_on_invalid_parent_hash() -> TestResult { + fn finalize_tempo_reverts_on_invalid_parent_hash() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -507,7 +506,7 @@ mod tests { } #[test] - fn finalize_tempo_reverts_on_invalid_block_number() -> TestResult { + fn finalize_tempo_reverts_on_invalid_block_number() -> eyre::Result<()> { let genesis = TempoHeader::default(); let genesis_rlp = encode_header(&genesis); let genesis_hash = keccak256(&genesis_rlp); @@ -531,7 +530,7 @@ mod tests { } #[test] - fn read_tempo_storage_slot_is_system_only() -> TestResult { + fn read_tempo_storage_slot_is_system_only() -> eyre::Result<()> { let genesis_rlp = encode_header(&TempoHeader::default()); let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; @@ -564,7 +563,7 @@ mod tests { } #[test] - fn read_tempo_storage_slots_returns_batch() -> TestResult { + fn read_tempo_storage_slots_returns_batch() -> eyre::Result<()> { let genesis_rlp = encode_header(&TempoHeader::default()); let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index 4cc654e28..ecb545cee 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -1,7 +1,9 @@ //! Shared test utilities for precompile tests. use std::{ + cell::RefCell, collections::HashMap, + rc::Rc, sync::{Arc, Mutex}, }; @@ -21,12 +23,20 @@ use revm::{ precompile::{PrecompileError, PrecompileResult}, }; use tempo_chainspec::hardfork::TempoHardfork; -use tempo_precompiles::storage::evm::EvmPrecompileStorageProvider; +use tempo_precompiles::{ + storage::{ + Handler, PrecompileStorageProvider, StorageCtx, actions::StorageActions, + evm::EvmPrecompileStorageProvider, hashmap::HashMapStorageProvider, + }, + storage_credits::NonCreditableSlots, + tip403_registry::{CompoundPolicyData, PolicyData, TIP403Registry}, +}; use crate::{ L1StorageReader, chaum_pedersen::{challenge_hash, recover_point}, ecies::DecryptedDeposit, + execution::L1BackedPrecompileEnv, }; pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; @@ -59,6 +69,19 @@ pub(crate) fn test_storage_provider( ) } +/// Create the shared finalized-L1 execution environment for a precompile test. +pub(crate) fn test_l1_env( + ctx: &TestContext, + l1_reader: P, +) -> L1BackedPrecompileEnv

{ + L1BackedPrecompileEnv::new( + &ctx.cfg, + l1_reader, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ) +} + /// Call a dynamic precompile with test defaults for value and reservoir. pub(crate) fn call_precompile( ctx: &mut TestContext, @@ -83,16 +106,54 @@ pub(crate) fn call_precompile( }) } +// TODO(rusowsky): Remove once Tempo L1 stores transfer policy IDs in the TIP403 precompile. +fn pack_transfer_policy_id(policy_id: u64) -> U256 { + U256::from(policy_id) << (tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8) +} + /// In-memory exact-block L1 reader shared by precompile tests. -#[derive(Clone, Default)] +#[derive(Clone)] pub(crate) struct MockL1Reader { slots: Shared>, + registry_storage: Shared, storage_requests: Shared>, fallback: B256, + policy_id: u64, fail_storage: bool, } +impl Default for MockL1Reader { + fn default() -> Self { + Self { + slots: Default::default(), + registry_storage: Arc::new(Mutex::new(HashMapStorageProvider::new(1))), + storage_requests: Default::default(), + fallback: B256::ZERO, + policy_id: 0, + fail_storage: false, + } + } +} + impl MockL1Reader { + pub(crate) fn allow_all() -> Self { + Self::with_policy_id(1) + } + + pub(crate) fn failing() -> Self { + Self { + fail_storage: true, + ..Self::allow_all() + } + } + + pub(crate) fn with_policy_id(policy_id: u64) -> Self { + Self { + policy_id, + ..Default::default() + } + } + pub(crate) fn returning(value: B256) -> Self { Self { fallback: value, @@ -117,6 +178,78 @@ impl MockL1Reader { pub(crate) fn storage_requests(&self) -> Vec { self.storage_requests.lock().unwrap().clone() } + + pub(crate) fn seed_transfer_policy_id(&self, token: Address, block_number: u64) { + let packed = pack_transfer_policy_id(self.policy_id); + self.set_u256( + token, + tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID, + block_number, + packed, + ); + } + + pub(crate) fn seed_simple_policy( + &self, + policy_id: u64, + policy_type: tempo_contracts::precompiles::ITIP403Registry::PolicyType, + accounts: &[Address], + ) -> tempo_precompiles::Result<()> { + let mut storage = self.registry_storage.lock().unwrap(); + StorageCtx::enter(&mut *storage, || { + let mut registry = TIP403Registry::new(); + let next_policy_id = registry.policy_id_counter()?.max(policy_id + 1); + registry.policy_id_counter.write(next_policy_id)?; + registry.policy_records[policy_id].base.write(PolicyData { + policy_type: policy_type as u8, + admin: Address::ZERO, + })?; + for account in accounts { + registry.policy_set[policy_id][*account].write(true)?; + } + Ok(()) + }) + } + + pub(crate) fn seed_blacklist_policy( + &self, + policy_id: u64, + accounts: &[Address], + ) -> tempo_precompiles::Result<()> { + self.seed_simple_policy( + policy_id, + tempo_contracts::precompiles::ITIP403Registry::PolicyType::BLACKLIST, + accounts, + ) + } + + pub(crate) fn seed_compound_policy( + &self, + policy_id: u64, + sender_policy_id: u64, + recipient_policy_id: u64, + mint_recipient_policy_id: u64, + ) -> tempo_precompiles::Result<()> { + let mut storage = self.registry_storage.lock().unwrap(); + StorageCtx::enter(&mut *storage, || { + let mut registry = TIP403Registry::new(); + let next_policy_id = registry.policy_id_counter()?.max(policy_id + 1); + registry.policy_id_counter.write(next_policy_id)?; + registry.policy_records[policy_id].base.write(PolicyData { + policy_type: tempo_contracts::precompiles::ITIP403Registry::PolicyType::COMPOUND + as u8, + admin: Address::ZERO, + })?; + registry.policy_records[policy_id] + .compound + .write(CompoundPolicyData { + sender_policy_id, + recipient_policy_id, + mint_recipient_policy_id, + })?; + Ok(()) + }) + } } impl L1StorageReader for MockL1Reader { @@ -133,13 +266,28 @@ impl L1StorageReader for MockL1Reader { if self.fail_storage { return Err(crate::zone_rpc_error("RPC unavailable")); } - Ok(self + if let Some(value) = self .slots .lock() .unwrap() .get(&(account, slot, block_number)) .copied() - .unwrap_or(self.fallback)) + { + return Ok(value); + } + + let key = U256::from_be_bytes(slot.0); + let value = self + .registry_storage + .lock() + .unwrap() + .sload(account, key) + .map_err(|err| PrecompileError::Fatal(err.to_string()))?; + if value.is_zero() { + Ok(self.fallback) + } else { + Ok(B256::from(value.to_be_bytes())) + } } } diff --git a/crates/precompiles/src/tip403_proxy/dispatch.rs b/crates/precompiles/src/tip403_proxy/dispatch.rs deleted file mode 100644 index 71ae2f96c..000000000 --- a/crates/precompiles/src/tip403_proxy/dispatch.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! ABI dispatch for the [`ZoneTip403ProxyRegistry`] precompile. - -use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{Address, Bytes}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; -use tempo_contracts::precompiles::ITIP403Registry::{self, PolicyType}; -use tempo_precompiles::{ - Precompile as TempoPrecompile, charge_input_cost, dispatch, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - tip403_registry::{ALLOW_ALL_POLICY_ID, REJECT_ALL_POLICY_ID}, -}; -use tracing::{debug, warn}; -use zone_primitives::policy::AuthRole; - -use super::{POLICY_DATA_GAS, ReadOnlyRegistry, ZoneTip403ProxyRegistry}; -use crate::{ZonePrecompileError, policy::PolicyCheck}; - -impl ZoneTip403ProxyRegistry

{ - /// Create a [`DynPrecompile`] that dispatches TIP-403 registry calls - /// to the zone's policy provider. - pub fn create( - provider: P, - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile { - let registry = Self::new(provider); - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - DynPrecompile::new_stateful( - PrecompileId::Custom("ZoneTip403ProxyRegistry".into()), - move |input| { - if !input.is_direct_call() { - warn!( - target: "zone::precompile", - "ZoneTip403ProxyRegistry called via DELEGATECALL - rejecting" - ); - return Ok(PrecompileOutput::revert( - 0, - ReadOnlyRegistry {}.abi_encode().into(), - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let mut registry = registry.clone(); - registry.call(input.data, input.caller) - }) - }, - ) - } -} - -impl TempoPrecompile for ZoneTip403ProxyRegistry

{ - /// Dispatch based on the 4-byte selector. - fn call(&mut self, calldata: &[u8], _msg_sender: Address) -> PrecompileResult { - let mut storage = StorageCtx::default(); - if let Some(err) = charge_input_cost(&mut storage, calldata) { - return err; - } - - dispatch!( - calldata, - |call| match call { - ITIP403Registry::ITIP403RegistryCalls { - policyIdCounter(_) => self.handle_policy_id_counter(), - policyExists(call) => self.handle_policy_exists(call.policyId), - policyData(call) => self.handle_policy_data(call.policyId), - isAuthorized(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::Transfer) - }, - isAuthorizedSender(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::Sender) - }, - isAuthorizedRecipient(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::Recipient) - }, - isAuthorizedMintRecipient(call) => { - self.handle_is_authorized(call.policyId, call.user, AuthRole::MintRecipient) - }, - compoundPolicyData(call) => { - self.handle_compound_policy_data(call.policyId) - }, - createPolicy(_) => self.read_only_revert(), - createPolicyWithAccounts(_) => self.read_only_revert(), - setPolicyAdmin(_) => self.read_only_revert(), - modifyPolicyWhitelist(_) => self.read_only_revert(), - modifyPolicyBlacklist(_) => self.read_only_revert(), - createCompoundPolicy(_) => self.read_only_revert(), - receivePolicy(_) => Ok(StorageCtx::default().revert_output(Bytes::new())), - validateReceivePolicy(_) => Ok(StorageCtx::default().revert_output(Bytes::new())), - setReceivePolicy(_) => Ok(StorageCtx::default().revert_output(Bytes::new())), - } - }, - ) - } -} - -impl ZoneTip403ProxyRegistry

{ - fn read_only_revert(&self) -> PrecompileResult { - debug!(target: "zone::precompile", "ZoneTip403ProxyRegistry: mutating call reverted"); - StorageCtx.error_result(ZonePrecompileError::from(ReadOnlyRegistry {})) - } - - /// Handle `isAuthorized(policyId, user)` and the directional variants. - fn handle_is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> PrecompileResult { - let authorized = self.is_authorized(policy_id, user, role)?; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(super::AUTH_CHECK_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::isAuthorizedCall::abi_encode_returns(&authorized); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `policyData(policyId) -> (PolicyType, address admin)`. - fn handle_policy_data(&self, policy_id: u64) -> PrecompileResult { - // Builtins: reject-all is an empty whitelist, allow-all is an empty blacklist. - let builtin_type = match policy_id { - REJECT_ALL_POLICY_ID => Some(PolicyType::WHITELIST), - ALLOW_ALL_POLICY_ID => Some(PolicyType::BLACKLIST), - _ => None, - }; - if let Some(policy_type) = builtin_type { - let ret = ITIP403Registry::policyDataReturn { - policyType: policy_type, - admin: Address::ZERO, - }; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyDataCall::abi_encode_returns(&ret); - return Ok(storage.success_output(encoded.into())); - } - - let policy_type = self.provider.policy_type_sync(policy_id)?; - - let ret = ITIP403Registry::policyDataReturn { - policyType: policy_type, - admin: Address::ZERO, - }; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyDataCall::abi_encode_returns(&ret); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `compoundPolicyData(policyId) -> (uint64, uint64, uint64)`. - fn handle_compound_policy_data(&self, policy_id: u64) -> PrecompileResult { - let (sender, recipient, mint_recipient) = self.provider.compound_policy_data(policy_id)?; - - let ret = ITIP403Registry::compoundPolicyDataReturn { - senderPolicyId: sender, - recipientPolicyId: recipient, - mintRecipientPolicyId: mint_recipient, - }; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::compoundPolicyDataCall::abi_encode_returns(&ret); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `policyExists(policyId) -> bool`. - fn handle_policy_exists(&self, policy_id: u64) -> PrecompileResult { - if matches!(policy_id, REJECT_ALL_POLICY_ID | ALLOW_ALL_POLICY_ID) { - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyExistsCall::abi_encode_returns(&true); - return Ok(storage.success_output(encoded.into())); - } - - let exists = self.provider.policy_exists(policy_id)?; - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyExistsCall::abi_encode_returns(&exists); - Ok(storage.success_output(encoded.into())) - } - - /// Handle `policyIdCounter() -> uint64`. - fn handle_policy_id_counter(&self) -> PrecompileResult { - let counter = self.provider.policy_id_counter(); - let mut storage = StorageCtx::default(); - if storage.deduct_gas(POLICY_DATA_GAS).is_err() { - return Ok(storage.halt_output(PrecompileHalt::OutOfGas)); - } - let encoded = ITIP403Registry::policyIdCounterCall::abi_encode_returns(&counter); - Ok(storage.success_output(encoded.into())) - } -} diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index fb1025057..2735c7230 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -1,92 +1,342 @@ -//! Zone-side TIP-403 registry proxy precompile. +//! Zone rules for the L1-backed Tempo TIP-403 registry. //! -//! Deployed at the same address as the L1 [`TIP403Registry`] (`0x403C…0000`), this -//! precompile intercepts external EVM calls to the registry and serves authorization -//! queries from the zone's [`PolicyCheck`] provider (cache-first, L1 RPC fallback). -//! -//! **Read-only calls** (`isAuthorized`, `isAuthorizedSender`, `isAuthorizedRecipient`, -//! `isAuthorizedMintRecipient`, `policyData`, `compoundPolicyData`, `policyExists`) -//! are resolved via the [`PolicyCheck`] trait. -//! -//! **Mutating calls** (`createPolicy`, `modifyPolicyWhitelist`, etc.) are reverted — -//! policy state is managed on L1, not on the zone. - -mod dispatch; +//! Calls at the canonical registry address execute the upstream +//! [`tempo_precompiles::tip403_registry::TIP403Registry`] implementation over finalized L1 +//! storage. The zone keeps mutating selectors read-only and otherwise follows upstream dispatch, +//! gas, delegate-call, and receive-policy behavior. use alloy_primitives::Address; -use revm::precompile::PrecompileError; -use tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS; -use zone_primitives::policy::AuthRole; +use alloy_sol_types::{SolCall, SolError}; +use tempo_contracts::precompiles::{ITIP403Registry, TIP403_REGISTRY_ADDRESS}; +use tempo_precompiles::storage::StorageCtx; -use crate::policy::PolicyCheck; +use crate::execution::{CallCheck, CallRules, ZoneCall}; -/// The precompile address — same as the L1 TIP403Registry. +/// Canonical TIP-403 registry address, shared with Tempo L1. pub const ZONE_TIP403_PROXY_ADDRESS: Address = TIP403_REGISTRY_ADDRESS; -/// Fixed gas cost for authorization checks. -pub const AUTH_CHECK_GAS: u64 = 200; - -/// Fixed gas cost for policy data lookups. -const POLICY_DATA_GAS: u64 = 200; +const TIP403_MUTATING_SELECTORS: &[[u8; 4]] = &[ + ITIP403Registry::createPolicyCall::SELECTOR, + ITIP403Registry::createPolicyWithAccountsCall::SELECTOR, + ITIP403Registry::setPolicyAdminCall::SELECTOR, + ITIP403Registry::modifyPolicyWhitelistCall::SELECTOR, + ITIP403Registry::modifyPolicyBlacklistCall::SELECTOR, + ITIP403Registry::createCompoundPolicyCall::SELECTOR, + ITIP403Registry::setReceivePolicyCall::SELECTOR, +]; alloy_sol_types::sol! { - /// Returned when a mutating call is attempted on the read-only zone registry. + /// Returned when a mutating call is attempted on the zone's read-only, L1-backed registry. #[derive(Debug, PartialEq, Eq)] error ReadOnlyRegistry(); } -/// Read-only zone-side proxy that mirrors the L1 TIP-403 registry. -/// -/// Unlike the L1 [`TIP403Registry`] (which is a storage-backed `#[contract]` -/// precompile), this proxy has **no on-chain storage**. It intercepts EVM calls -/// at the same address (`0x403C…0000`) and resolves authorization queries via -/// the [`PolicyCheck`] trait. -/// -/// All mutating calls (`createPolicy`, `modifyPolicyWhitelist`, etc.) are -/// rejected with `ReadOnlyRegistry` — policy state lives exclusively on L1. -/// -/// The struct also exposes [`is_authorized`](Self::is_authorized) and -/// [`is_transfer_authorized`](Self::is_transfer_authorized) for use by the -/// [`ZoneTip20Token`](super::ZoneTip20Token) precompile, which needs the same -/// authorization logic during transfer/mint pre-checks. -#[derive(Debug, Clone)] -pub struct ZoneTip403ProxyRegistry

{ - provider: P, +/// Rules that keep the zone registry read-only before upstream execution. +pub(crate) struct Tip403Rules; + +impl CallRules for Tip403Rules { + fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + if call + .selector() + .is_some_and(|selector| TIP403_MUTATING_SELECTORS.contains(&selector)) + { + return CallCheck::Return(Ok( + StorageCtx::default().revert_output(ReadOnlyRegistry {}.abi_encode().into()) + )); + } + + CallCheck::Continue + } } -impl ZoneTip403ProxyRegistry

{ - /// Create a new proxy registry backed by the given policy provider. - pub fn new(provider: P) -> Self { - Self { provider } +#[cfg(test)] +mod tests { + use super::*; + + use alloy_evm::precompiles::DynPrecompile; + use alloy_primitives::{Bytes, U256, address}; + use revm::precompile::{PrecompileError, PrecompileOutput}; + use tempo_precompiles::{DelegateCallNotAllowed, storage::PrecompileStorageProvider}; + + use crate::{ + create_tip403_precompile, + tempo_state::slots::TEMPO_BLOCK_NUMBER, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, + test_storage_provider, + }, + }; + + const ANCHOR: u64 = 77; + const CALLER: Address = address!("0x0000000000000000000000000000000000000aaa"); + const ALICE: Address = address!("0x00000000000000000000000000000000000000a1"); + const BOB: Address = address!("0x00000000000000000000000000000000000000b2"); + + struct RegistryHarness { + ctx: TestContext, + precompile: DynPrecompile, + } + + impl RegistryHarness { + fn new(l1: MockL1Reader) -> Self { + let mut ctx = test_context(); + test_storage_provider(&mut ctx, u64::MAX, false) + .sstore( + zone_primitives::constants::TEMPO_STATE_ADDRESS, + TEMPO_BLOCK_NUMBER, + U256::from(ANCHOR), + ) + .unwrap(); + let env = test_l1_env(&ctx, l1); + Self { + ctx, + precompile: create_tip403_precompile(&env), + } + } + + fn call(&mut self, data: &[u8], gas: u64) -> Result { + self.call_as( + data, + gas, + ZONE_TIP403_PROXY_ADDRESS, + ZONE_TIP403_PROXY_ADDRESS, + ) + } + + fn call_as( + &mut self, + data: &[u8], + gas: u64, + target: Address, + bytecode: Address, + ) -> Result { + call_precompile( + &mut self.ctx, + &self.precompile, + CALLER, + data, + gas, + true, + target, + bytecode, + ) + } + } + + fn seeded_reader() -> MockL1Reader { + let l1 = MockL1Reader::default(); + l1.seed_simple_policy(5, ITIP403Registry::PolicyType::WHITELIST, &[ALICE]) + .unwrap(); + l1.seed_simple_policy(6, ITIP403Registry::PolicyType::BLACKLIST, &[BOB]) + .unwrap(); + l1.seed_compound_policy(10, 5, 6, 1).unwrap(); + l1 + } + + fn bool_result>( + harness: &mut RegistryHarness, + call: &T, + ) -> eyre::Result { + let output = harness.call(&call.abi_encode(), u64::MAX)?; + Ok(T::abi_decode_returns(&output.bytes)?) + } + + #[test] + fn authorization_and_read_methods_match_upstream_policy_semantics() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(seeded_reader()); + + for (policy_id, user, expected) in [ + (0, ALICE, false), + (1, ALICE, true), + (5, ALICE, true), + (5, BOB, false), + (6, BOB, false), + ] { + let call = ITIP403Registry::isAuthorizedCall { + policyId: policy_id, + user, + }; + assert_eq!(bool_result(&mut harness, &call)?, expected); + } + + assert!(bool_result( + &mut harness, + &ITIP403Registry::isAuthorizedSenderCall { + policyId: 10, + user: ALICE, + }, + )?); + assert!(!bool_result( + &mut harness, + &ITIP403Registry::isAuthorizedRecipientCall { + policyId: 10, + user: BOB, + }, + )?); + assert!(bool_result( + &mut harness, + &ITIP403Registry::isAuthorizedMintRecipientCall { + policyId: 10, + user: BOB, + }, + )?); + + let counter = harness.call( + &ITIP403Registry::policyIdCounterCall {}.abi_encode(), + u64::MAX, + )?; + assert_eq!( + ITIP403Registry::policyIdCounterCall::abi_decode_returns(&counter.bytes)?, + 11 + ); + + let exists = ITIP403Registry::policyExistsCall { policyId: 10 }; + assert!(bool_result(&mut harness, &exists)?); + + let policy_data = harness.call( + &ITIP403Registry::policyDataCall { policyId: 5 }.abi_encode(), + u64::MAX, + )?; + let policy_data = ITIP403Registry::policyDataCall::abi_decode_returns(&policy_data.bytes)?; + assert_eq!( + policy_data.policyType, + ITIP403Registry::PolicyType::WHITELIST + ); + assert_eq!(policy_data.admin, Address::ZERO); + + let compound = harness.call( + &ITIP403Registry::compoundPolicyDataCall { policyId: 10 }.abi_encode(), + u64::MAX, + )?; + let compound = + ITIP403Registry::compoundPolicyDataCall::abi_decode_returns(&compound.bytes)?; + assert_eq!(compound.senderPolicyId, 5); + assert_eq!(compound.recipientPolicyId, 6); + assert_eq!(compound.mintRecipientPolicyId, 1); + Ok(()) + } + + #[test] + fn mutations_revert_while_receive_policy_reads_use_upstream_dispatch() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(MockL1Reader::default()); + let mutation = ITIP403Registry::createPolicyCall { + admin: CALLER, + policyType: ITIP403Registry::PolicyType::BLACKLIST, + } + .abi_encode(); + let output = harness.call(&mutation, u64::MAX)?; + assert!(output.is_revert()); + assert_eq!(output.bytes, Bytes::from(ReadOnlyRegistry {}.abi_encode())); + + let receive = harness.call( + &ITIP403Registry::receivePolicyCall { account: CALLER }.abi_encode(), + u64::MAX, + )?; + assert!(receive.is_success()); + let receive = ITIP403Registry::receivePolicyCall::abi_decode_returns(&receive.bytes)?; + assert!(!receive.hasReceivePolicy); + + let validation = harness.call( + &ITIP403Registry::validateReceivePolicyCall { + token: Address::repeat_byte(0x20), + sender: ALICE, + receiver: BOB, + } + .abi_encode(), + u64::MAX, + )?; + assert!(validation.is_success()); + let validation = + ITIP403Registry::validateReceivePolicyCall::abi_decode_returns(&validation.bytes)?; + assert!(validation.authorized); + assert_eq!( + validation.blockedReason, + ITIP403Registry::BlockedReason::NONE + ); + + let set_receive = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: 1, + tokenFilterId: 1, + recoveryAuthority: Address::ZERO, + } + .abi_encode(); + let output = harness.call(&set_receive, u64::MAX)?; + assert!(output.is_revert()); + assert_eq!(output.bytes, Bytes::from(ReadOnlyRegistry {}.abi_encode())); + Ok(()) } - /// Resolve the `transferPolicyId` for a token. - pub fn resolve_transfer_policy_id(&self, token: Address) -> Result { - self.provider.resolve_transfer_policy_id(token) + #[test] + fn delegate_calls_revert_before_anchor_or_l1_access() -> eyre::Result<()> { + let reader = MockL1Reader::default(); + let mut harness = RegistryHarness::new(reader.clone()); + let call = ITIP403Registry::policyIdCounterCall {}.abi_encode(); + let output = harness.call_as( + &call, + u64::MAX, + ZONE_TIP403_PROXY_ADDRESS, + Address::repeat_byte(0x44), + )?; + + assert!(output.is_revert()); + assert_eq!(output.gas_used, 0); + assert_eq!( + output.bytes, + Bytes::from(DelegateCallNotAllowed {}.abi_encode()) + ); + assert!(reader.hardfork_requests().is_empty()); + assert!(reader.storage_requests().is_empty()); + Ok(()) } - /// Check whether `user` is authorized under `policy_id` for the given `role`. - pub fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result { - self.provider.is_authorized(policy_id, user, role) + #[test] + fn registry_reads_every_slot_and_hardfork_at_the_exact_tempo_anchor() -> eyre::Result<()> { + let reader = seeded_reader(); + let mut harness = RegistryHarness::new(reader.clone()); + let call = ITIP403Registry::isAuthorizedCall { + policyId: 5, + user: ALICE, + } + .abi_encode(); + let output = harness.call(&call, u64::MAX)?; + assert!(output.is_success()); + + assert_eq!(reader.hardfork_requests(), vec![ANCHOR]); + let requests = reader.storage_requests(); + assert!(!requests.is_empty()); + assert!(requests.iter().all(|(_, _, block)| *block == ANCHOR)); + Ok(()) } - /// Check sender + recipient authorization for a transfer. - /// - /// Short-circuits on sender failure (matching L1 T2 behavior). - pub fn is_transfer_authorized( - &self, - policy_id: u64, - from: Address, - to: Address, - ) -> Result { - if !self.is_authorized(policy_id, from, AuthRole::Sender)? { - return Ok(false); + #[test] + fn anchored_hardfork_and_storage_failures_fail_closed() { + let call = ITIP403Registry::isAuthorizedCall { + policyId: 5, + user: ALICE, } - self.is_authorized(policy_id, to, AuthRole::Recipient) + .abi_encode(); + + let hardfork_reader = MockL1Reader::failing_hardfork(); + let mut harness = RegistryHarness::new(hardfork_reader.clone()); + assert!(matches!( + harness.call(&call, u64::MAX), + Err(PrecompileError::Fatal(message)) if message.contains("hardfork unavailable") + )); + assert_eq!(hardfork_reader.hardfork_requests(), vec![ANCHOR]); + assert!(hardfork_reader.storage_requests().is_empty()); + + let storage_reader = MockL1Reader::failing_storage(); + let mut harness = RegistryHarness::new(storage_reader.clone()); + assert!(matches!( + harness.call(&call, u64::MAX), + Err(PrecompileError::Fatal(message)) if message.contains("RPC unavailable") + )); + assert_eq!(storage_reader.hardfork_requests(), vec![ANCHOR]); + assert!( + storage_reader + .storage_requests() + .iter() + .all(|(_, _, block)| *block == ANCHOR) + ); } } diff --git a/crates/precompiles/src/ztip20/dispatch.rs b/crates/precompiles/src/ztip20/dispatch.rs deleted file mode 100644 index 5217cfaa8..000000000 --- a/crates/precompiles/src/ztip20/dispatch.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! ABI dispatch and precheck routing for the [`ZoneTip20Token`] wrapper. - -use alloc::sync::Arc; - -use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{Address, Bytes}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; -use tempo_precompiles::{ - DelegateCallNotAllowed, Precompile as TempoPrecompile, charge_input_cost, - storage::{StorageCtx, evm::EvmPrecompileStorageProvider}, - tip20::{IRolesAuth, ITIP20, TIP20Token}, -}; - -use super::{FIXED_TRANSFER_GAS, SequencerExt, ZoneTip20Token}; -use crate::{policy::PolicyCheck, tip403_proxy::ZoneTip403ProxyRegistry}; - -/// Decode ABI args or return a reverted precompile output. -/// -/// Unlike `.ok()?` (which silently skips the policy check on decode failure), -/// this macro returns a definitive revert so malformed calldata cannot bypass -/// the zone policy layer. -macro_rules! decode_or_revert { - ($call_ty:ty, $args:expr) => { - match <$call_ty>::abi_decode_raw_validate($args) { - Ok(c) => c, - Err(_) => { - return Some(Ok(StorageCtx::default().revert_output(Bytes::new()))); - } - } - }; -} - -impl ZoneTip20Token

{ - fn add_input_cost(calldata: &[u8], result: PrecompileResult) -> PrecompileResult { - let mut storage = StorageCtx::default(); - let gas_before = storage.gas_used(); - if let Some(err) = charge_input_cost(&mut storage, calldata) { - return err; - } - let input_gas = storage.gas_used().saturating_sub(gas_before); - - result.map(|mut output| { - output.gas_used = output.gas_used.saturating_add(input_gas); - output - }) - } - - fn selector(data: &[u8]) -> Option<[u8; 4]> { - tempo_precompiles::dispatch::selector_from_calldata(data) - } - - fn is_fixed_gas_selector(selector: [u8; 4]) -> bool { - matches!( - selector, - ITIP20::transferCall::SELECTOR - | ITIP20::transferFromCall::SELECTOR - | ITIP20::transferWithMemoCall::SELECTOR - | ITIP20::transferFromWithMemoCall::SELECTOR - | ITIP20::approveCall::SELECTOR - ) - } - - fn apply_fixed_gas(result: PrecompileResult) -> PrecompileResult { - match result { - Ok(mut output) => { - output.gas_used = FIXED_TRANSFER_GAS; - Ok(output) - } - Err(err) => Err(err), - } - } - - /// Check selector-specific privacy/auth rules before delegating. - /// - /// Returns `Some(Ok(reverted_output))` if the call is forbidden. - /// Returns `None` if the call may delegate to vanilla TIP20. - fn precheck( - &self, - selector: [u8; 4], - address: Address, - data: &[u8], - caller: Address, - ) -> Option { - let args = &data[4..]; - - match selector { - ITIP20::balanceOfCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::balanceOfCall, args); - self.enforce_balance_of(call.account, caller) - } - ITIP20::allowanceCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::allowanceCall, args); - self.enforce_allowance(call.owner, call.spender, caller) - } - ITIP20::transferCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferCall, args); - self.enforce_transfer(address, caller, call.to) - } - ITIP20::transferFromCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferFromCall, args); - self.enforce_transfer(address, call.from, call.to) - } - ITIP20::transferWithMemoCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferWithMemoCall, args); - self.enforce_transfer(address, caller, call.to) - } - ITIP20::transferFromWithMemoCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::transferFromWithMemoCall, args); - self.enforce_transfer(address, call.from, call.to) - } - ITIP20::mintCall::SELECTOR => { - if let Some(revert) = self.reject_crossed_mint_caller(caller) { - return Some(revert); - } - let call = decode_or_revert!(ITIP20::mintCall, args); - self.enforce_mint(address, call.to) - } - ITIP20::mintWithMemoCall::SELECTOR => { - if let Some(revert) = self.reject_crossed_mint_caller(caller) { - return Some(revert); - } - let call = decode_or_revert!(ITIP20::mintWithMemoCall, args); - self.enforce_mint(address, call.to) - } - ITIP20::burnCall::SELECTOR | ITIP20::burnWithMemoCall::SELECTOR => { - self.reject_crossed_burn_caller(caller) - } - ITIP20::userRewardInfoCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::userRewardInfoCall, args); - self.enforce_balance_of(call.account, caller) - } - ITIP20::getPendingRewardsCall::SELECTOR => { - let call = decode_or_revert!(ITIP20::getPendingRewardsCall, args); - self.enforce_balance_of(call.account, caller) - } - IRolesAuth::hasRoleCall::SELECTOR => { - let call = decode_or_revert!(IRolesAuth::hasRoleCall, args); - self.enforce_balance_of(call.account, caller) - } - _ => None, - } - } -} - -impl

ZoneTip20Token

-where - P: PolicyCheck + Clone + Send + Sync + 'static, -{ - /// Create a [`DynPrecompile`] for a zone-side TIP-20 token at `address`. - /// - /// The returned precompile: - /// 1. Rejects uninitialized TIP-20-prefix addresses. - /// 2. Checks the 4-byte selector for transfer/mint calls. - /// 3. When a TIP-403 registry is configured, reads `transfer_policy_id` - /// from EVM storage and checks authorization via the - /// [`ZoneTip403ProxyRegistry`]. - /// 4. Delegates to the vanilla `TIP20Token::call()` for execution. - pub fn create( - address: Address, - cfg: &revm::context::CfgEnv, - registry: Option>, - sequencer: Arc, - ) -> DynPrecompile { - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - let token = Self::new(registry, sequencer); - - DynPrecompile::new_stateful( - PrecompileId::Custom("ZoneTip20Token".into()), - move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); - } - - let selector = Self::selector(input.data); - let is_fixed_gas = selector.is_some_and(Self::is_fixed_gas_selector); - if is_fixed_gas && input.gas < FIXED_TRANSFER_GAS { - return Ok(PrecompileOutput::halt( - PrecompileHalt::OutOfGas, - input.reservoir, - )); - } - - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - if is_fixed_gas { u64::MAX } else { input.gas }, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - let storage = StorageCtx::default(); - let finish = |result| { - if is_fixed_gas { - Self::apply_fixed_gas(result) - } else { - result - } - }; - - let mut tip20 = - TIP20Token::from_address(address).expect("TIP20 prefix already verified"); - - if let Err(err) = Self::ensure_initialized(&tip20) { - return finish(Self::add_input_cost(input.data, storage.error_result(err))); - } - - if let Some(selector) = selector - && let Some(revert) = - token.precheck(selector, address, input.data, input.caller) - { - return finish(Self::add_input_cost(input.data, revert)); - } - - finish(tip20.call(input.data, input.caller)) - }) - }, - ) - } -} diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 83c5503be..dde3d3f96 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -1,323 +1,187 @@ -//! Zone-specific TIP-20 token precompile with PolicyCheck-backed authorization. +//! Zone pre-execution rules for the upstream Tempo TIP20 precompile. //! -//! On L1, the vanilla [`TIP20Token`] checks transfer/mint authorization by -//! instantiating a `TIP403Registry` in Rust which reads EVM storage at -//! `0x403C…0000`. On the zone, that storage is empty (defaults to policy 1 = -//! allow-all), so all transfers pass regardless of L1 blacklists. +//! [`TIP20Token`] remains the source of truth for token and TIP403 policy behavior. +//! Before forwarding a call to Tempo, [`TIP20Rules`] applies only zone-specific checks: +//! privacy-gated reads, fixed gas for selected selectors, and bridge mint/burn callers. //! -//! This wrapper intercepts transfer and mint calls, checks authorization -//! against the zone's [`ZoneTip403ProxyRegistry`] (which delegates to -//! [`PolicyCheck`] — cache-first, L1 RPC fallback), and only then delegates -//! to the vanilla `TIP20Token` implementation. +//! Accepted calldata and callers are forwarded unchanged to Tempo against the zone's +//! L1-backed storage provider. The provider preserves ordinary zone-local token state +//! while exposing selected policy values from finalized Tempo L1 state. use alloc::sync::Arc; -mod dispatch; - use alloy_primitives::Address; -use alloy_sol_types::{SolError, SolInterface}; -use revm::precompile::{PrecompileError, PrecompileOutput, PrecompileResult}; -use tempo_contracts::precompiles::TIP20Error; +use alloy_sol_types::{SolCall, SolError}; +use revm::precompile::PrecompileOutput; use tempo_precompiles::{ - Result as TempoResult, - storage::{ContractStorage, StorageCtx}, - tip20::{RolesAuthError, TIP20Token}, + storage::StorageCtx, + tip20::{IRolesAuth, ITIP20}, }; use tempo_zone_contracts::Unauthorized; -use tracing::{trace, warn}; -use zone_primitives::{ - constants::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS}, - policy::AuthRole, -}; +use zone_primitives::constants::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS}; -use crate::{ - policy::PolicyCheck, - tip403_proxy::{AUTH_CHECK_GAS, ZoneTip403ProxyRegistry}, -}; +use crate::execution::{CallCheck, CallRules, ZoneCall}; + +/// Fixed gas charged for TIP20 transfer and approval selectors on the zone. +pub const TIP20_FIXED_TRANSFER_GAS: u64 = 100_000; + +pub(crate) const TIP20_FIXED_GAS_SELECTORS: &[[u8; 4]] = &[ + ITIP20::transferCall::SELECTOR, + ITIP20::transferFromCall::SELECTOR, + ITIP20::transferWithMemoCall::SELECTOR, + ITIP20::transferFromWithMemoCall::SELECTOR, + ITIP20::approveCall::SELECTOR, +]; + +type CallCheckResult = Result<(), PrecompileOutput>; -const FIXED_TRANSFER_GAS: u64 = 100_000; +fn decode_and_check( + args: &[u8], + check: impl FnOnce(C) -> CallCheckResult, +) -> CallCheckResult { + match C::abi_decode_raw_validate(args) { + Ok(decoded) => check(decoded), + Err(_) => Ok(()), + } +} /// Capability trait for resolving the active zone sequencer. /// -/// The zone runtime implements this for its L1-backed state provider so the -/// precompile can enforce sequencer-visible reads without knowing about the -/// concrete provider type. +/// The zone runtime implements this for its L1-backed state provider so the precompile +/// can enforce sequencer-visible reads without knowing about the concrete provider type. pub trait SequencerExt: Send + Sync { /// Return the latest known active sequencer. fn latest_sequencer(&self) -> Option

; } -/// Zone-specific TIP-20 token precompile. -/// -/// Wraps the vanilla [`TIP20Token`] and the [`ZoneTip403ProxyRegistry`] to add -/// optional PolicyCheck-backed authorization for transfers and mints, privacy-gated -/// `balanceOf`/`allowance`, fixed gas for transfer-family calls and `approve`, -/// and operation-specific bridge auth for mint/burn selectors. -pub struct ZoneTip20Token

{ - /// Optional TIP-403 registry wrapper used for transfer and mint-recipient policy checks. - registry: Option>, +/// Zone-specific rules applied before forwarding to upstream [`TIP20Token`]. +#[derive(Clone)] +pub(crate) struct TIP20Rules { /// Sequencer-capable backend used to authorize private reads for the active sequencer. sequencer: Arc, } -impl ZoneTip20Token

{ - /// Create a new wrapper with the given registry. - pub fn new( - registry: Option>, - sequencer: Arc, - ) -> Self { - Self { - registry, - sequencer, - } - } - - /// Enforce the vanilla TIP-20 initialized-token check before zone policy logic. - fn ensure_initialized(tip20: &TIP20Token) -> TempoResult<()> { - if tip20.is_initialized()? { - Ok(()) - } else { - Err(TIP20Error::uninitialized().into()) - } +impl TIP20Rules { + pub(crate) fn new(sequencer: Arc) -> Self { + Self { sequencer } } +} - fn enforce_balance_of(&self, account: Address, caller: Address) -> Option { - if caller == account || self.is_sequencer(caller) { - None - } else { - Some(Ok(Self::unauthorized_output())) - } +impl CallRules for TIP20Rules { + fn fixed_gas(&self, selector: Option<[u8; 4]>) -> Option { + selector + .is_some_and(|selector| TIP20_FIXED_GAS_SELECTORS.contains(&selector)) + .then_some(TIP20_FIXED_TRANSFER_GAS) } - fn enforce_allowance( - &self, - owner: Address, - spender: Address, - caller: Address, - ) -> Option { - if caller == owner || caller == spender || self.is_sequencer(caller) { - None - } else { - Some(Ok(Self::unauthorized_output())) - } - } + /// Apply zone privacy and bridge-path checks before upstream execution. + fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + let Some(selector) = call.selector() else { + return CallCheck::Continue; + }; + let args = &call.data[4..]; - /// Check sender + recipient authorization for a transfer. - /// - /// Returns `Some(revert)` if forbidden, `None` if allowed. - fn enforce_transfer( - &self, - token: Address, - from: Address, - to: Address, - ) -> Option { - let registry = self.registry.as_ref()?; - let policy_id = match Self::resolve_transfer_policy_id(registry, token) { - Ok(id) => id, - Err(e) => { - warn!( - target: "zone::precompile", - %token, error = %e, - "failed to resolve transfer_policy_id, rejecting transfer" - ); - return Some(Err(e)); + let result = match selector { + ITIP20::mintCall::SELECTOR | ITIP20::mintWithMemoCall::SELECTOR => { + self.check_mint_auth(call.caller) } + ITIP20::burnCall::SELECTOR | ITIP20::burnWithMemoCall::SELECTOR => { + self.check_burn_auth(call.caller) + } + ITIP20::balanceOfCall::SELECTOR => { + decode_and_check::(args, |decoded| { + self.check_balance_read(decoded.account, call.caller) + }) + } + ITIP20::allowanceCall::SELECTOR => { + decode_and_check::(args, |decoded| { + self.check_allowance_read(decoded.owner, decoded.spender, call.caller) + }) + } + IRolesAuth::hasRoleCall::SELECTOR => { + decode_and_check::(args, |decoded| { + self.check_balance_read(decoded.account, call.caller) + }) + } + _ => Ok(()), }; - trace!( - target: "zone::precompile", - %token, %from, %to, policy_id, - "ZoneTip20Token: checking transfer authorization" - ); - - match registry.is_transfer_authorized(policy_id, from, to) { - Ok(true) => None, - Ok(false) => { - trace!( - target: "zone::precompile", - %from, %to, policy_id, "transfer not authorized" - ); - Some(Ok(Self::policy_forbids_output())) - } - Err(e) => Some(Err(e)), + match result { + Ok(()) => CallCheck::Continue, + Err(output) => CallCheck::Return(Ok(output)), } } +} - /// Check mint recipient authorization. - /// - /// Returns `Some(revert)` if forbidden, `None` if allowed. - /// Resolution errors are treated as allow because mints are triggered by - /// deposit system transactions whose policy is already enforced on L1. - fn enforce_mint(&self, token: Address, to: Address) -> Option { - let registry = self.registry.as_ref()?; - let policy_id = match Self::resolve_transfer_policy_id(registry, token) { - Ok(id) => id, - Err(e) => { - warn!( - target: "zone::precompile", - %token, error = %e, - "failed to resolve transfer_policy_id for mint, deferring to L1 enforcement" - ); - return None; - } - }; - - trace!( - target: "zone::precompile", - %token, %to, policy_id, - "ZoneTip20Token: checking mint recipient authorization" - ); +fn unauthorized_output() -> PrecompileOutput { + StorageCtx::default().revert_output(Unauthorized {}.abi_encode().into()) +} - match registry.is_authorized(policy_id, to, AuthRole::MintRecipient) { - Ok(true) => None, - Ok(false) => { - trace!(target: "zone::precompile", %to, policy_id, "mint recipient not authorized"); - Some(Ok(Self::policy_forbids_output())) - } - Err(e) => Some(Err(e)), +impl TIP20Rules { + fn check_balance_read(&self, owner: Address, caller: Address) -> CallCheckResult { + if caller == owner { + return Ok(()); } + self.check_sequencer(caller) } - /// Reject the system caller that is only allowed on the opposite bridge path. - fn reject_crossed_mint_caller(&self, caller: Address) -> Option { - if caller == ZONE_OUTBOX_ADDRESS { - Some(Ok(Self::roles_unauthorized_output())) - } else { - None + fn check_allowance_read( + &self, + owner: Address, + spender: Address, + caller: Address, + ) -> CallCheckResult { + if caller == spender { + return Ok(()); } + self.check_balance_read(owner, caller) } - /// Reject the system caller that is only allowed on the opposite bridge path. - fn reject_crossed_burn_caller(&self, caller: Address) -> Option { - if caller == ZONE_INBOX_ADDRESS { - Some(Ok(Self::roles_unauthorized_output())) - } else { - None + fn check_mint_auth(&self, caller: Address) -> CallCheckResult { + if caller != ZONE_INBOX_ADDRESS { + return Err(unauthorized_output()); } + Ok(()) } - /// Resolve the `transfer_policy_id` for a token. - fn resolve_transfer_policy_id( - registry: &ZoneTip403ProxyRegistry

, - token: Address, - ) -> Result { - registry.resolve_transfer_policy_id(token) + fn check_burn_auth(&self, caller: Address) -> CallCheckResult { + if caller != ZONE_OUTBOX_ADDRESS { + return Err(unauthorized_output()); + } + Ok(()) } - fn is_sequencer(&self, caller: Address) -> bool { - self.sequencer + fn check_sequencer(&self, caller: Address) -> CallCheckResult { + if self + .sequencer .latest_sequencer() - .is_some_and(|sequencer| caller == sequencer) - } - - fn unauthorized_output() -> PrecompileOutput { - StorageCtx::default().revert_output(Unauthorized {}.abi_encode().into()) - } - - fn roles_unauthorized_output() -> PrecompileOutput { - StorageCtx::default().revert_output(RolesAuthError::unauthorized().selector().into()) - } - - /// Build a reverted output with the `policyForbids()` error selector. - fn policy_forbids_output() -> PrecompileOutput { - PrecompileOutput::revert( - AUTH_CHECK_GAS, - tempo_contracts::precompiles::TIP20Error::policy_forbids() - .selector() - .into(), - StorageCtx::default().reservoir(), - ) + .is_none_or(|sequencer| caller != sequencer) + { + return Err(unauthorized_output()); + } + Ok(()) } } #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{TestContext, test_context, test_storage_provider}; use alloy::primitives::{Address, Bytes, U256, address}; - use alloy_evm::{ - EvmInternals, - precompiles::{DynPrecompile, Precompile as AlloyEvmPrecompile, PrecompileInput}, - }; - use alloy_sol_types::SolCall; + use alloy_evm::precompiles::DynPrecompile; + use alloy_sol_types::{SolCall, SolError, SolInterface}; use revm::precompile::{PrecompileHalt, PrecompileResult}; + use tempo_contracts::precompiles::TIP20Error; use tempo_precompiles::{ PATH_USD_ADDRESS, - tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, TIP20Token}, + storage::StorageCtx, + tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, RolesAuthError, TIP20Token}, }; + use tempo_zone_contracts::Unauthorized; - type TestResult = Result>; - - #[derive(Clone, Default)] - struct MockPolicyProvider { - transfer_authorized: bool, - mint_authorized: bool, - policy_id: u64, - fail_policy_id_resolution: bool, - } - - impl MockPolicyProvider { - fn allow_all() -> Self { - Self { - transfer_authorized: true, - mint_authorized: true, - policy_id: 1, - fail_policy_id_resolution: false, - } - } - - fn failing() -> Self { - Self { - fail_policy_id_resolution: true, - ..Default::default() - } - } - } - - impl PolicyCheck for MockPolicyProvider { - fn is_authorized( - &self, - _policy_id: u64, - _user: Address, - role: AuthRole, - ) -> Result { - let authorized = match role { - AuthRole::MintRecipient => self.mint_authorized, - _ => self.transfer_authorized, - }; - Ok(authorized) - } - - fn resolve_transfer_policy_id(&self, _token: Address) -> Result { - if self.fail_policy_id_resolution { - return Err(PrecompileError::Fatal("RPC unavailable".into())); - } - Ok(self.policy_id) - } - - fn policy_type_sync( - &self, - _policy_id: u64, - ) -> Result - { - Ok(tempo_contracts::precompiles::ITIP403Registry::PolicyType::BLACKLIST) - } - - fn compound_policy_data( - &self, - _policy_id: u64, - ) -> Result<(u64, u64, u64), PrecompileError> { - Ok((self.policy_id, self.policy_id, self.policy_id)) - } - - fn policy_exists(&self, _policy_id: u64) -> Result { - Ok(true) - } - - fn policy_id_counter(&self) -> u64 { - self.policy_id - } - } + use crate::test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, + test_storage_provider, + }; #[derive(Clone, Copy)] struct MockSequencer { @@ -337,20 +201,15 @@ mod tests { bob: Address, spender: Address, sequencer: Address, - issuer: Address, precompile: DynPrecompile, } impl PrecompileHarness { - fn new(policy: MockPolicyProvider) -> TestResult { - Self::new_with_registry(Some(policy)) + fn new(l1_reader: MockL1Reader) -> eyre::Result { + Self::new_with_l1(l1_reader) } - fn new_without_registry() -> TestResult { - Self::new_with_registry(None) - } - - fn new_with_registry(policy: Option) -> TestResult { + fn new_with_l1(l1_reader: MockL1Reader) -> eyre::Result { let token = PATH_USD_ADDRESS; let admin = address!("0x00000000000000000000000000000000000000a1"); let alice = address!("0x00000000000000000000000000000000000000a2"); @@ -362,7 +221,12 @@ mod tests { { let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); - StorageCtx::enter(&mut storage, || -> TestResult { + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + StorageCtx::default().sstore( + zone_primitives::constants::TEMPO_STATE_ADDRESS, + crate::tempo_state::slots::TEMPO_BLOCK_NUMBER, + U256::from(7u64), + )?; let mut token_contract = TIP20Token::from_address(token).expect("PATH_USD must be valid"); token_contract.initialize( @@ -402,10 +266,12 @@ mod tests { })?; } - let precompile = ZoneTip20Token::create( + l1_reader.seed_transfer_policy_id(token, 7); + + let env = test_l1_env(&ctx, l1_reader); + let precompile = crate::create_tip20_precompile( token, - &ctx.cfg, - policy.map(ZoneTip403ProxyRegistry::new), + &env, Arc::new(MockSequencer { address: Some(sequencer), }), @@ -418,7 +284,6 @@ mod tests { bob, spender, sequencer, - issuer, precompile, }) } @@ -430,23 +295,19 @@ mod tests { gas: u64, is_static: bool, ) -> PrecompileResult { - AlloyEvmPrecompile::call( + call_precompile( + &mut self.ctx, &self.precompile, - PrecompileInput { - data: &calldata, - caller, - internals: EvmInternals::from_context(&mut self.ctx), - gas, - reservoir: 0, - value: U256::ZERO, - is_static, - target_address: self.token, - bytecode_address: self.token, - }, + caller, + &calldata, + gas, + is_static, + self.token, + self.token, ) } - fn balance_of(&mut self, account: Address) -> TestResult { + fn balance_of(&mut self, account: Address) -> eyre::Result { let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || { let token = TIP20Token::from_address(self.token).expect("token must exist"); @@ -454,7 +315,7 @@ mod tests { }) } - fn allowance(&mut self, owner: Address, spender: Address) -> TestResult { + fn allowance(&mut self, owner: Address, spender: Address) -> eyre::Result { let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || { let token = TIP20Token::from_address(self.token).expect("token must exist"); @@ -464,8 +325,8 @@ mod tests { } #[test] - fn balance_of_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn balance_of_enforces_account_or_sequencer_access() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let calldata: Bytes = ITIP20::balanceOfCall { account: harness.alice, } @@ -492,8 +353,8 @@ mod tests { } #[test] - fn allowance_enforces_owner_spender_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn allowance_enforces_owner_spender_or_sequencer_access() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let calldata: Bytes = ITIP20::allowanceCall { owner: harness.alice, spender: harness.spender, @@ -527,8 +388,8 @@ mod tests { } #[test] - fn wrapper_without_policy_registry_still_enforces_privacy_and_fixed_gas() -> TestResult { - let mut harness = PrecompileHarness::new_without_registry()?; + fn wrapper_still_enforces_privacy_and_fixed_gas() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let private_balance = harness.call( harness.bob, @@ -537,7 +398,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, true, )?; assert!(private_balance.is_revert()); @@ -554,28 +415,25 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(transfer.is_success()); - assert_eq!(transfer.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(harness.balance_of(harness.bob)?, U256::from(12_345u64)); Ok(()) } #[test] - fn uninitialized_token_rejects_before_policy_precheck() -> TestResult { + fn uninitialized_token_rejects_before_policy_read() -> eyre::Result<()> { let token = address!("20C0000000000000000000000000000000000999"); let caller = address!("0x00000000000000000000000000000000000000a2"); let to = address!("0x00000000000000000000000000000000000000a3"); let mut ctx = test_context(); - let precompile = ZoneTip20Token::create( - token, - &ctx.cfg, - Some(ZoneTip403ProxyRegistry::new(MockPolicyProvider::failing())), - Arc::new(MockSequencer { address: None }), - ); + let env = test_l1_env(&ctx, MockL1Reader::failing()); + let precompile = + crate::create_tip20_precompile(token, &env, Arc::new(MockSequencer { address: None })); let calldata: Bytes = ITIP20::transferCall { to, amount: U256::from(1u64), @@ -583,19 +441,15 @@ mod tests { .abi_encode() .into(); - let result = AlloyEvmPrecompile::call( + let result = call_precompile( + &mut ctx, &precompile, - PrecompileInput { - data: &calldata, - caller, - internals: EvmInternals::from_context(&mut ctx), - gas: FIXED_TRANSFER_GAS, - reservoir: 0, - value: U256::ZERO, - is_static: false, - target_address: token, - bytecode_address: token, - }, + caller, + &calldata, + TIP20_FIXED_TRANSFER_GAS, + false, + token, + token, )?; assert!(result.is_revert()); @@ -608,8 +462,34 @@ mod tests { } #[test] - fn bridge_auth_rejects_crossed_system_calls_and_keeps_allowed_paths() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn malformed_calldata_uses_upstream_dispatch() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + + let balance_of = harness.call( + harness.alice, + Bytes::from(ITIP20::balanceOfCall::SELECTOR.to_vec()), + 100_000, + true, + )?; + assert!(balance_of.is_revert()); + assert_eq!(balance_of.bytes, Bytes::new()); + + let transfer = harness.call( + harness.alice, + Bytes::from(ITIP20::transferCall::SELECTOR.to_vec()), + TIP20_FIXED_TRANSFER_GAS, + false, + )?; + assert!(transfer.is_revert()); + assert_eq!(transfer.bytes, Bytes::new()); + assert_eq!(transfer.gas_used, TIP20_FIXED_TRANSFER_GAS); + + Ok(()) + } + + #[test] + fn bridge_auth_rejects_crossed_system_calls_and_keeps_allowed_paths() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let inbox_mint = harness.call( ZONE_INBOX_ADDRESS, @@ -671,37 +551,12 @@ mod tests { Bytes::from(RolesAuthError::unauthorized().selector().to_vec()) ); - let issuer_mint = harness.call( - harness.issuer, - ITIP20::mintCall { - to: harness.issuer, - amount: U256::from(25_000u64), - } - .abi_encode() - .into(), - 100_000, - false, - )?; - assert!(issuer_mint.is_success()); - - let issuer_burn = harness.call( - harness.issuer, - ITIP20::burnCall { - amount: U256::from(5_000u64), - } - .abi_encode() - .into(), - 100_000, - false, - )?; - assert!(issuer_burn.is_success()); - Ok(()) } #[test] - fn fixed_gas_selectors_charge_exactly_one_hundred_thousand_gas() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn fixed_gas_selectors_charge_exactly_one_hundred_thousand_gas() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let approve = harness.call( harness.alice, @@ -711,10 +566,10 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(approve.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(approve.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(approve.state_gas_used, 0); let approve_update = harness.call( @@ -725,10 +580,10 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(approve_update.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(approve_update.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(approve_update.state_gas_used, 0); let transfer_new = harness.call( @@ -739,10 +594,10 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(transfer_new.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer_new.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(transfer_new.state_gas_used, 0); let transfer_existing = harness.call( @@ -753,10 +608,10 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(transfer_existing.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer_existing.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(transfer_existing.state_gas_used, 0); let transfer_with_memo = harness.call( @@ -768,10 +623,10 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(transfer_with_memo.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer_with_memo.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(transfer_with_memo.state_gas_used, 0); let transfer_from = harness.call( @@ -783,10 +638,10 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(transfer_from.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer_from.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(transfer_from.state_gas_used, 0); let transfer_from_with_memo = harness.call( @@ -799,18 +654,18 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; - assert_eq!(transfer_from_with_memo.gas_used, FIXED_TRANSFER_GAS); + assert_eq!(transfer_from_with_memo.gas_used, TIP20_FIXED_TRANSFER_GAS); assert_eq!(transfer_from_with_memo.state_gas_used, 0); Ok(()) } #[test] - fn fixed_gas_selectors_fail_out_of_gas_below_threshold() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn fixed_gas_selectors_fail_out_of_gas_below_threshold() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; for calldata in [ ITIP20::transferCall { @@ -849,7 +704,7 @@ mod tests { .into(), ] { let output = harness - .call(harness.alice, calldata, FIXED_TRANSFER_GAS - 1, false) + .call(harness.alice, calldata, TIP20_FIXED_TRANSFER_GAS - 1, false) .expect("out of gas is returned as a halted precompile output"); assert!(output.is_halt()); assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); @@ -859,8 +714,8 @@ mod tests { } #[test] - fn fixed_gas_keeps_allowance_and_balance_state_changes_intact() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn fixed_gas_keeps_allowance_and_balance_state_changes_intact() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let approve = harness.call( harness.alice, @@ -870,7 +725,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(approve.is_success()); @@ -887,7 +742,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(transfer.is_success()); @@ -897,58 +752,100 @@ mod tests { } #[test] - fn user_reward_info_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = ITIP20::userRewardInfoCall { - account: harness.alice, - } - .abi_encode() - .into(); - - // Owner can query their own reward info - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert!(owner.is_success()); - - // Sequencer can query anyone's reward info - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert!(sequencer.is_success()); + fn l1_blacklist_denies_transfer_sender_and_recipient() -> eyre::Result<()> { + let sender_l1 = MockL1Reader::with_policy_id(42); + let mut sender_harness = PrecompileHarness::new(sender_l1.clone())?; + sender_l1.seed_blacklist_policy(42, &[sender_harness.alice])?; + let sender_result = sender_harness.call( + sender_harness.alice, + ITIP20::transferCall { + to: sender_harness.bob, + amount: U256::from(100u64), + } + .abi_encode() + .into(), + TIP20_FIXED_TRANSFER_GAS, + false, + )?; + assert!(sender_result.is_revert()); + assert_eq!( + sender_result.bytes, + Bytes::from(TIP20Error::policy_forbids().selector().to_vec()) + ); + assert_eq!(sender_result.gas_used, TIP20_FIXED_TRANSFER_GAS); - // Outsider is rejected - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); + let recipient_l1 = MockL1Reader::with_policy_id(42); + let mut recipient_harness = PrecompileHarness::new(recipient_l1.clone())?; + recipient_l1.seed_blacklist_policy(42, &[recipient_harness.bob])?; + let recipient_result = recipient_harness.call( + recipient_harness.alice, + ITIP20::transferCall { + to: recipient_harness.bob, + amount: U256::from(100u64), + } + .abi_encode() + .into(), + TIP20_FIXED_TRANSFER_GAS, + false, + )?; + assert!(recipient_result.is_revert()); + assert_eq!( + recipient_result.bytes, + Bytes::from(TIP20Error::policy_forbids().selector().to_vec()) + ); Ok(()) } #[test] - fn get_pending_rewards_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; - let calldata: Bytes = ITIP20::getPendingRewardsCall { - account: harness.alice, - } - .abi_encode() - .into(); + fn l1_policy_denies_mint_recipient() -> eyre::Result<()> { + let l1 = MockL1Reader::with_policy_id(43); + let mut harness = PrecompileHarness::new(l1.clone())?; + l1.seed_blacklist_policy(43, &[harness.bob])?; - // Owner can query their own pending rewards - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert!(owner.is_success()); + let result = harness.call( + ZONE_INBOX_ADDRESS, + ITIP20::mintCall { + to: harness.bob, + amount: U256::from(100u64), + } + .abi_encode() + .into(), + 100_000, + false, + )?; + assert!(result.is_revert()); + assert_eq!( + result.bytes, + Bytes::from(TIP20Error::policy_forbids().selector().to_vec()) + ); - // Sequencer can query anyone's pending rewards - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert!(sequencer.is_success()); + Ok(()) + } - // Outsider is rejected - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); + #[test] + fn transfer_policy_id_reflects_l1_policy_storage_not_local_default() -> eyre::Result<()> { + let l1_policy_id = 99; + let mut harness = PrecompileHarness::new(MockL1Reader::with_policy_id(l1_policy_id))?; + + let result = harness.call( + harness.alice, + ITIP20::transferPolicyIdCall {}.abi_encode().into(), + 100_000, + true, + )?; + assert!(result.is_success()); + assert_eq!( + ITIP20::transferPolicyIdCall::abi_decode_returns(&result.bytes)?, + l1_policy_id + ); Ok(()) } #[test] - fn transfer_fails_closed_on_policy_resolution_error() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::failing())?; + fn transfer_fails_closed_on_policy_resolution_error() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::failing())?; let calldata: Bytes = ITIP20::transferCall { to: harness.bob, @@ -967,8 +864,8 @@ mod tests { } #[test] - fn mint_defers_to_l1_on_policy_resolution_error() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::failing())?; + fn mint_fails_on_l1_storage_error() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::failing())?; let calldata: Bytes = ITIP20::mintCall { to: harness.alice, @@ -977,18 +874,18 @@ mod tests { .abi_encode() .into(); - let result = harness.call(harness.issuer, calldata, 100_000, false); + let result = harness.call(ZONE_INBOX_ADDRESS, calldata, 100_000, false); assert!( - result.is_ok(), - "mint must proceed when policy resolution errors (L1 enforces policy at deposit time)" + result.is_err(), + "mint must fail when upstream TIP-20 cannot read L1 policy storage" ); Ok(()) } #[test] - fn has_role_enforces_account_or_sequencer_access() -> TestResult { - let mut harness = PrecompileHarness::new(MockPolicyProvider::allow_all())?; + fn has_role_enforces_account_or_sequencer_access() -> eyre::Result<()> { + let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; let calldata: Bytes = IRolesAuth::hasRoleCall { account: harness.alice, role: *ISSUER_ROLE, diff --git a/docs/ZONES.md b/docs/ZONES.md index 68c4fd5ca..7c7ea260d 100644 --- a/docs/ZONES.md +++ b/docs/ZONES.md @@ -527,9 +527,9 @@ Zones inherit the Tempo L1 EVM but replace, disable, or pass through each precom | Precompile | Address | Zone Behavior | |------------|---------|---------------| | Standard EVM (ecrecover, SHA-256, etc.) | `0x01`–`0x0a`, `0x0100` on T1C+ | **Unchanged** — standard Ethereum precompiles inherited from Tempo's active hardfork (Prague pre-T1C, Osaka at T1C+) are available as-is. | -| TIP-20 tokens | `0x20C0…` prefix | **Replaced** — routed through `ZoneTip20Token`, which adds privacy (caller-scoped reads), fixed gas for transfers, bridge-auth for mint/burn, and TIP-403 policy enforcement via the L1-synced cache. | +| TIP-20 tokens | `0x20C0…` prefix | **Adapted** — upstream Tempo TIP-20 business logic runs over zone-local token state and exact-block L1 policy state, with zone privacy (caller-scoped reads), fixed gas for transfers, and bridge authorization for mint/burn. | | TIP20Factory | `0x20FC…0000` | **Replaced** — `ZoneTokenFactory` exposes only `enableToken(address, name, symbol, currency)`, called by ZoneInbox during `advanceTempo` to initialize bridged tokens. | -| TIP403Registry | `0x403C…0000` | **Replaced** — read-only `ZoneTip403ProxyRegistry` serves authorization queries from a cache-first, L1-RPC-fallback provider. Mutating calls (`createPolicy`, `modifyPolicyWhitelist`, etc.) revert — policy state is managed on L1. | +| TIP403Registry | `0x403C…0000` | **Adapted** — the upstream Tempo registry executes read-only against raw L1 storage at the exact finalized block recorded in `TempoState`. Mutating calls (`createPolicy`, `modifyPolicyWhitelist`, etc.) revert because policy state is managed on L1. | | TipFeeManager | `0xfeec…0000` | **Present** — the precompile is still registered, but its liquidity pools are not used by transactions. The zone executor overrides `validatorTokens` to match each transaction's fee token, so the FeeAMM swap path is bypassed and fees are collected directly in the user's token. | | StablecoinDEX | `0xdec0…0000` | **Disabled** — not registered on zones, so the address behaves like an empty account. Users on zones can trade on the StablecoinDEX on Tempo via the bridge. | | NonceManager | `0x4E4F…0000` | **Unchanged** — same implementation as L1, runs locally on zone state. | diff --git a/specs/spec.md b/specs/spec.md index 0440fd35f..dff9fe2da 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -818,7 +818,7 @@ Current callers: - `ZoneInbox`: `currentDepositQueueHash` and encryption keys from the portal - `ZoneConfig`: sequencer address, token registry from the portal -TIP-403 policy authorization on the zone is handled by a dedicated read-only proxy precompile (at the same address as the L1 `TIP403Registry`), which resolves policy queries via the zone node's policy provider rather than calling `readTempoStorageSlot` directly. +TIP-403 policy authorization on the zone executes Tempo's registry precompile at the canonical address over raw L1 registry storage pinned to the current finalized `tempoBlockNumber`. ### Staleness and Finality @@ -834,7 +834,7 @@ Zones inherit compliance policies from Tempo automatically. Token issuers set tr ### Policy Enforcement on Zones -The zone has a `TIP403Registry` deployed at the same address as on Tempo. This contract is read-only and does not support writing policies. Its `isAuthorized` function reads policy state from Tempo via `TempoState.readTempoStorageSlot()`. +The zone has a `TIP403Registry` deployed at the same address as on Tempo. This contract is read-only and does not support writing policies. Its read methods execute Tempo's registry logic over raw L1 policy storage at the finalized `TempoState.tempoBlockNumber` anchor. Zone-side TIP-20 transfers check `isAuthorized(policyId, from)` and `isAuthorized(policyId, to)` before executing. If either check fails, the transfer reverts. @@ -2045,7 +2045,7 @@ Reads the sequencer address, token registry, and encryption key from the portal ### TIP-403 Registry -Deployed at the same address as on Tempo. Read-only on the zone. Its `isAuthorized(policyId, account)` function reads policy state from Tempo via `TempoState.readTempoStorageSlot()`. Zone-side TIP-20 transfers call this automatically. +Deployed at the same address as on Tempo. Read-only on the zone. Its read methods execute Tempo's registry logic over raw L1 policy storage at the finalized `TempoState.tempoBlockNumber` anchor. Zone-side TIP-20 transfers call this automatically.
From 0c58d546873d87967d0ea70a61fcc8a658b6bb72 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 12:24:30 +0200 Subject: [PATCH 16/30] fix: seed all slots so test fixtures without rpc don't hang --- crates/node/tests/it/utils.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 907d14e22..bf581153e 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -3310,6 +3310,17 @@ impl L1Fixture { let refunds_slot = B256::with_last_byte(10); let path_usd_config_slot = portal_token_config_slot(PATH_USD_ADDRESS); let enabled_token_config = enabled_deposits_active_token_config(); + let outbox_receive_policy_slot = + ZONE_OUTBOX_ADDRESS.mapping_slot(tip403_registry_slots::RECEIVE_POLICIES); + + // Local fixtures have no RPC fallback. A withdrawal transfers to the outbox, so seed the + // absence of its address-level receive policy as baseline raw L1 state. + cache.set( + TIP403_REGISTRY_ADDRESS, + B256::from(outbox_receive_policy_slot.to_be_bytes()), + 0, + B256::ZERO, + ); for block in 0..=num_blocks { let mut sequencer_bytes = [0u8; 32]; From 474772f6a6726c1843f8baa01a67c2e5288e9d8b Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 16:57:01 +0200 Subject: [PATCH 17/30] fix: cache enabled tokens transfer policy id --- crates/node/tests/it/utils.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index bf581153e..0e4024665 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -3381,6 +3381,20 @@ impl L1Fixture { } } + fn seed_enabled_token_policy_state(&self, block_number: u64, tokens: &[EnabledToken]) { + for cache in self.caches.lock().unwrap().iter() { + let mut cache = cache.write(); + for token in tokens { + seed_raw_tip20_policy_id( + &mut cache, + block_number, + token.token, + ALLOW_ALL_POLICY_ID, + ); + } + } + } + /// Build a [`TempoHeader`] for the next L1 block. fn next_header(&mut self) -> TempoHeader { let number = self.next_block_number; @@ -3437,6 +3451,13 @@ impl L1Fixture { queue: &DepositQueue, events: L1PortalEvents, ) { + let block_number = block.header.inner.number; + self.seed_enabled_token_policy_state(block_number, &events.enabled_tokens); + for deposit in &events.deposits { + if let L1Deposit::Regular(deposit) = deposit { + self.seed_no_receive_policy_at(block_number, deposit.to); + } + } queue.enqueue(block.header.clone(), events, vec![]); } @@ -3465,6 +3486,7 @@ impl L1Fixture { tokens: Vec, ) { let header = self.next_header(); + self.seed_enabled_token_policy_state(header.inner.number, &tokens); let events = L1PortalEvents { deposits: vec![], enabled_tokens: tokens, From 2dfc2fb3bd4b64bae6e64716e80c0254b0e47801 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 17:27:15 +0200 Subject: [PATCH 18/30] feat: make `CallRules` future-proof --- crates/precompiles/src/tip403_proxy/mod.rs | 2 +- crates/precompiles/src/ztip20/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 2735c7230..4129ad2ef 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -35,7 +35,7 @@ alloy_sol_types::sol! { pub(crate) struct Tip403Rules; impl CallRules for Tip403Rules { - fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { if call .selector() .is_some_and(|selector| TIP403_MUTATING_SELECTORS.contains(&selector)) diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index dde3d3f96..2cc740425 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -75,7 +75,7 @@ impl CallRules for TIP20Rules { } /// Apply zone privacy and bridge-path checks before upstream execution. - fn check_call(&self, call: ZoneCall<'_>) -> CallCheck { + fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { let Some(selector) = call.selector() else { return CallCheck::Continue; }; From 0d558576359e1ca788e696dca30c07acb36f272a Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 08:25:33 +0200 Subject: [PATCH 19/30] test(precompiles): use composed hardfork context --- crates/node/tests/it/utils.rs | 2 -- crates/precompiles/src/tip403_proxy/mod.rs | 18 ++++-------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 0e4024665..78cc451c9 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -904,7 +904,6 @@ impl ZoneTestNode { seed_local_policy_cache(&policy_cache); let mut cache = l1_state_cache.write(); seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); - cache.extend_hardfork_schedule(u64::MAX, [(0, TempoHardfork::T8)]); } let node_handle = NodeBuilder::new(node_config) @@ -3348,7 +3347,6 @@ impl L1Fixture { } seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); - cache.extend_hardfork_schedule(u64::MAX, [(0, TempoHardfork::T8)]); cache.update_anchor(NumHash { number: num_blocks, hash: B256::ZERO, diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 4129ad2ef..091ea6e5a 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -56,6 +56,7 @@ mod tests { use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Bytes, U256, address}; use revm::precompile::{PrecompileError, PrecompileOutput}; + use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{DelegateCallNotAllowed, storage::PrecompileStorageProvider}; use crate::{ @@ -80,6 +81,7 @@ mod tests { impl RegistryHarness { fn new(l1: MockL1Reader) -> Self { let mut ctx = test_context(); + ctx.cfg.spec = TempoHardfork::T8; test_storage_provider(&mut ctx, u64::MAX, false) .sstore( zone_primitives::constants::TEMPO_STATE_ADDRESS, @@ -284,13 +286,12 @@ mod tests { output.bytes, Bytes::from(DelegateCallNotAllowed {}.abi_encode()) ); - assert!(reader.hardfork_requests().is_empty()); assert!(reader.storage_requests().is_empty()); Ok(()) } #[test] - fn registry_reads_every_slot_and_hardfork_at_the_exact_tempo_anchor() -> eyre::Result<()> { + fn registry_reads_every_slot_at_the_exact_tempo_anchor() -> eyre::Result<()> { let reader = seeded_reader(); let mut harness = RegistryHarness::new(reader.clone()); let call = ITIP403Registry::isAuthorizedCall { @@ -301,7 +302,6 @@ mod tests { let output = harness.call(&call, u64::MAX)?; assert!(output.is_success()); - assert_eq!(reader.hardfork_requests(), vec![ANCHOR]); let requests = reader.storage_requests(); assert!(!requests.is_empty()); assert!(requests.iter().all(|(_, _, block)| *block == ANCHOR)); @@ -309,29 +309,19 @@ mod tests { } #[test] - fn anchored_hardfork_and_storage_failures_fail_closed() { + fn anchored_storage_failures_fail_closed() { let call = ITIP403Registry::isAuthorizedCall { policyId: 5, user: ALICE, } .abi_encode(); - let hardfork_reader = MockL1Reader::failing_hardfork(); - let mut harness = RegistryHarness::new(hardfork_reader.clone()); - assert!(matches!( - harness.call(&call, u64::MAX), - Err(PrecompileError::Fatal(message)) if message.contains("hardfork unavailable") - )); - assert_eq!(hardfork_reader.hardfork_requests(), vec![ANCHOR]); - assert!(hardfork_reader.storage_requests().is_empty()); - let storage_reader = MockL1Reader::failing_storage(); let mut harness = RegistryHarness::new(storage_reader.clone()); assert!(matches!( harness.call(&call, u64::MAX), Err(PrecompileError::Fatal(message)) if message.contains("RPC unavailable") )); - assert_eq!(storage_reader.hardfork_requests(), vec![ANCHOR]); assert!( storage_reader .storage_requests() From 7e01f9e6face66d1518b451b73fd86d74f87b041 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 17:46:52 +0200 Subject: [PATCH 20/30] wip: zone outbox precompile --- crates/contracts/src/precompiles/mod.rs | 4 +- .../precompiles/{zone_outbox.rs => outbox.rs} | 42 +- .../contracts/src/precompiles/zone_portal.rs | 4 +- crates/payload/src/builder.rs | 10 +- crates/precompiles/src/lib.rs | 2 + crates/precompiles/src/outbox/dispatch.rs | 176 +++++ crates/precompiles/src/outbox/mod.rs | 729 ++++++++++++++++++ crates/precompiles/src/tempo_state.rs | 5 + crates/sequencer/src/monitor.rs | 8 +- crates/sequencer/src/settlement.rs | 18 +- xtask/src/demo_blacklist.rs | 4 +- xtask/src/demo_swap_and_deposit.rs | 8 +- 12 files changed, 979 insertions(+), 31 deletions(-) rename crates/contracts/src/precompiles/{zone_outbox.rs => outbox.rs} (63%) create mode 100644 crates/precompiles/src/outbox/dispatch.rs create mode 100644 crates/precompiles/src/outbox/mod.rs diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index bd20bb0a6..732c6851d 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -1,18 +1,18 @@ pub mod common; +pub mod outbox; pub mod swap_and_deposit_router; pub mod tempo_state; pub mod zone_factory; pub mod zone_inbox; -pub mod zone_outbox; pub mod zone_portal; pub mod zone_tx_context; pub use common::*; +pub use outbox::*; pub use swap_and_deposit_router::*; pub use tempo_state::*; pub use zone_factory::*; pub use zone_inbox::*; -pub use zone_outbox::*; pub use zone_portal::*; pub use zone_tx_context::*; diff --git a/crates/contracts/src/precompiles/zone_outbox.rs b/crates/contracts/src/precompiles/outbox.rs similarity index 63% rename from crates/contracts/src/precompiles/zone_outbox.rs rename to crates/contracts/src/precompiles/outbox.rs index 907118bd7..1e7a2461e 100644 --- a/crates/contracts/src/precompiles/zone_outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -1,11 +1,10 @@ //! `ZoneOutbox` — deployed on the Zone L2. -pub use ZoneOutbox::LastBatch; +pub use IZoneOutbox::{LastBatch, PendingWithdrawal, StaticCallNotAllowed}; crate::sol! { #[derive(Debug)] - contract ZoneOutbox { - // -- Shared types -- + contract IZoneOutbox { struct LastBatch { bytes32 withdrawalQueueHash; @@ -26,6 +25,19 @@ crate::sol! { bytes revealTo; } + struct Withdrawal { + address token; + bytes32 senderTag; + address to; + uint128 amount; + uint128 fee; + bytes32 memo; + uint64 gasLimit; + address fallbackRecipient; + bytes callbackData; + bytes encryptedSender; + } + // -- Events -- event WithdrawalRequested( @@ -44,6 +56,10 @@ crate::sol! { event BatchFinalized(bytes32 indexed withdrawalQueueHash, uint64 withdrawalBatchIndex); + event TempoGasRateUpdated(uint128 tempoGasRate); + + event MaxWithdrawalsPerBlockUpdated(uint256 maxWithdrawalsPerBlock); + // -- Errors -- error OnlySequencer(); @@ -52,9 +68,22 @@ crate::sol! { error InvalidWithdrawalCount(uint256 actual, uint256 expected); error InvalidEncryptedSenderCount(uint256 actual, uint256 expected); error InvalidEncryptedSenderLength(uint256 actual, uint256 expected); + error InvalidFallbackRecipient(); + error CallbackDataTooLarge(); + error GasFeeRateTooHigh(); + error TransferFailed(); + error InvalidBlockNumber(); + error TooManyWithdrawalsThisBlock(); + error InvalidRevealTo(); + error InvalidCurrentTxHash(); + error TokenNotEnabled(); + error StaticCallNotAllowed(); // -- View functions -- + function config() external view returns (address); + function tempoGasRate() external view returns (uint128); + function maxWithdrawalsPerBlock() external view returns (uint256); function lastBatch() external view returns (LastBatch memory); function withdrawalBatchIndex() external view returns (uint64); function lastFinalizedTimestamp() external view returns (uint64); @@ -64,10 +93,17 @@ crate::sol! { function getPendingWithdrawals() external view returns (PendingWithdrawal[] memory); function consumeFallbackRecipient(uint64 fallbackNonce) external returns (address recipient); function calculateWithdrawalFee(uint64 gasLimit) external view returns (uint128 fee); + function MAX_CALLBACK_DATA_SIZE() external view returns (uint256); function MAX_WITHDRAWAL_GAS_LIMIT() external view returns (uint64); + function MAX_GAS_FEE_RATE() external view returns (uint128); + function WITHDRAWAL_BASE_GAS() external view returns (uint64); + function REVEAL_TO_KEY_LENGTH() external view returns (uint256); + function AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH() external view returns (uint256); // -- State-changing functions -- + function setTempoGasRate(uint128 _tempoGasRate) external; + function setMaxWithdrawalsPerBlock(uint256 _maxWithdrawalsPerBlock) external; function requestWithdrawal( address token, address to, diff --git a/crates/contracts/src/precompiles/zone_portal.rs b/crates/contracts/src/precompiles/zone_portal.rs index 812b1c4aa..b70693c4d 100644 --- a/crates/contracts/src/precompiles/zone_portal.rs +++ b/crates/contracts/src/precompiles/zone_portal.rs @@ -4,7 +4,7 @@ pub use ZonePortal::{ BlockTransition, DepositQueueTransition, EncryptedDeposit, EncryptedDepositPayload, Withdrawal, }; -use crate::ZoneOutbox; +use crate::IZoneOutbox; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_sol_types::SolValue; use zone_primitives::constants::EMPTY_SENTINEL; @@ -364,7 +364,7 @@ impl Withdrawal { /// Reconstruct the public L1-facing withdrawal from a zone-side withdrawal request event. pub fn from_requested_event( - event: &ZoneOutbox::WithdrawalRequested, + event: &IZoneOutbox::WithdrawalRequested, tx_hash: B256, encrypted_sender: Bytes, ) -> Self { diff --git a/crates/payload/src/builder.rs b/crates/payload/src/builder.rs index 34dccf3f3..376e594c3 100644 --- a/crates/payload/src/builder.rs +++ b/crates/payload/src/builder.rs @@ -521,7 +521,7 @@ where /// Build the `finalizeWithdrawalBatch(count)` system transaction. /// /// This must be the **last** transaction in each finalizing zone block. It calls -/// [`ZoneOutbox.finalizeWithdrawalBatch`](crate::abi::ZoneOutbox) which: +/// [`IZoneOutbox.finalizeWithdrawalBatch`](crate::abi::IZoneOutbox) which: /// - Collects up to `count` pending withdrawals /// - Builds the withdrawal hash chain (oldest outermost) /// - Increments `withdrawalBatchIndex` @@ -535,7 +535,7 @@ pub(crate) fn build_finalize_withdrawal_batch_tx( block_number: u64, encrypted_senders: Vec, ) -> Recovered { - let calldata = abi::ZoneOutbox::finalizeWithdrawalBatchCall { + let calldata = abi::IZoneOutbox::finalizeWithdrawalBatchCall { count, blockNumber: block_number, encryptedSenders: encrypted_senders, @@ -579,11 +579,11 @@ where fn read_pending_withdrawals_from_outbox( builder: &mut B, block_number: u64, -) -> Result, PayloadBuilderError> +) -> Result, PayloadBuilderError> where B: BlockBuilder, { - let calldata = abi::ZoneOutbox::getPendingWithdrawalsCall {}.abi_encode(); + let calldata = abi::IZoneOutbox::getPendingWithdrawalsCall {}.abi_encode(); let output = execute_outbox_view_call( builder, calldata.into(), @@ -591,7 +591,7 @@ where "getPendingWithdrawals", )?; - abi::ZoneOutbox::getPendingWithdrawalsCall::abi_decode_returns(&output).map_err(|err| { + abi::IZoneOutbox::getPendingWithdrawalsCall::abi_decode_returns(&output).map_err(|err| { PayloadBuilderError::Internal(reth_errors::RethError::msg(format!( "failed to decode getPendingWithdrawals return data: {err}" ))) diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 28d2b323a..6fd415295 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -46,6 +46,7 @@ pub mod dispatch { } mod execution; +pub mod outbox; pub mod storage; pub mod tempo_state; pub mod tip20_factory; @@ -54,6 +55,7 @@ pub mod ztip20; pub use aes_gcm::{AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt}; pub use chaum_pedersen::{CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify}; +pub use outbox::ZoneOutbox; pub use storage::L1StorageReader; pub use tempo_state::TempoState; pub use tip20_factory::{ZONE_TIP20_FACTORY_ADDRESS, ZoneTokenFactory}; diff --git a/crates/precompiles/src/outbox/dispatch.rs b/crates/precompiles/src/outbox/dispatch.rs new file mode 100644 index 000000000..a7ff768a2 --- /dev/null +++ b/crates/precompiles/src/outbox/dispatch.rs @@ -0,0 +1,176 @@ +//! ABI dispatch for the [`ZoneOutbox`] precompile. + +use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_sol_types::{SolCall, SolError}; +use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult}; +use tempo_precompiles::{ + DelegateCallNotAllowed, charge_input_cost, dispatch, + storage::{Handler, StorageCtx, evm::EvmPrecompileStorageProvider}, + view, +}; +use tempo_zone_contracts::IZoneOutbox; +use zone_primitives::constants::{MAX_WITHDRAWAL_GAS_LIMIT, ZONE_CONFIG_ADDRESS}; + +use crate::ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE; + +use super::{ + MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, REVEAL_TO_KEY_LENGTH, WITHDRAWAL_BASE_GAS, + ZoneOutbox, ZonePortalReader, +}; + +alloy_sol_types::sol! { + function requestWithdrawal( + address token, + address to, + uint128 amount, + bytes32 memo, + uint64 gasLimit, + address fallbackRecipient, + bytes data + ) external; +} + +impl ZoneOutbox { + fn call_with_context( + &mut self, + provider: &P, + current_tx_hash: B256, + calldata: &[u8], + msg_sender: Address, + ) -> PrecompileResult { + if let Some(err) = charge_input_cost(&mut self.storage, calldata) { + return err; + } + + // Preserve the original seven-argument overload. The generated interface + // contains the newer overload with `revealTo`, so dispatch this selector + // explicitly and treat it as an empty reveal key. + if tempo_precompiles::dispatch::selector_from_calldata(calldata) + == Some(requestWithdrawalCall::SELECTOR) + { + let Ok(call) = requestWithdrawalCall::abi_decode_raw_validate(&calldata[4..]) else { + return Ok(self.storage.revert_output(Bytes::new())); + }; + return self.request_withdrawal( + provider, + msg_sender, + current_tx_hash, + IZoneOutbox::requestWithdrawalCall { + token: call.token, + to: call.to, + amount: call.amount, + memo: call.memo, + gasLimit: call.gasLimit, + fallbackRecipient: call.fallbackRecipient, + data: call.data, + revealTo: Bytes::new(), + }, + ); + } + + dispatch!( + calldata, + |call| match call { + IZoneOutbox::IZoneOutboxCalls { + config(call) => view(call, |_| Ok(ZONE_CONFIG_ADDRESS)), + tempoGasRate(call) => view(call, |_| self.tempo_gas_rate.read()), + maxWithdrawalsPerBlock(call) => { + view(call, |_| self.max_withdrawals_per_block.read()) + }, + lastBatch(call) => view(call, |_| self.last_batch()), + withdrawalBatchIndex(call) => { + view(call, |_| self.withdrawal_batch_index.read()) + }, + lastFinalizedTimestamp(call) => { + view(call, |_| self.last_finalized_timestamp.read()) + }, + nextWithdrawalIndex(call) => { + view(call, |_| self.next_withdrawal_index.read()) + }, + pendingWithdrawalsCount(call) => { + view(call, |_| self.pending_withdrawals_count()) + }, + getPendingWithdrawals(call) => { + view(call, |_| self.get_pending_withdrawals()) + }, + calculateWithdrawalFee(call) => { + self.calculate_withdrawal_fee(call.gasLimit) + }, + MAX_CALLBACK_DATA_SIZE(call) => { + view(call, |_| Ok(U256::from(MAX_CALLBACK_DATA_SIZE))) + }, + MAX_WITHDRAWAL_GAS_LIMIT(call) => { + view(call, |_| Ok(MAX_WITHDRAWAL_GAS_LIMIT)) + }, + MAX_GAS_FEE_RATE(call) => view(call, |_| Ok(MAX_GAS_FEE_RATE)), + WITHDRAWAL_BASE_GAS(call) => view(call, |_| Ok(WITHDRAWAL_BASE_GAS)), + REVEAL_TO_KEY_LENGTH(call) => { + view(call, |_| Ok(U256::from(REVEAL_TO_KEY_LENGTH))) + }, + AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(call) => { + view(call, |_| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))) + }, + setTempoGasRate(call) => { + self.set_tempo_gas_rate(provider, msg_sender, call) + }, + setMaxWithdrawalsPerBlock(call) => { + self.set_max_withdrawals_per_block(provider, msg_sender, call) + }, + requestWithdrawal(call) => self.request_withdrawal( + provider, + msg_sender, + current_tx_hash, + call, + ), + enqueueDepositBounceBack(call) => { + self.enqueue_deposit_bounce_back(msg_sender, call) + }, + finalizeWithdrawalBatch(call) => { + self.finalize_withdrawal_batch(provider, msg_sender, call) + }, + } + }, + ) + } + + /// Wrap this precompile for registration in the zone EVM. + pub fn create( + provider: P, + tx_hash: F, + cfg: &revm::context::CfgEnv, + ) -> DynPrecompile + where + P: ZonePortalReader + Clone + Send + Sync + 'static, + F: for<'a> Fn(&PrecompileInput<'a>) -> B256 + Clone + Send + Sync + 'static, + { + let spec = cfg.spec; + let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; + let gas_params = cfg.gas_params.clone(); + + DynPrecompile::new_stateful(PrecompileId::Custom("ZoneOutbox".into()), move |input| { + if !input.is_direct_call() { + return Ok(PrecompileOutput::revert( + 0, + SolError::abi_encode(&DelegateCallNotAllowed {}).into(), + input.reservoir, + )); + } + + let current_tx_hash = tx_hash(&input); + let mut storage = EvmPrecompileStorageProvider::new( + input.internals, + input.gas, + input.reservoir, + spec, + amsterdam_eip8037_enabled, + input.is_static, + gas_params.clone(), + ); + + StorageCtx::enter(&mut storage, || { + Self::new().call_with_context(&provider, current_tx_hash, input.data, input.caller) + }) + }) + } +} diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs new file mode 100644 index 000000000..128cd108b --- /dev/null +++ b/crates/precompiles/src/outbox/mod.rs @@ -0,0 +1,729 @@ +//! Native `ZoneOutbox` precompile. +//! +mod dispatch; + +use alloc::vec::Vec; + +use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolError, SolValue}; +use revm::precompile::{PrecompileError, PrecompileResult}; +use tempo_precompiles::{ + Result as TempoResult, + error::TempoPrecompileError, + storage::Handler, + tip20::{ITIP20, TIP20Token}, +}; +use tempo_precompiles_macros::{Storable, contract}; +use tempo_zone_contracts::IZoneOutbox as ZoneOutboxAbi; +use zone_primitives::constants::{ + EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, ZONE_INBOX_ADDRESS, + ZONE_OUTBOX_ADDRESS, +}; + +use crate::{ + chaum_pedersen::recover_point, ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, + storage::L1StorageReader, tempo_state::TempoState, +}; + +const MAX_CALLBACK_DATA_SIZE: usize = 1024; +const MAX_GAS_FEE_RATE: u128 = 1_000_000_000_000_000_000; +const WITHDRAWAL_BASE_GAS: u64 = 50_000; +const REVEAL_TO_KEY_LENGTH: usize = 33; +const PORTAL_TOKEN_CONFIGS_SLOT: B256 = B256::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, +]); + +/// L1 portal state needed by the native outbox. +pub trait ZonePortalReader: L1StorageReader { + /// Zone portal address on Tempo L1. + fn portal_address(&self) -> Address; +} + +#[contract(addr = ZONE_OUTBOX_ADDRESS)] +pub struct ZoneOutbox { + tempo_gas_rate: u128, + next_withdrawal_index: u64, + withdrawal_batch_index: u64, + last_batch: LastBatchStorage, + pending_withdrawals: Vec, + pending_withdrawals_head: U256, + max_withdrawals_per_block: U256, + withdrawals_this_block: U256, + current_block_number: U256, + last_finalized_timestamp: u64, +} + +impl ZoneOutbox { + /// Initializes the precompile account code. + pub fn initialize(&mut self) -> TempoResult<()> { + self.__initialize() + } + + fn static_revert(&self) -> Option { + if self.storage.is_static() { + Some(Ok(self.storage.revert_output( + ZoneOutboxAbi::StaticCallNotAllowed {}.abi_encode().into(), + ))) + } else { + None + } + } + + fn portal_storage( + &self, + provider: &P, + slot: B256, + ) -> Result { + let tempo_block_number = self + .current_tempo_block_number() + .map_err(|err| PrecompileError::Fatal(err.to_string()))?; + provider.read_l1_storage(provider.portal_address(), slot, tempo_block_number) + } + + fn current_tempo_block_number(&self) -> TempoResult { + TempoState::new().current_tempo_block_number() + } + + fn sequencer(&self, provider: &P) -> Result { + let value = self.portal_storage(provider, PORTAL_SEQUENCER_SLOT)?; + Ok(Address::from_slice(&value.as_slice()[12..])) + } + + fn token_enabled( + &self, + provider: &P, + token: Address, + ) -> Result { + let slot = keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()); + let value = self.portal_storage(provider, slot)?; + Ok(value.as_slice()[31] != 0) + } + + fn ensure_sequencer( + &self, + provider: &P, + caller: Address, + ) -> PrecompileResult { + if caller == Address::ZERO || caller == self.sequencer(provider)? { + return Ok(self.storage.success_output(Bytes::new())); + } + Ok(self + .storage + .revert_output(ZoneOutboxAbi::OnlySequencer {}.abi_encode().into())) + } + + fn validate_gas_limit(&self, gas_limit: u64) -> Option { + if gas_limit > MAX_WITHDRAWAL_GAS_LIMIT { + Some(Ok(self.storage.revert_output( + ZoneOutboxAbi::GasLimitTooHigh {}.abi_encode().into(), + ))) + } else { + None + } + } + + fn calculate_fee_unchecked(&self, gas_limit: u64) -> TempoResult { + let gas = u128::from(WITHDRAWAL_BASE_GAS) + .checked_add(u128::from(gas_limit)) + .ok_or_else(TempoPrecompileError::under_overflow)?; + gas.checked_mul(self.tempo_gas_rate.read()?) + .ok_or_else(TempoPrecompileError::under_overflow) + } + + fn calculate_withdrawal_fee(&self, gas_limit: u64) -> PrecompileResult { + if let Some(revert) = self.validate_gas_limit(gas_limit) { + return revert; + } + let fee = match self.calculate_fee_unchecked(gas_limit) { + Ok(fee) => fee, + Err(err) => return self.storage.error_result(err), + }; + Ok(self.storage.success_output( + ZoneOutboxAbi::calculateWithdrawalFeeCall::abi_encode_returns(&fee).into(), + )) + } + + fn validate_reveal_to(&self, reveal_to: &[u8]) -> Option { + if reveal_to.is_empty() { + return None; + } + if reveal_to.len() != REVEAL_TO_KEY_LENGTH { + return Some(Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidRevealTo {}.abi_encode().into(), + ))); + } + let y_parity = reveal_to[0]; + if !matches!(y_parity, 0x02 | 0x03) { + return Some(Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidRevealTo {}.abi_encode().into(), + ))); + } + let mut x = [0u8; 32]; + x.copy_from_slice(&reveal_to[1..]); + if recover_point(&x, y_parity).is_none() { + return Some(Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidRevealTo {}.abi_encode().into(), + ))); + } + None + } + + fn validate_encrypted_sender( + &self, + reveal_to: &[u8], + encrypted_sender: &[u8], + ) -> Option { + let expected = if reveal_to.is_empty() { + 0 + } else { + AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE + }; + if encrypted_sender.len() != expected { + return Some(Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidEncryptedSenderLength { + actual: U256::from(encrypted_sender.len()), + expected: U256::from(expected), + } + .abi_encode() + .into(), + ))); + } + None + } + + fn enforce_withdrawal_block_cap(&mut self) -> PrecompileResult { + let max = match self.max_withdrawals_per_block.read() { + Ok(max) => max, + Err(err) => return self.storage.error_result(err), + }; + if max.is_zero() { + return Ok(self.storage.success_output(Bytes::new())); + } + + let block_number = U256::from(self.storage.block_number()); + let current_block = match self.current_block_number.read() { + Ok(current_block) => current_block, + Err(err) => return self.storage.error_result(err), + }; + if block_number != current_block { + if let Err(err) = self.current_block_number.write(block_number) { + return self.storage.error_result(err); + } + if let Err(err) = self.withdrawals_this_block.write(U256::ZERO) { + return self.storage.error_result(err); + } + } + + let withdrawals = match self.withdrawals_this_block.read() { + Ok(withdrawals) => withdrawals, + Err(err) => return self.storage.error_result(err), + }; + if withdrawals >= max { + return Ok(self.storage.revert_output( + ZoneOutboxAbi::TooManyWithdrawalsThisBlock {} + .abi_encode() + .into(), + )); + } + let next = match withdrawals + .checked_add(U256::ONE) + .ok_or_else(TempoPrecompileError::under_overflow) + { + Ok(next) => next, + Err(err) => return self.storage.error_result(err), + }; + if let Err(err) = self.withdrawals_this_block.write(next) { + return self.storage.error_result(err); + } + Ok(self.storage.success_output(Bytes::new())) + } + + fn request_withdrawal( + &mut self, + provider: &P, + caller: Address, + current_tx_hash: B256, + call: ZoneOutboxAbi::requestWithdrawalCall, + ) -> PrecompileResult { + if let Some(revert) = self.static_revert() { + return revert; + } + if call.fallbackRecipient == Address::ZERO { + return Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidFallbackRecipient {} + .abi_encode() + .into(), + )); + } + if !self.token_enabled(provider, call.token)? { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::TokenNotEnabled {}.abi_encode().into())); + } + if let Some(revert) = self.validate_gas_limit(call.gasLimit) { + return revert; + } + if call.data.len() > MAX_CALLBACK_DATA_SIZE { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::CallbackDataTooLarge {}.abi_encode().into())); + } + if let Some(revert) = self.validate_reveal_to(&call.revealTo) { + return revert; + } + let cap_check = self.enforce_withdrawal_block_cap()?; + if cap_check.is_revert() { + return Ok(cap_check); + } + + let fee = match self.calculate_fee_unchecked(call.gasLimit) { + Ok(fee) => fee, + Err(err) => return self.storage.error_result(err), + }; + let total_burn = match call + .amount + .checked_add(fee) + .ok_or_else(TempoPrecompileError::under_overflow) + { + Ok(total_burn) => total_burn, + Err(err) => return self.storage.error_result(err), + }; + if current_tx_hash.is_zero() { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::InvalidCurrentTxHash {}.abi_encode().into())); + } + + let mut zone_token = match TIP20Token::from_address(call.token) { + Ok(zone_token) => zone_token, + Err(err) => return self.storage.error_result(err), + }; + let amount = U256::from(total_burn); + let transferred = match zone_token.transfer_from( + ZONE_OUTBOX_ADDRESS, + ITIP20::transferFromCall { + from: caller, + to: ZONE_OUTBOX_ADDRESS, + amount, + }, + ) { + Ok(transferred) => transferred, + Err(err) => return self.storage.error_result(err), + }; + if !transferred { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::TransferFailed {}.abi_encode().into())); + } + if let Err(err) = zone_token.burn(ZONE_OUTBOX_ADDRESS, ITIP20::burnCall { amount }) { + return self.storage.error_result(err); + } + + if let Err(err) = self.pending_withdrawals.push(PendingWithdrawalStorage { + token: call.token, + sender: caller, + tx_hash: current_tx_hash, + to: call.to, + amount: call.amount, + fee, + memo: call.memo, + gas_limit: call.gasLimit, + fallback_recipient: call.fallbackRecipient, + callback_data: call.data.clone(), + reveal_to: call.revealTo.clone(), + }) { + return self.storage.error_result(err); + } + + let index = match self.next_withdrawal_index.read() { + Ok(index) => index, + Err(err) => return self.storage.error_result(err), + }; + let next_index = match index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow) + { + Ok(next_index) => next_index, + Err(err) => return self.storage.error_result(err), + }; + if let Err(err) = self.next_withdrawal_index.write(next_index) { + return self.storage.error_result(err); + } + + if let Err(err) = self.emit_event(ZoneOutboxAbi::WithdrawalRequested { + withdrawalIndex: index, + sender: caller, + token: call.token, + to: call.to, + amount: call.amount, + fee, + memo: call.memo, + gasLimit: call.gasLimit, + fallbackRecipient: call.fallbackRecipient, + data: call.data, + revealTo: call.revealTo, + }) { + return self.storage.error_result(err); + } + + Ok(self.storage.success_output(Bytes::new())) + } + + fn enqueue_deposit_bounce_back( + &mut self, + caller: Address, + call: ZoneOutboxAbi::enqueueDepositBounceBackCall, + ) -> PrecompileResult { + if let Some(revert) = self.static_revert() { + return revert; + } + if caller != ZONE_INBOX_ADDRESS { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::OnlyZoneInbox {}.abi_encode().into())); + } + + if let Err(err) = self.pending_withdrawals.push(PendingWithdrawalStorage { + token: call.token, + sender: Address::ZERO, + tx_hash: B256::ZERO, + to: call.bouncebackRecipient, + amount: call.amount, + fee: 0, + memo: B256::ZERO, + gas_limit: 0, + fallback_recipient: Address::ZERO, + callback_data: Bytes::new(), + reveal_to: Bytes::new(), + }) { + return self.storage.error_result(err); + } + + let index = match self.next_withdrawal_index.read() { + Ok(index) => index, + Err(err) => return self.storage.error_result(err), + }; + let next_index = match index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow) + { + Ok(next_index) => next_index, + Err(err) => return self.storage.error_result(err), + }; + if let Err(err) = self.next_withdrawal_index.write(next_index) { + return self.storage.error_result(err); + } + + if let Err(err) = self.emit_event(ZoneOutboxAbi::WithdrawalRequested { + withdrawalIndex: index, + sender: Address::ZERO, + token: call.token, + to: call.bouncebackRecipient, + amount: call.amount, + fee: 0, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: Address::ZERO, + data: Bytes::new(), + revealTo: Bytes::new(), + }) { + return self.storage.error_result(err); + } + + Ok(self.storage.success_output(Bytes::new())) + } + + fn finalize_withdrawal_batch( + &mut self, + provider: &P, + caller: Address, + call: ZoneOutboxAbi::finalizeWithdrawalBatchCall, + ) -> PrecompileResult { + if let Some(revert) = self.static_revert() { + return revert; + } + let sequencer_check = self.ensure_sequencer(provider, caller)?; + if sequencer_check.is_revert() { + return Ok(sequencer_check); + } + if call.blockNumber != self.storage.block_number() { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::InvalidBlockNumber {}.abi_encode().into())); + } + + let len = match self.pending_withdrawals.len() { + Ok(len) => len, + Err(err) => return self.storage.error_result(err), + }; + let head_u256 = match self.pending_withdrawals_head.read() { + Ok(head_u256) => head_u256, + Err(err) => return self.storage.error_result(err), + }; + let head = match checked_usize(head_u256) { + Ok(head) => head, + Err(err) => return self.storage.error_result(err), + }; + let pending = len.saturating_sub(head); + let count = match checked_usize(call.count) { + Ok(count) => count, + Err(err) => return self.storage.error_result(err), + }; + + if count != pending { + return Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidWithdrawalCount { + actual: call.count, + expected: U256::from(pending), + } + .abi_encode() + .into(), + )); + } + if call.encryptedSenders.len() != count { + return Ok(self.storage.revert_output( + ZoneOutboxAbi::InvalidEncryptedSenderCount { + actual: U256::from(call.encryptedSenders.len()), + expected: U256::from(count), + } + .abi_encode() + .into(), + )); + } + + let mut withdrawal_queue_hash = B256::ZERO; + if count > 0 { + withdrawal_queue_hash = EMPTY_SENTINEL; + let start = head; + let end = start + count; + + for i in (start..end).rev() { + let pending_withdrawal = match self.pending_withdrawals[i].read() { + Ok(pending_withdrawal) => pending_withdrawal, + Err(err) => return self.storage.error_result(err), + }; + let encrypted_sender = call.encryptedSenders[i - start].clone(); + if let Some(revert) = self.validate_encrypted_sender( + &pending_withdrawal.reveal_to, + encrypted_sender.as_ref(), + ) { + return revert; + } + + let sender_tag = sender_tag(pending_withdrawal.sender, pending_withdrawal.tx_hash); + let withdrawal = ZoneOutboxAbi::Withdrawal { + token: pending_withdrawal.token, + senderTag: sender_tag, + to: pending_withdrawal.to, + amount: pending_withdrawal.amount, + fee: pending_withdrawal.fee, + memo: pending_withdrawal.memo, + gasLimit: pending_withdrawal.gas_limit, + fallbackRecipient: pending_withdrawal.fallback_recipient, + callbackData: pending_withdrawal.callback_data, + encryptedSender: encrypted_sender, + }; + withdrawal_queue_hash = keccak256((withdrawal, withdrawal_queue_hash).abi_encode()); + if let Err(err) = self.pending_withdrawals[i].delete() { + return self.storage.error_result(err); + } + } + + if let Err(err) = self.pending_withdrawals_head.write(U256::from(end)) { + return self.storage.error_result(err); + } + if end == len { + if let Err(err) = self.pending_withdrawals.delete() { + return self.storage.error_result(err); + } + if let Err(err) = self.pending_withdrawals_head.write(U256::ZERO) { + return self.storage.error_result(err); + } + } + } + + let current_batch_index = match self.withdrawal_batch_index.read() { + Ok(current_batch_index) => current_batch_index, + Err(err) => return self.storage.error_result(err), + }; + let next_batch_index = match current_batch_index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow) + { + Ok(next_batch_index) => next_batch_index, + Err(err) => return self.storage.error_result(err), + }; + if let Err(err) = self.withdrawal_batch_index.write(next_batch_index) { + return self.storage.error_result(err); + } + if let Err(err) = self.last_batch.write(LastBatchStorage { + withdrawal_queue_hash, + withdrawal_batch_index: next_batch_index, + }) { + return self.storage.error_result(err); + } + if let Err(err) = self + .last_finalized_timestamp + .write(self.storage.timestamp().to::()) + { + return self.storage.error_result(err); + } + if let Err(err) = self.emit_event(ZoneOutboxAbi::BatchFinalized { + withdrawalQueueHash: withdrawal_queue_hash, + withdrawalBatchIndex: next_batch_index, + }) { + return self.storage.error_result(err); + } + + Ok(self.storage.success_output( + ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_encode_returns(&withdrawal_queue_hash) + .into(), + )) + } + + fn set_tempo_gas_rate( + &mut self, + provider: &P, + caller: Address, + call: ZoneOutboxAbi::setTempoGasRateCall, + ) -> PrecompileResult { + if let Some(revert) = self.static_revert() { + return revert; + } + let sequencer_check = self.ensure_sequencer(provider, caller)?; + if sequencer_check.is_revert() { + return Ok(sequencer_check); + } + if call._tempoGasRate > MAX_GAS_FEE_RATE { + return Ok(self + .storage + .revert_output(ZoneOutboxAbi::GasFeeRateTooHigh {}.abi_encode().into())); + } + if let Err(err) = self.tempo_gas_rate.write(call._tempoGasRate) { + return self.storage.error_result(err); + } + if let Err(err) = self.emit_event(ZoneOutboxAbi::TempoGasRateUpdated { + tempoGasRate: call._tempoGasRate, + }) { + return self.storage.error_result(err); + } + Ok(self.storage.success_output(Bytes::new())) + } + + fn set_max_withdrawals_per_block( + &mut self, + provider: &P, + caller: Address, + call: ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall, + ) -> PrecompileResult { + if let Some(revert) = self.static_revert() { + return revert; + } + let sequencer_check = self.ensure_sequencer(provider, caller)?; + if sequencer_check.is_revert() { + return Ok(sequencer_check); + } + if let Err(err) = self + .max_withdrawals_per_block + .write(call._maxWithdrawalsPerBlock) + { + return self.storage.error_result(err); + } + if let Err(err) = self.emit_event(ZoneOutboxAbi::MaxWithdrawalsPerBlockUpdated { + maxWithdrawalsPerBlock: call._maxWithdrawalsPerBlock, + }) { + return self.storage.error_result(err); + } + Ok(self.storage.success_output(Bytes::new())) + } + + fn pending_withdrawals_count(&self) -> TempoResult { + let len = self.pending_withdrawals.len()?; + let head = checked_usize(self.pending_withdrawals_head.read()?)?; + if head >= len { + Ok(U256::ZERO) + } else { + Ok(U256::from(len - head)) + } + } + + fn get_pending_withdrawals(&self) -> TempoResult> { + let len = self.pending_withdrawals.len()?; + let head = checked_usize(self.pending_withdrawals_head.read()?)?; + if head >= len { + return Ok(Vec::new()); + } + + let mut pending = Vec::with_capacity(len - head); + for index in head..len { + pending.push(self.pending_withdrawals[index].read()?.into_abi()); + } + Ok(pending) + } + + fn last_batch(&self) -> TempoResult { + Ok(self.last_batch.read()?.into_abi()) + } +} + +impl LastBatchStorage { + fn into_abi(self) -> ZoneOutboxAbi::LastBatch { + ZoneOutboxAbi::LastBatch { + withdrawalQueueHash: self.withdrawal_queue_hash, + withdrawalBatchIndex: self.withdrawal_batch_index, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Storable)] +struct LastBatchStorage { + withdrawal_queue_hash: B256, + withdrawal_batch_index: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Storable)] +struct PendingWithdrawalStorage { + token: Address, + sender: Address, + tx_hash: B256, + to: Address, + amount: u128, + fee: u128, + memo: B256, + gas_limit: u64, + fallback_recipient: Address, + callback_data: Bytes, + reveal_to: Bytes, +} + +impl PendingWithdrawalStorage { + fn into_abi(self) -> ZoneOutboxAbi::PendingWithdrawal { + ZoneOutboxAbi::PendingWithdrawal { + token: self.token, + sender: self.sender, + txHash: self.tx_hash, + to: self.to, + amount: self.amount, + fee: self.fee, + memo: self.memo, + gasLimit: self.gas_limit, + fallbackRecipient: self.fallback_recipient, + callbackData: self.callback_data, + revealTo: self.reveal_to, + } + } +} + +fn checked_usize(value: U256) -> TempoResult { + if value > U256::from(u32::MAX) { + return Err(TempoPrecompileError::under_overflow()); + } + Ok(value.to::()) +} + +fn sender_tag(sender: Address, tx_hash: B256) -> B256 { + let mut preimage = [0u8; 52]; + preimage[..20].copy_from_slice(sender.as_slice()); + preimage[20..].copy_from_slice(tx_hash.as_slice()); + keccak256(preimage) +} diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 772c1917e..015ca7b39 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -67,6 +67,11 @@ impl TempoState { ) } + /// Returns the finalized Tempo block number recorded in zone state. + pub fn current_tempo_block_number(&self) -> tempo_precompiles::Result { + self.tempo_block_number.read() + } + fn revert_error(&self, error: E) -> PrecompileResult { Ok(self.storage.revert_output(error.abi_encode().into())) } diff --git a/crates/sequencer/src/monitor.rs b/crates/sequencer/src/monitor.rs index 9135ee225..169dd534d 100644 --- a/crates/sequencer/src/monitor.rs +++ b/crates/sequencer/src/monitor.rs @@ -35,7 +35,7 @@ use tracing::{error, info, instrument, warn}; use alloy_sol_types::{ContractError, SolInterface as _}; use crate::{ - abi::{self, NO_QUEUE_INDEX, TempoState, ZoneInbox, ZoneOutbox, ZonePortal}, + abi::{self, IZoneOutbox, NO_QUEUE_INDEX, TempoState, ZoneInbox, ZonePortal}, rpc::rpc_connection_config, settlement::{ BatchAnchorConfig, BatchData, BatchSubmitter, ZoneBlockSnapshot, fetch_finalized_batch, @@ -98,7 +98,7 @@ pub struct ZoneMonitor { provider: DynProvider, /// ZoneOutbox contract on **Zone L2** — source of `WithdrawalRequested` and /// `BatchFinalized` events. - outbox: ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, /// ZoneInbox contract on **Zone L2** — queried for the processed deposit queue hash. inbox: ZoneInbox::ZoneInboxInstance, TempoNetwork>, /// TempoState predeploy on **Zone L2** — provides the latest Tempo L1 block number @@ -181,7 +181,7 @@ impl ZoneMonitor { repair_notify: Arc, ) -> Result { let metrics = crate::metrics::ZoneMonitorMetrics::default(); - let outbox = ZoneOutbox::new(config.outbox_address, provider.clone()); + let outbox = IZoneOutbox::new(config.outbox_address, provider.clone()); let inbox = ZoneInbox::new(config.inbox_address, provider.clone()); let tempo_state = TempoState::new(config.tempo_state_address, provider.clone()); @@ -1006,7 +1006,7 @@ mod tests { config, metrics: crate::metrics::ZoneMonitorMetrics::default(), provider: zone_provider.clone(), - outbox: ZoneOutbox::new(Address::repeat_byte(0x22), zone_provider.clone()), + outbox: IZoneOutbox::new(Address::repeat_byte(0x22), zone_provider.clone()), inbox: ZoneInbox::new(Address::repeat_byte(0x33), zone_provider.clone()), tempo_state: TempoState::new(Address::repeat_byte(0x44), zone_provider), withdrawal_store: SharedWithdrawalStore::new(), diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 1c42cf93c..4affef861 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -25,7 +25,7 @@ use std::collections::BTreeMap; -use crate::abi::{self, BlockTransition, DepositQueueTransition, ZoneOutbox, ZonePortal}; +use crate::abi::{self, BlockTransition, DepositQueueTransition, IZoneOutbox, ZonePortal}; use alloy_consensus::Transaction; use alloy_network::ReceiptResponse; use alloy_primitives::{Address, B256, Bytes, U256}; @@ -591,7 +591,7 @@ impl BatchSubmitter { } // Step 4: fetch WithdrawalRequested events from zone L2 for each pending slot. - let outbox = ZoneOutbox::new(outbox_address, zone_provider.clone()); + let outbox = IZoneOutbox::new(outbox_address, zone_provider.clone()); let mut slot_withdrawals: BTreeMap> = BTreeMap::new(); for portal_slot in head..tail { if !events.contains_key(&portal_slot) { @@ -783,7 +783,7 @@ struct RequestedWithdrawalLog { tx_index: u64, log_index: u64, tx_hash: B256, - event: abi::ZoneOutbox::WithdrawalRequested, + event: abi::IZoneOutbox::WithdrawalRequested, } #[derive(Debug, Clone)] @@ -801,7 +801,7 @@ struct FinalizedBatchLog { /// This includes zero-withdrawal batches because they still advance the L2 /// withdrawal batch index and therefore require a matching L1 `submitBatch`. pub(crate) async fn fetch_finalized_batch_boundaries( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, from: u64, to: u64, ) -> Result> { @@ -834,7 +834,7 @@ pub(crate) async fn fetch_finalized_batch_boundaries( /// the immediately preceding batch boundary so the off-chain processor can /// service the portal queue. pub(crate) async fn fetch_finalized_batch( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, zone_provider: &DynProvider, from: u64, to: u64, @@ -890,7 +890,7 @@ pub(crate) async fn fetch_finalized_batch( ) })?; let encrypted_senders = - abi::ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref()) + abi::IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref()) .map_err(|err| { eyre::eyre!( "failed to decode finalizeWithdrawalBatch calldata for {}: {err}", @@ -935,7 +935,7 @@ pub(crate) async fn fetch_finalized_batch( /// Fetch `WithdrawalRequested` events for one portal queue slot. pub(crate) async fn fetch_slot_withdrawals( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, zone_provider: &DynProvider, from: u64, to: u64, @@ -946,7 +946,7 @@ pub(crate) async fn fetch_slot_withdrawals( } async fn fetch_requested_withdrawal_logs( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, from: u64, to: u64, ) -> Result> { @@ -978,7 +978,7 @@ async fn fetch_requested_withdrawal_logs( } async fn fetch_finalized_batch_logs( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, from: u64, to: u64, ) -> Result> { diff --git a/xtask/src/demo_blacklist.rs b/xtask/src/demo_blacklist.rs index 9ae7df898..a8043df58 100644 --- a/xtask/src/demo_blacklist.rs +++ b/xtask/src/demo_blacklist.rs @@ -75,7 +75,7 @@ use tempo_precompiles::{ PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, tip20::ISSUER_ROLE, }; use tempo_zone_contracts::{ - DepositType, EncryptedDepositPayload, ZONE_OUTBOX_ADDRESS, ZoneInbox, ZoneOutbox, ZonePortal, + DepositType, EncryptedDepositPayload, IZoneOutbox, ZONE_OUTBOX_ADDRESS, ZoneInbox, ZonePortal, }; use zone_precompiles::ecies::encrypt_deposit; @@ -572,7 +572,7 @@ impl DemoBlacklist { if withdraw_amount == 0 { println!(" No balance to withdraw — skipping."); } else { - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_target); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_target); let l1_block_before = l1.get_block_number().await?; let receipt = outbox diff --git a/xtask/src/demo_swap_and_deposit.rs b/xtask/src/demo_swap_and_deposit.rs index 60291e28e..acca1fa36 100644 --- a/xtask/src/demo_swap_and_deposit.rs +++ b/xtask/src/demo_swap_and_deposit.rs @@ -15,8 +15,8 @@ use tempo_contracts::precompiles::{ }; use tempo_precompiles::{PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, tip20::ISSUER_ROLE}; use tempo_zone_contracts::{ - EncryptedDepositPayload, SwapAndDepositRouterEncryptedCallback, ZONE_OUTBOX_ADDRESS, - ZoneOutbox, ZonePortal, + EncryptedDepositPayload, IZoneOutbox, SwapAndDepositRouterEncryptedCallback, + ZONE_OUTBOX_ADDRESS, ZonePortal, }; use zone_precompiles::ecies::encrypt_deposit; @@ -170,7 +170,7 @@ impl DemoSwapAndDeposit { .call() .await .wrap_err("failed to fetch portal deposit fee")?; - let withdrawal_fee = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2) + let withdrawal_fee = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2) .calculateWithdrawalFee(ROUTER_CALLBACK_GAS_LIMIT) .call() .await @@ -357,7 +357,7 @@ impl DemoSwapAndDeposit { ) .await?; let l1_from_block = l1.get_block_number().await.unwrap_or(0); - let receipt = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_operator) + let receipt = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_operator) .requestWithdrawal( alpha, router, From e23ddc7bc17102a09eb09780ac2ef522bd2edb69 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 10:11:17 +0200 Subject: [PATCH 21/30] wip: leverage precompile execution --- Cargo.lock | 2 - crates/contracts/src/precompiles/outbox.rs | 17 +- crates/evm/Cargo.toml | 5 +- crates/evm/src/executor.rs | 5 +- crates/evm/src/lib.rs | 3 - crates/evm/src/tx_context.rs | 166 ---------------- crates/l1/src/state/provider.rs | 4 + crates/precompiles/src/execution.rs | 62 ++++-- crates/precompiles/src/lib.rs | 17 +- crates/precompiles/src/outbox/dispatch.rs | 209 +++++---------------- crates/precompiles/src/outbox/mod.rs | 178 ++++++++++-------- crates/precompiles/src/storage.rs | 6 +- crates/precompiles/src/test_utils.rs | 4 + crates/precompiles/src/tx_context.rs | 135 +++++++++++++ 14 files changed, 373 insertions(+), 440 deletions(-) delete mode 100644 crates/evm/src/tx_context.rs create mode 100644 crates/precompiles/src/tx_context.rs diff --git a/Cargo.lock b/Cargo.lock index c7983ebfc..599e1d450 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13396,7 +13396,6 @@ dependencies = [ "alloy-evm", "alloy-primitives", "alloy-provider", - "alloy-sol-types", "eyre", "reth-chainspec", "reth-evm", @@ -13412,7 +13411,6 @@ dependencies = [ "tempo-revm", "tempo-zone-contracts", "tokio", - "tracing", "zone-chainspec", "zone-l1", "zone-precompiles", diff --git a/crates/contracts/src/precompiles/outbox.rs b/crates/contracts/src/precompiles/outbox.rs index 1e7a2461e..6b4b8d821 100644 --- a/crates/contracts/src/precompiles/outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -3,7 +3,7 @@ pub use IZoneOutbox::{LastBatch, PendingWithdrawal, StaticCallNotAllowed}; crate::sol! { - #[derive(Debug)] + #[derive(Debug)] contract IZoneOutbox { struct LastBatch { @@ -121,4 +121,19 @@ crate::sol! { ) external; function finalizeWithdrawalBatch(uint256 count, uint64 blockNumber, bytes[] calldata encryptedSenders) external returns (bytes32 withdrawalQueueHash); } + + /// Legacy seven-argument withdrawal interface. The current overload adds `revealTo`. + #[derive(Debug)] + interface ILegacyZoneOutbox { + function requestWithdrawal( + address token, + address to, + uint128 amount, + bytes32 memo, + uint64 gasLimit, + address fallbackRecipient, + bytes calldata data + ) external; + } + } diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index c9b524a77..05df44c10 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -36,17 +36,14 @@ reth-revm.workspace = true # alloy alloy-consensus.workspace = true alloy-evm.workspace = true -alloy-primitives.workspace = true alloy-provider = { workspace = true, features = ["reqwest"] } -alloy-sol-types.workspace = true # revm revm.workspace = true # misc tokio.workspace = true -tracing.workspace = true - [dev-dependencies] +alloy-primitives.workspace = true eyre.workspace = true tempo-precompiles = { workspace = true, features = ["test-utils"] } diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index 214ca3754..d482ec4b7 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -21,7 +21,7 @@ use tempo_primitives::{TempoReceipt, TempoTxEnvelope, TempoTxType}; use tempo_revm::{TempoStateAccess, evm::TempoContext}; use zone_chainspec::ZoneChainSpec; -use crate::{ZoneEvm, tx_context}; +use crate::ZoneEvm; /// Simplified block executor for zone nodes. /// @@ -102,7 +102,8 @@ where // transaction's resolved fee token, so the handler skips FeeAMM. self.override_validator_token(); - let _tx_hash_guard = tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); + let _tx_hash_guard = + zone_precompiles::tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); self.inner .execute_transaction_without_commit((tx_env, recovered)) } diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 09e5f8f43..b2d21241a 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -9,7 +9,6 @@ mod executor; pub mod precompiles; -mod tx_context; mod zone_evm; pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; @@ -17,7 +16,6 @@ pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; use crate::{ executor::ZoneBlockExecutor, precompiles::{SequencerExt, extend_zone_precompiles}, - tx_context::ZoneTxContext, }; use alloy_evm::{ Database, Evm, EvmEnv, EvmFactory, @@ -81,7 +79,6 @@ impl ZoneEvmFactory { StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), ); - precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| Some(ZoneTxContext::create())); evm } } diff --git a/crates/evm/src/tx_context.rs b/crates/evm/src/tx_context.rs deleted file mode 100644 index b935fc64d..000000000 --- a/crates/evm/src/tx_context.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Transaction-hash execution context for authenticated withdrawals. -//! -//! The zone outbox needs the real hash of the currently executing user transaction so it can -//! commit `senderTag = keccak256(sender || txHash)` on-chain. The block executor publishes that -//! hash into a thread-local context before EVM execution, and this precompile exposes it to -//! Solidity at a fixed system address. - -use std::{cell::RefCell, thread_local}; - -use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{B256, Bytes}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileId, PrecompileOutput}; -use tracing::{debug, warn}; - -alloy_sol_types::sol! { - function currentTxHash() external returns (bytes32); - error DelegateCallNotAllowed(); -} - -thread_local! { - static CURRENT_TX_HASH: RefCell> = const { RefCell::new(None) }; -} - -/// Guard that clears the current tx hash when dropped. -pub(crate) struct TxHashGuard; - -impl Drop for TxHashGuard { - fn drop(&mut self) { - clear_current_tx_hash(); - } -} - -/// Publish the current executing transaction hash for the duration of EVM execution. -pub(crate) fn set_current_tx_hash(tx_hash: B256) -> TxHashGuard { - CURRENT_TX_HASH.with(|slot| { - *slot.borrow_mut() = Some(tx_hash); - }); - TxHashGuard -} - -fn clear_current_tx_hash() { - CURRENT_TX_HASH.with(|slot| { - *slot.borrow_mut() = None; - }); -} - -fn current_tx_hash() -> Option { - CURRENT_TX_HASH.with(|slot| *slot.borrow()) -} - -/// `DynPrecompile` implementation that returns the currently executing zone tx hash. -pub(crate) struct ZoneTxContext; - -impl ZoneTxContext { - pub(crate) fn create() -> DynPrecompile { - DynPrecompile::new_stateful(PrecompileId::Custom("ZoneTxContext".into()), move |input| { - if !input.is_direct_call() { - warn!( - target: "zone::precompile", - "ZoneTxContext called via DELEGATECALL — rejecting" - ); - return Ok(PrecompileOutput::revert( - 0, - DelegateCallNotAllowed {}.abi_encode().into(), - input.reservoir, - )); - } - - let data = input.data; - if data.len() < 4 { - warn!( - target: "zone::precompile", - data_len = data.len(), - "ZoneTxContext called with insufficient data" - ); - return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); - } - - let selector: [u8; 4] = data[..4].try_into().expect("len >= 4"); - if selector != currentTxHashCall::SELECTOR { - warn!( - target: "zone::precompile", - ?selector, - "ZoneTxContext: unknown selector" - ); - return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); - } - - debug!(target: "zone::precompile", "ZoneTxContext: currentTxHash"); - - let Some(tx_hash) = current_tx_hash() else { - warn!( - target: "zone::precompile", - "ZoneTxContext: current transaction hash is not set" - ); - return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); - }; - let encoded = currentTxHashCall::abi_encode_returns(&tx_hash); - Ok(PrecompileOutput::new(20, encoded.into(), input.reservoir)) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_evm::{ - EvmInternals, - precompiles::{Precompile, PrecompileInput}, - }; - use alloy_primitives::{Address, U256}; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - }; - use tempo_chainspec::hardfork::TempoHardfork; - - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - - fn call_with_tx_hash(tx_hash: Option) -> PrecompileOutput { - let _guard = tx_hash.map(set_current_tx_hash); - let mut ctx: TestContext = - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); - let calldata = currentTxHashCall {}.abi_encode(); - - ZoneTxContext::create() - .call(PrecompileInput { - data: &calldata, - gas: u64::MAX, - reservoir: 0, - caller: Address::ZERO, - value: U256::ZERO, - target_address: Address::ZERO, - is_static: true, - bytecode_address: Address::ZERO, - internals: EvmInternals::from_context(&mut ctx), - }) - .expect("precompile call should not fail") - } - - #[test] - fn returns_current_transaction_hash() { - let tx_hash = B256::repeat_byte(0x42); - let output = call_with_tx_hash(Some(tx_hash)); - - assert!(!output.is_revert()); - assert_eq!( - output.bytes, - currentTxHashCall::abi_encode_returns(&tx_hash) - ); - } - - #[test] - fn reverts_when_current_transaction_hash_is_not_set() { - let output = call_with_tx_hash(None); - - assert!(output.is_revert()); - assert!(output.bytes.is_empty()); - } -} diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index a01d0b092..a07e62432 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -279,6 +279,10 @@ impl L1StateProvider { } impl L1StorageReader for L1StateProvider { + fn portal_address(&self) -> Address { + self.portal_address + } + fn read_l1_storage( &self, account: Address, diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index f3f526c9f..d652c2813 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -14,9 +14,10 @@ //! 1. L1-backed execution rejects delegate calls before storage access. //! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. //! 3. Apply the local phase of [`CallRules`]. Rejected calls return without touching L1. -//! 4. For admitted L1-backed calls, resolve the anchor, hardfork, and storage overlay, then apply -//! the L1-backed rules phase. -//! 5. Forward the original calldata and caller, applying any configured fixed gas charge. +//! 4. Calls that do not require L1 execute immediately against zone-local state. +//! 5. For calls requiring L1, resolve the anchor, hardfork, and storage overlay, then apply the +//! L1-backed rules phase. +//! 6. Forward the original calldata and caller, applying any configured fixed gas charge. //! //! Rule-level rejections include calldata input gas. Calls without a fixed charge retain normal //! provider metering, while successful fixed-price calls report exactly the configured charge. @@ -86,6 +87,8 @@ pub(crate) struct ZoneCall<'a> { pub(crate) caller: Address, /// Whether target and bytecode addresses match. pub(crate) is_direct: bool, + /// Whether the EVM call is static. + pub(crate) is_static: bool, } impl<'a> ZoneCall<'a> { @@ -94,6 +97,7 @@ impl<'a> ZoneCall<'a> { data: input.data, caller: input.caller, is_direct: input.is_direct_call(), + is_static: input.is_static, } } @@ -115,19 +119,32 @@ pub(crate) enum CallCheck { Return(PrecompileResult), } +impl CallCheck { + /// Reject a call with a typed Tempo or zone-native precompile error. + pub(crate) fn from_error(error: impl Into) -> Self { + Self::Return(StorageCtx::default().error_result(error.into())) + } +} + /// Selector-, caller-, and call-context-dependent rules evaluated by centralized precompile /// execution before invoking the implementation. /// -/// The local phase runs against ordinary zone state before any optional finalized-L1 resolution. -/// L1-backed execution then runs a second phase against the exact anchored overlay. Rules may -/// enforce admission policy and duplicate cheap business checks as fail-fast preflight, but the -/// precompile implementation remains responsible for its canonical business invariants. +/// The local phase always runs before optional finalized-L1 state resolution. Calls selected for +/// anchored execution then run the L1-backed phase against that exact overlay. Rules may enforce +/// admission policy and duplicate cheap business checks as fail-fast preflight, but the precompile +/// implementation remains responsible for its canonical business invariants. pub(crate) trait CallRules: 'static { /// Return the fixed gas charge for this selector, if one applies. fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { None } + /// Return whether this selector requires execution against the finalized-L1 storage overlay. + /// Calls returning `false` remain entirely local and execute after the local rules phase. + fn requires_l1(&self, _selector: Option<[u8; 4]>) -> bool { + true + } + /// Apply rules using only calldata, caller, call context, and ordinary zone-local state. /// /// This phase always runs before optional L1 anchor resolution. It may reject invalid calls @@ -138,8 +155,8 @@ pub(crate) trait CallRules: 'static { /// Apply rules whose answer must come from the finalized Tempo L1-backed storage overlay. /// - /// This phase runs only for L1-backed execution, after the anchor and overlay have been - /// resolved. + /// This phase runs only when [`Self::requires_l1`] returns `true`, after the anchor and overlay + /// have been resolved. fn check_with_l1_backed_state(&self, _call: ZoneCall<'_>) -> CallCheck { CallCheck::Continue } @@ -210,16 +227,16 @@ pub(crate) fn create_local_precompile( }) } -/// Create a direct-call-only precompile backed by the finalized Tempo L1 anchor. +/// Create a direct-call-only precompile with selector-dependent finalized-L1 backing. /// -/// The helper rejects delegate calls before any storage access, reads the `TempoState` anchor once, -/// and constructs [`ZonePrecompileStorageProvider`] with that exact block. Construction is -/// fallible because the provider resolves the active hardfork from the same anchor. Any anchor, -/// hardfork, or L1 storage failure is returned as a precompile error rather than falling back to -/// local or latest state. +/// The helper rejects delegate calls before storage access and applies the local rules phase. +/// Selectors for which [`CallRules::requires_l1`] returns `false` execute immediately against local +/// state. Other selectors read the `TempoState` anchor once and construct +/// [`ZonePrecompileStorageProvider`] with that exact block. Construction is fallible because the +/// provider resolves the active hardfork from the same anchor. Any anchor, hardfork, or L1 storage +/// failure is returned as a precompile error rather than falling back to local or latest state. /// -/// Calls admitted by `rules` are forwarded to `execute` with their original calldata and caller -/// while the L1 overlay is active. +/// Admitted calls retain their original calldata and caller in either execution mode. pub(crate) fn create_l1_backed_precompile( id: &'static str, env: L1BackedPrecompileEnv

, @@ -271,6 +288,11 @@ pub(crate) fn create_l1_backed_precompile( } } + if !rules.requires_l1(call.selector()) { + let exec_result = StorageCtx::enter(&mut inner, || execute(call.data, call.caller)); + return apply_fixed_gas(exec_result, fixed_gas); + } + let mut storage = match ZonePrecompileStorageProvider::try_new(inner, l1_reader.clone()) { Ok(storage) => storage, Err(err) => return err.into_precompile_result(), @@ -326,7 +348,6 @@ mod tests { cell::{Cell, RefCell}, rc::Rc, }; - use tempo_precompiles::storage::PrecompileStorageProvider; const FIXED_GAS: u64 = 123; type RuleRecord = Rc, Address)>>>; @@ -411,8 +432,7 @@ mod tests { let reader = MockL1Reader::default(); let observed_spec = Rc::new(Cell::new(None)); let execute_spec = observed_spec.clone(); - let mut cfg = revm::context::CfgEnv::::default(); - cfg.spec = TempoHardfork::T8; + let cfg = revm::context::CfgEnv::::default(); let env = L1BackedPrecompileEnv::new( &cfg, reader.clone(), @@ -434,6 +454,7 @@ mod tests { .is_revert() ); assert!(checked.get()); + assert!(reader.hardfork_requests().is_empty()); let precompile = create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { @@ -452,6 +473,7 @@ mod tests { .call(input(&mut ctx, &[], Address::ZERO, u64::MAX)) .unwrap(); + assert_eq!(reader.hardfork_requests(), vec![anchor]); assert_eq!(observed_spec.get(), Some(TempoHardfork::T8)); } diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 6fd415295..e14546327 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -51,6 +51,7 @@ pub mod storage; pub mod tempo_state; pub mod tip20_factory; pub mod tip403_proxy; +pub mod tx_context; pub mod ztip20; pub use aes_gcm::{AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt}; @@ -79,7 +80,8 @@ use tempo_precompiles::{ tip20::{TIP20Token, is_tip20_prefix}, tip403_registry::TIP403Registry, }; -use zone_primitives::constants::TEMPO_STATE_ADDRESS; +use tempo_zone_contracts::ZONE_TX_CONTEXT_ADDRESS; +use zone_primitives::constants::{TEMPO_STATE_ADDRESS, ZONE_OUTBOX_ADDRESS}; /// Register zone-native and currently supported Tempo precompiles. /// @@ -115,6 +117,19 @@ pub fn extend_zone_precompiles( precompiles.apply_precompile(&ZONE_TIP20_FACTORY_ADDRESS, |_| { Some(ZoneTokenFactory::create(cfg)) }); + let outbox_env = l1_env.clone(); + let portal = l1_reader.portal_address(); + precompiles.apply_precompile(&ZONE_OUTBOX_ADDRESS, move |_| { + Some(execution::create_l1_backed_precompile( + "ZoneOutbox", + outbox_env.clone(), + outbox::ZoneOutboxRules::new(portal), + |data, caller| ZoneOutbox::new().call(data, caller), + )) + }); + precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| { + Some(tx_context::create_precompile()) + }); let tip403_env = l1_env.clone(); precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, move |_| { diff --git a/crates/precompiles/src/outbox/dispatch.rs b/crates/precompiles/src/outbox/dispatch.rs index a7ff768a2..bc98ed03a 100644 --- a/crates/precompiles/src/outbox/dispatch.rs +++ b/crates/precompiles/src/outbox/dispatch.rs @@ -1,176 +1,71 @@ -//! ABI dispatch for the [`ZoneOutbox`] precompile. +//! ABI dispatch and centralized call rules for the [`ZoneOutbox`] precompile. -use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; -use alloy_primitives::{Address, B256, Bytes, U256}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult}; +use alloy_primitives::{Address, Bytes, U256}; +use revm::precompile::PrecompileResult; use tempo_precompiles::{ - DelegateCallNotAllowed, charge_input_cost, dispatch, - storage::{Handler, StorageCtx, evm::EvmPrecompileStorageProvider}, + Precompile, charge_input_cost, dispatch, + storage::Handler, view, }; -use tempo_zone_contracts::IZoneOutbox; +use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox}; use zone_primitives::constants::{MAX_WITHDRAWAL_GAS_LIMIT, ZONE_CONFIG_ADDRESS}; -use crate::ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE; +use crate::{ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, tx_context}; use super::{ - MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, REVEAL_TO_KEY_LENGTH, WITHDRAWAL_BASE_GAS, - ZoneOutbox, ZonePortalReader, + MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, REVEAL_TO_KEY_LENGTH, WITHDRAWAL_BASE_GAS, ZoneOutbox, }; -alloy_sol_types::sol! { - function requestWithdrawal( - address token, - address to, - uint128 amount, - bytes32 memo, - uint64 gasLimit, - address fallbackRecipient, - bytes data - ) external; -} - -impl ZoneOutbox { - fn call_with_context( - &mut self, - provider: &P, - current_tx_hash: B256, - calldata: &[u8], - msg_sender: Address, - ) -> PrecompileResult { +impl Precompile for ZoneOutbox { + fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { if let Some(err) = charge_input_cost(&mut self.storage, calldata) { return err; } - // Preserve the original seven-argument overload. The generated interface - // contains the newer overload with `revealTo`, so dispatch this selector - // explicitly and treat it as an empty reveal key. - if tempo_precompiles::dispatch::selector_from_calldata(calldata) - == Some(requestWithdrawalCall::SELECTOR) - { - let Ok(call) = requestWithdrawalCall::abi_decode_raw_validate(&calldata[4..]) else { - return Ok(self.storage.revert_output(Bytes::new())); - }; - return self.request_withdrawal( - provider, - msg_sender, - current_tx_hash, - IZoneOutbox::requestWithdrawalCall { - token: call.token, - to: call.to, - amount: call.amount, - memo: call.memo, - gasLimit: call.gasLimit, - fallbackRecipient: call.fallbackRecipient, - data: call.data, - revealTo: Bytes::new(), - }, - ); - } - - dispatch!( - calldata, - |call| match call { - IZoneOutbox::IZoneOutboxCalls { - config(call) => view(call, |_| Ok(ZONE_CONFIG_ADDRESS)), - tempoGasRate(call) => view(call, |_| self.tempo_gas_rate.read()), - maxWithdrawalsPerBlock(call) => { - view(call, |_| self.max_withdrawals_per_block.read()) - }, - lastBatch(call) => view(call, |_| self.last_batch()), - withdrawalBatchIndex(call) => { - view(call, |_| self.withdrawal_batch_index.read()) - }, - lastFinalizedTimestamp(call) => { - view(call, |_| self.last_finalized_timestamp.read()) - }, - nextWithdrawalIndex(call) => { - view(call, |_| self.next_withdrawal_index.read()) - }, - pendingWithdrawalsCount(call) => { - view(call, |_| self.pending_withdrawals_count()) - }, - getPendingWithdrawals(call) => { - view(call, |_| self.get_pending_withdrawals()) - }, - calculateWithdrawalFee(call) => { - self.calculate_withdrawal_fee(call.gasLimit) - }, - MAX_CALLBACK_DATA_SIZE(call) => { - view(call, |_| Ok(U256::from(MAX_CALLBACK_DATA_SIZE))) - }, - MAX_WITHDRAWAL_GAS_LIMIT(call) => { - view(call, |_| Ok(MAX_WITHDRAWAL_GAS_LIMIT)) - }, - MAX_GAS_FEE_RATE(call) => view(call, |_| Ok(MAX_GAS_FEE_RATE)), - WITHDRAWAL_BASE_GAS(call) => view(call, |_| Ok(WITHDRAWAL_BASE_GAS)), - REVEAL_TO_KEY_LENGTH(call) => { - view(call, |_| Ok(U256::from(REVEAL_TO_KEY_LENGTH))) - }, - AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(call) => { - view(call, |_| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))) - }, - setTempoGasRate(call) => { - self.set_tempo_gas_rate(provider, msg_sender, call) - }, - setMaxWithdrawalsPerBlock(call) => { - self.set_max_withdrawals_per_block(provider, msg_sender, call) - }, - requestWithdrawal(call) => self.request_withdrawal( - provider, - msg_sender, - current_tx_hash, - call, - ), - enqueueDepositBounceBack(call) => { - self.enqueue_deposit_bounce_back(msg_sender, call) - }, - finalizeWithdrawalBatch(call) => { - self.finalize_withdrawal_batch(provider, msg_sender, call) + dispatch!(calldata, |call| match call { + IZoneOutbox::IZoneOutboxCalls { + config(call) => view(call, |_| Ok(ZONE_CONFIG_ADDRESS)), + tempoGasRate(call) => view(call, |_| self.tempo_gas_rate.read()), + maxWithdrawalsPerBlock(call) => view(call, |_| self.max_withdrawals_per_block.read()), + lastBatch(call) => view(call, |_| self.last_batch()), + withdrawalBatchIndex(call) => view(call, |_| self.withdrawal_batch_index.read()), + lastFinalizedTimestamp(call) => view(call, |_| self.last_finalized_timestamp.read()), + nextWithdrawalIndex(call) => view(call, |_| self.next_withdrawal_index.read()), + pendingWithdrawalsCount(call) => view(call, |_| self.pending_withdrawals_count()), + getPendingWithdrawals(call) => view(call, |_| self.get_pending_withdrawals()), + calculateWithdrawalFee(call) => self.calculate_withdrawal_fee(call.gasLimit), + MAX_CALLBACK_DATA_SIZE(call) => view(call, |_| Ok(U256::from(MAX_CALLBACK_DATA_SIZE))), + MAX_WITHDRAWAL_GAS_LIMIT(call) => view(call, |_| Ok(MAX_WITHDRAWAL_GAS_LIMIT)), + MAX_GAS_FEE_RATE(call) => view(call, |_| Ok(MAX_GAS_FEE_RATE)), + WITHDRAWAL_BASE_GAS(call) => view(call, |_| Ok(WITHDRAWAL_BASE_GAS)), + REVEAL_TO_KEY_LENGTH(call) => view(call, |_| Ok(U256::from(REVEAL_TO_KEY_LENGTH))), + AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(call) => view(call, |_| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))), + setTempoGasRate(call) => self.set_tempo_gas_rate(call), + setMaxWithdrawalsPerBlock(call) => self.set_max_withdrawals_per_block(call), + requestWithdrawal(call) => self.request_withdrawal( + msg_sender, + tx_context::current_tx_hash().unwrap_or_default(), + call, + ), + enqueueDepositBounceBack(call) => self.enqueue_deposit_bounce_back(msg_sender, call), + finalizeWithdrawalBatch(call) => self.finalize_withdrawal_batch(call), + } + ILegacyZoneOutbox::ILegacyZoneOutboxCalls { + requestWithdrawal(call) => self.request_withdrawal( + msg_sender, + tx_context::current_tx_hash().unwrap_or_default(), + IZoneOutbox::requestWithdrawalCall { + token: call.token, + to: call.to, + amount: call.amount, + memo: call.memo, + gasLimit: call.gasLimit, + fallbackRecipient: call.fallbackRecipient, + data: call.data, + revealTo: Bytes::new(), }, - } - }, - ) - } - - /// Wrap this precompile for registration in the zone EVM. - pub fn create( - provider: P, - tx_hash: F, - cfg: &revm::context::CfgEnv, - ) -> DynPrecompile - where - P: ZonePortalReader + Clone + Send + Sync + 'static, - F: for<'a> Fn(&PrecompileInput<'a>) -> B256 + Clone + Send + Sync + 'static, - { - let spec = cfg.spec; - let amsterdam_eip8037_enabled = cfg.enable_amsterdam_eip8037; - let gas_params = cfg.gas_params.clone(); - - DynPrecompile::new_stateful(PrecompileId::Custom("ZoneOutbox".into()), move |input| { - if !input.is_direct_call() { - return Ok(PrecompileOutput::revert( - 0, - SolError::abi_encode(&DelegateCallNotAllowed {}).into(), - input.reservoir, - )); + ), } - - let current_tx_hash = tx_hash(&input); - let mut storage = EvmPrecompileStorageProvider::new( - input.internals, - input.gas, - input.reservoir, - spec, - amsterdam_eip8037_enabled, - input.is_static, - gas_params.clone(), - ); - - StorageCtx::enter(&mut storage, || { - Self::new().call_with_context(&provider, current_tx_hash, input.data, input.caller) - }) }) } } diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index 128cd108b..018078e75 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -6,23 +6,24 @@ use alloc::vec::Vec; use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolError, SolValue}; -use revm::precompile::{PrecompileError, PrecompileResult}; +use revm::{interpreter::instructions::utility::IntoAddress, precompile::PrecompileResult}; use tempo_precompiles::{ Result as TempoResult, error::TempoPrecompileError, - storage::Handler, + storage::{Handler, StorageCtx}, tip20::{ITIP20, TIP20Token}, }; use tempo_precompiles_macros::{Storable, contract}; -use tempo_zone_contracts::IZoneOutbox as ZoneOutboxAbi; +use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi}; use zone_primitives::constants::{ EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, }; use crate::{ - chaum_pedersen::recover_point, ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, - storage::L1StorageReader, tempo_state::TempoState, + chaum_pedersen::recover_point, + ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, + execution::{CallCheck, CallRules, ZoneCall}, }; const MAX_CALLBACK_DATA_SIZE: usize = 1024; @@ -32,11 +33,92 @@ const REVEAL_TO_KEY_LENGTH: usize = 33; const PORTAL_TOKEN_CONFIGS_SLOT: B256 = B256::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, ]); +const SEQUENCER_SELECTORS: &[[u8; 4]] = &[ + ZoneOutboxAbi::setTempoGasRateCall::SELECTOR, + ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall::SELECTOR, + ZoneOutboxAbi::finalizeWithdrawalBatchCall::SELECTOR, +]; +const WITHDRAWAL_SELECTORS: &[[u8; 4]] = &[ + ZoneOutboxAbi::requestWithdrawalCall::SELECTOR, + ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR, +]; + +/// Admission checks that require the finalized `ZonePortal` state. +pub(crate) struct ZoneOutboxRules { + portal: Address, +} + +impl ZoneOutboxRules { + pub(crate) fn new(portal: Address) -> Self { + Self { portal } + } + + fn revert(error: impl SolError) -> CallCheck { + CallCheck::Return(Ok( + StorageCtx::default().revert_output(error.abi_encode().into()) + )) + } +} + +impl CallRules for ZoneOutboxRules { + fn requires_l1(&self, selector: Option<[u8; 4]>) -> bool { + selector.is_some_and(|selector| { + SEQUENCER_SELECTORS.contains(&selector) || WITHDRAWAL_SELECTORS.contains(&selector) + }) + } + + fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + if call.is_static + && call.selector().is_some_and(|selector| { + self.requires_l1(Some(selector)) + || selector == ZoneOutboxAbi::enqueueDepositBounceBackCall::SELECTOR + }) + { + Self::revert(ZoneOutboxAbi::StaticCallNotAllowed {}) + } else { + CallCheck::Continue + } + } -/// L1 portal state needed by the native outbox. -pub trait ZonePortalReader: L1StorageReader { - /// Zone portal address on Tempo L1. - fn portal_address(&self) -> Address; + fn check_with_l1_backed_state(&self, call: ZoneCall<'_>) -> CallCheck { + let Some(selector) = call.selector() else { + return CallCheck::Continue; + }; + if SEQUENCER_SELECTORS.contains(&selector) && call.caller != Address::ZERO { + let sequencer = match StorageCtx::default() + .sload(self.portal, U256::from_be_bytes(PORTAL_SEQUENCER_SLOT.0)) + { + Ok(value) => value.into_address(), + Err(err) => return CallCheck::Return(StorageCtx::default().error_result(err)), + }; + if sequencer != call.caller { + return Self::revert(ZoneOutboxAbi::OnlySequencer {}); + } + } + + let token = match selector { + ZoneOutboxAbi::requestWithdrawalCall::SELECTOR => { + ZoneOutboxAbi::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) + .ok() + .map(|call| call.token) + } + ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { + ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) + .ok() + .map(|call| call.token) + } + _ => None, + }; + if let Some(token) = token { + let slot = keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()).into(); + match StorageCtx::default().sload(self.portal, slot) { + Ok(value) if value.byte(0) != 0 => {} + Ok(_) => return Self::revert(ZoneOutboxAbi::TokenNotEnabled {}), + Err(err) => return CallCheck::Return(StorageCtx::default().error_result(err)), + } + } + CallCheck::Continue + } } #[contract(addr = ZONE_OUTBOX_ADDRESS)] @@ -69,49 +151,6 @@ impl ZoneOutbox { } } - fn portal_storage( - &self, - provider: &P, - slot: B256, - ) -> Result { - let tempo_block_number = self - .current_tempo_block_number() - .map_err(|err| PrecompileError::Fatal(err.to_string()))?; - provider.read_l1_storage(provider.portal_address(), slot, tempo_block_number) - } - - fn current_tempo_block_number(&self) -> TempoResult { - TempoState::new().current_tempo_block_number() - } - - fn sequencer(&self, provider: &P) -> Result { - let value = self.portal_storage(provider, PORTAL_SEQUENCER_SLOT)?; - Ok(Address::from_slice(&value.as_slice()[12..])) - } - - fn token_enabled( - &self, - provider: &P, - token: Address, - ) -> Result { - let slot = keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()); - let value = self.portal_storage(provider, slot)?; - Ok(value.as_slice()[31] != 0) - } - - fn ensure_sequencer( - &self, - provider: &P, - caller: Address, - ) -> PrecompileResult { - if caller == Address::ZERO || caller == self.sequencer(provider)? { - return Ok(self.storage.success_output(Bytes::new())); - } - Ok(self - .storage - .revert_output(ZoneOutboxAbi::OnlySequencer {}.abi_encode().into())) - } - fn validate_gas_limit(&self, gas_limit: u64) -> Option { if gas_limit > MAX_WITHDRAWAL_GAS_LIMIT { Some(Ok(self.storage.revert_output( @@ -238,9 +277,8 @@ impl ZoneOutbox { Ok(self.storage.success_output(Bytes::new())) } - fn request_withdrawal( + fn request_withdrawal( &mut self, - provider: &P, caller: Address, current_tx_hash: B256, call: ZoneOutboxAbi::requestWithdrawalCall, @@ -255,11 +293,6 @@ impl ZoneOutbox { .into(), )); } - if !self.token_enabled(provider, call.token)? { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::TokenNotEnabled {}.abi_encode().into())); - } if let Some(revert) = self.validate_gas_limit(call.gasLimit) { return revert; } @@ -433,19 +466,13 @@ impl ZoneOutbox { Ok(self.storage.success_output(Bytes::new())) } - fn finalize_withdrawal_batch( + fn finalize_withdrawal_batch( &mut self, - provider: &P, - caller: Address, call: ZoneOutboxAbi::finalizeWithdrawalBatchCall, ) -> PrecompileResult { if let Some(revert) = self.static_revert() { return revert; } - let sequencer_check = self.ensure_sequencer(provider, caller)?; - if sequencer_check.is_revert() { - return Ok(sequencer_check); - } if call.blockNumber != self.storage.block_number() { return Ok(self .storage @@ -581,19 +608,10 @@ impl ZoneOutbox { )) } - fn set_tempo_gas_rate( - &mut self, - provider: &P, - caller: Address, - call: ZoneOutboxAbi::setTempoGasRateCall, - ) -> PrecompileResult { + fn set_tempo_gas_rate(&mut self, call: ZoneOutboxAbi::setTempoGasRateCall) -> PrecompileResult { if let Some(revert) = self.static_revert() { return revert; } - let sequencer_check = self.ensure_sequencer(provider, caller)?; - if sequencer_check.is_revert() { - return Ok(sequencer_check); - } if call._tempoGasRate > MAX_GAS_FEE_RATE { return Ok(self .storage @@ -610,19 +628,13 @@ impl ZoneOutbox { Ok(self.storage.success_output(Bytes::new())) } - fn set_max_withdrawals_per_block( + fn set_max_withdrawals_per_block( &mut self, - provider: &P, - caller: Address, call: ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall, ) -> PrecompileResult { if let Some(revert) = self.static_revert() { return revert; } - let sequencer_check = self.ensure_sequencer(provider, caller)?; - if sequencer_check.is_revert() { - return Ok(sequencer_check); - } if let Err(err) = self .max_withdrawals_per_block .write(call._maxWithdrawalsPerBlock) diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 84e8b28dd..62d280ee5 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -41,6 +41,9 @@ use zone_primitives::constants::TEMPO_STATE_ADDRESS; /// L1 storage access needed by zone precompile storage overlays and `TempoState` reads. pub trait L1StorageReader: Clone + Send + Sync + 'static { + /// Zone portal account whose configuration is mirrored from Tempo L1. + fn portal_address(&self) -> Address; + /// Read `account[slot]` at `block_number` on Tempo L1. fn read_l1_storage( &self, @@ -150,7 +153,7 @@ impl PrecompileStorageProvider for ZonePrecompileStorageProv // Run the local SLOAD first to preserve EVM warm/cold state, gas charging, and storage-action // recording; mirrored L1 state overrides only the value observed by TIP-20/TIP-403 logic. let local = self.inner.sload(address, key)?; - if address == TIP403_REGISTRY_ADDRESS { + if address == TIP403_REGISTRY_ADDRESS || address == self.l1.portal_address() { return self.read_l1_slot(address, key); } if is_tip20_policy_id_slot(address, key) { @@ -166,6 +169,7 @@ impl PrecompileStorageProvider for ZonePrecompileStorageProv fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { if address == TIP403_REGISTRY_ADDRESS + || address == self.l1.portal_address() || is_tip20_policy_id_slot(address, key) && value != merge_transfer_policy_id(value, self.read_l1_slot(address, key)?) { diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index ecb545cee..285b724bd 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -253,6 +253,10 @@ impl MockL1Reader { } impl L1StorageReader for MockL1Reader { + fn portal_address(&self) -> Address { + Address::repeat_byte(0x77) + } + fn read_l1_storage( &self, account: Address, diff --git a/crates/precompiles/src/tx_context.rs b/crates/precompiles/src/tx_context.rs new file mode 100644 index 000000000..9deb24d10 --- /dev/null +++ b/crates/precompiles/src/tx_context.rs @@ -0,0 +1,135 @@ +//! Transaction-hash execution context shared by the zone EVM and native precompiles. + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::{B256, Bytes}; +use alloy_sol_types::{SolCall, SolError}; +use revm::precompile::{PrecompileId, PrecompileOutput}; + +alloy_sol_types::sol! { + function currentTxHash() external returns (bytes32); + error DelegateCallNotAllowed(); +} + +#[cfg(feature = "std")] +use std::{cell::RefCell, thread_local}; + +#[cfg(feature = "std")] +thread_local! { + static CURRENT_TX_HASH: RefCell> = const { RefCell::new(None) }; +} + +/// Guard that clears the published transaction hash when dropped. +#[cfg(feature = "std")] +pub struct TxHashGuard; + +#[cfg(feature = "std")] +impl Drop for TxHashGuard { + fn drop(&mut self) { + CURRENT_TX_HASH.with(|slot| *slot.borrow_mut() = None); + } +} + +/// Publish the current transaction hash for the duration of EVM execution. +#[cfg(feature = "std")] +pub fn set_current_tx_hash(tx_hash: B256) -> TxHashGuard { + CURRENT_TX_HASH.with(|slot| *slot.borrow_mut() = Some(tx_hash)); + TxHashGuard +} + +/// Return the hash of the transaction currently executing, when supplied by the host. +#[cfg(feature = "std")] +pub fn current_tx_hash() -> Option { + CURRENT_TX_HASH.with(|slot| *slot.borrow()) +} + +/// Prover execution currently has no thread-local host context. +#[cfg(not(feature = "std"))] +pub fn current_tx_hash() -> Option { + None +} + +/// Create the transaction-context precompile. Calls without a transaction published by the +/// execution host revert rather than inventing an identifier. +pub(crate) fn create_precompile() -> DynPrecompile { + DynPrecompile::new_stateful(PrecompileId::Custom("ZoneTxContext".into()), |input| { + if !input.is_direct_call() { + return Ok(PrecompileOutput::revert( + 0, + DelegateCallNotAllowed {}.abi_encode().into(), + input.reservoir, + )); + } + if input.data.len() < 4 || input.data[..4] != currentTxHashCall::SELECTOR { + return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); + } + let Some(hash) = current_tx_hash() else { + return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); + }; + Ok(PrecompileOutput::new( + 20, + currentTxHashCall::abi_encode_returns(&hash).into(), + input.reservoir, + )) + }) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use alloy_evm::{ + EvmInternals, + precompiles::{Precompile, PrecompileInput}, + }; + use alloy_primitives::{Address, U256}; + use revm::{ + Context, + database::{CacheDB, EmptyDB}, + }; + use tempo_chainspec::hardfork::TempoHardfork; + + type TestContext = Context< + revm::context::BlockEnv, + revm::context::TxEnv, + revm::context::CfgEnv, + CacheDB, + >; + + fn call_with_tx_hash(tx_hash: Option) -> PrecompileOutput { + let _guard = tx_hash.map(set_current_tx_hash); + let mut ctx: TestContext = + Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); + let calldata = currentTxHashCall {}.abi_encode(); + + create_precompile() + .call(PrecompileInput { + data: &calldata, + gas: u64::MAX, + reservoir: 0, + caller: Address::ZERO, + value: U256::ZERO, + target_address: Address::ZERO, + is_static: true, + bytecode_address: Address::ZERO, + internals: EvmInternals::from_context(&mut ctx), + }) + .expect("precompile call should not fail") + } + + #[test] + fn returns_current_transaction_hash() { + let tx_hash = B256::repeat_byte(0x42); + let output = call_with_tx_hash(Some(tx_hash)); + assert!(!output.is_revert()); + assert_eq!( + output.bytes, + currentTxHashCall::abi_encode_returns(&tx_hash) + ); + } + + #[test] + fn reverts_when_current_transaction_hash_is_not_set() { + let output = call_with_tx_hash(None); + assert!(output.is_revert()); + assert!(output.bytes.is_empty()); + } +} From 9a4d9fb3b9c6e8dd569c06dccbaec205d4ae55fb Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 10:13:47 +0200 Subject: [PATCH 22/30] feat: improve error and `CallRules` devex --- crates/contracts/src/precompiles/outbox.rs | 52 +- crates/precompiles/src/error.rs | 52 +- crates/precompiles/src/outbox/dispatch.rs | 85 ++-- crates/precompiles/src/outbox/mod.rs | 536 +++++++-------------- crates/precompiles/src/tip403_proxy/mod.rs | 31 +- 5 files changed, 295 insertions(+), 461 deletions(-) diff --git a/crates/contracts/src/precompiles/outbox.rs b/crates/contracts/src/precompiles/outbox.rs index 6b4b8d821..fca6344f8 100644 --- a/crates/contracts/src/precompiles/outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -1,9 +1,42 @@ //! `ZoneOutbox` — deployed on the Zone L2. -pub use IZoneOutbox::{LastBatch, PendingWithdrawal, StaticCallNotAllowed}; +pub use IZoneOutbox::{ + IZoneOutboxErrors as ZoneOutboxError, LastBatch, PendingWithdrawal, StaticCallNotAllowed, +}; crate::sol! { - #[derive(Debug)] + /// Legacy seven-argument withdrawal interface. The current overload adds `revealTo`. + #[derive(Debug)] + interface ILegacyZoneOutbox { + function requestWithdrawal( + address token, + address to, + uint128 amount, + bytes32 memo, + uint64 gasLimit, + address fallbackRecipient, + bytes calldata data + ) external; + } +} + +impl From for IZoneOutbox::requestWithdrawalCall { + fn from(call: ILegacyZoneOutbox::requestWithdrawalCall) -> Self { + Self { + token: call.token, + to: call.to, + amount: call.amount, + memo: call.memo, + gasLimit: call.gasLimit, + fallbackRecipient: call.fallbackRecipient, + data: call.data, + revealTo: Default::default(), + } + } +} + +crate::sol! { + #[derive(Debug, PartialEq, Eq)] contract IZoneOutbox { struct LastBatch { @@ -121,19 +154,4 @@ crate::sol! { ) external; function finalizeWithdrawalBatch(uint256 count, uint64 blockNumber, bytes[] calldata encryptedSenders) external returns (bytes32 withdrawalQueueHash); } - - /// Legacy seven-argument withdrawal interface. The current overload adds `revealTo`. - #[derive(Debug)] - interface ILegacyZoneOutbox { - function requestWithdrawal( - address token, - address to, - uint128 amount, - bytes32 memo, - uint64 gasLimit, - address fallbackRecipient, - bytes calldata data - ) external; - } - } diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 8e849855f..56b201926 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -3,10 +3,10 @@ use alloy_sol_types::{SolError, SolInterface}; use revm::precompile::{PrecompileOutput, PrecompileResult}; use tempo_precompiles::IntoPrecompileResult; +use tempo_zone_contracts::ZoneOutboxError; use crate::{tip20_factory::ZoneTokenFactoryError, tip403_proxy::ReadOnlyRegistry}; -// Required by the `#[contract]` proc macro expansion. pub use tempo_precompiles::error::{Result, TempoPrecompileError}; /// Result type for zone-native precompile operations. @@ -23,19 +23,22 @@ pub enum ZonePrecompileError { /// An error originating in the upstream Tempo precompiles crate. #[error(transparent)] Tempo(TempoPrecompileError), + /// Error from the ZoneOutbox. + #[error("ZoneOutbox error: {0:?}")] + Outbox(ZoneOutboxError), /// Error from the zone TIP-20 factory. #[error("Zone TIP-20 factory error: {0:?}")] ZoneTokenFactory(ZoneTokenFactoryError), - /// Error from the zone TIP-403 registry. + /// Error from the read-only zone TIP-403 registry. #[error("Zone TIP-403 registry error: {0:?}")] Zone403Registry(ReadOnlyRegistry), } impl IntoPrecompileResult for ZonePrecompileError { - #[inline] fn into_precompile_result(self, gas: u64, reservoir: u64) -> PrecompileResult { let data = match self { Self::Tempo(error) => return error.into_precompile_result(gas, reservoir), + Self::Outbox(error) => error.abi_encode(), Self::ZoneTokenFactory(error) => error.abi_encode(), Self::Zone403Registry(error) => error.abi_encode(), }; @@ -46,23 +49,33 @@ impl IntoPrecompileResult for ZonePrecompileError { #[cfg(test)] mod tests { use super::*; - use revm::precompile::{PrecompileHalt, PrecompileStatus}; + use alloy_primitives::U256; + use alloy_sol_types::SolError; + use revm::precompile::PrecompileHalt; + use tempo_zone_contracts::IZoneOutbox; #[test] - fn zone_errors_revert_with_abi_encoded_data() { - let factory_error = ZoneTokenFactoryError::only_zone_inbox(); + fn outbox_errors_revert_with_exact_abi_data() { for (error, expected) in [ ( - ZonePrecompileError::from(factory_error.clone()), - factory_error.abi_encode(), + ZoneOutboxError::GasLimitTooHigh(IZoneOutbox::GasLimitTooHigh {}), + IZoneOutbox::GasLimitTooHigh {}.abi_encode(), ), ( - ZonePrecompileError::from(ReadOnlyRegistry {}), - ReadOnlyRegistry {}.abi_encode(), + ZoneOutboxError::InvalidWithdrawalCount(IZoneOutbox::InvalidWithdrawalCount { + actual: U256::from(1), + expected: U256::from(2), + }), + IZoneOutbox::InvalidWithdrawalCount { + actual: U256::from(1), + expected: U256::from(2), + } + .abi_encode(), ), ] { - let output = error.into_precompile_result(10, 20).unwrap(); - + let output = ZonePrecompileError::from(error) + .into_precompile_result(10, 20) + .unwrap(); assert!(output.is_revert()); assert_eq!(output.gas_used, 10); assert_eq!(output.reservoir, 20); @@ -71,15 +84,18 @@ mod tests { } #[test] - fn tempo_error_preserves_upstream_behavior() { - let output = ZonePrecompileError::from(TempoPrecompileError::OutOfGas) + fn other_zone_and_tempo_errors_preserve_conversion_behavior() { + let factory_error = ZoneTokenFactoryError::only_zone_inbox(); + let output = ZonePrecompileError::from(factory_error.clone()) .into_precompile_result(10, 20) .unwrap(); + assert!(output.is_revert()); + assert_eq!(output.bytes, factory_error.abi_encode()); - assert!(matches!( - output.status, - PrecompileStatus::Halt(PrecompileHalt::OutOfGas) - )); + let output = ZonePrecompileError::from(TempoPrecompileError::OutOfGas) + .into_precompile_result(10, 20) + .unwrap(); + assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); assert_eq!(output.reservoir, 20); } } diff --git a/crates/precompiles/src/outbox/dispatch.rs b/crates/precompiles/src/outbox/dispatch.rs index bc98ed03a..2765900a5 100644 --- a/crates/precompiles/src/outbox/dispatch.rs +++ b/crates/precompiles/src/outbox/dispatch.rs @@ -1,16 +1,16 @@ -//! ABI dispatch and centralized call rules for the [`ZoneOutbox`] precompile. +//! ABI dispatch for the [`ZoneOutbox`] precompile. -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, U256}; use revm::precompile::PrecompileResult; -use tempo_precompiles::{ - Precompile, charge_input_cost, dispatch, - storage::Handler, - view, -}; +use tempo_precompiles::{Precompile, charge_input_cost, dispatch, storage::Handler}; use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox}; use zone_primitives::constants::{MAX_WITHDRAWAL_GAS_LIMIT, ZONE_CONFIG_ADDRESS}; -use crate::{ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, tx_context}; +use crate::{ + dispatch::{metadata, mutate, mutate_void, view}, + ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, + tx_context, +}; use super::{ MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, REVEAL_TO_KEY_LENGTH, WITHDRAWAL_BASE_GAS, ZoneOutbox, @@ -24,47 +24,40 @@ impl Precompile for ZoneOutbox { dispatch!(calldata, |call| match call { IZoneOutbox::IZoneOutboxCalls { - config(call) => view(call, |_| Ok(ZONE_CONFIG_ADDRESS)), - tempoGasRate(call) => view(call, |_| self.tempo_gas_rate.read()), - maxWithdrawalsPerBlock(call) => view(call, |_| self.max_withdrawals_per_block.read()), - lastBatch(call) => view(call, |_| self.last_batch()), - withdrawalBatchIndex(call) => view(call, |_| self.withdrawal_batch_index.read()), - lastFinalizedTimestamp(call) => view(call, |_| self.last_finalized_timestamp.read()), - nextWithdrawalIndex(call) => view(call, |_| self.next_withdrawal_index.read()), - pendingWithdrawalsCount(call) => view(call, |_| self.pending_withdrawals_count()), - getPendingWithdrawals(call) => view(call, |_| self.get_pending_withdrawals()), - calculateWithdrawalFee(call) => self.calculate_withdrawal_fee(call.gasLimit), - MAX_CALLBACK_DATA_SIZE(call) => view(call, |_| Ok(U256::from(MAX_CALLBACK_DATA_SIZE))), - MAX_WITHDRAWAL_GAS_LIMIT(call) => view(call, |_| Ok(MAX_WITHDRAWAL_GAS_LIMIT)), - MAX_GAS_FEE_RATE(call) => view(call, |_| Ok(MAX_GAS_FEE_RATE)), - WITHDRAWAL_BASE_GAS(call) => view(call, |_| Ok(WITHDRAWAL_BASE_GAS)), - REVEAL_TO_KEY_LENGTH(call) => view(call, |_| Ok(U256::from(REVEAL_TO_KEY_LENGTH))), - AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(call) => view(call, |_| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))), - setTempoGasRate(call) => self.set_tempo_gas_rate(call), - setMaxWithdrawalsPerBlock(call) => self.set_max_withdrawals_per_block(call), - requestWithdrawal(call) => self.request_withdrawal( - msg_sender, - tx_context::current_tx_hash().unwrap_or_default(), - call, - ), - enqueueDepositBounceBack(call) => self.enqueue_deposit_bounce_back(msg_sender, call), - finalizeWithdrawalBatch(call) => self.finalize_withdrawal_batch(call), + config(_) => metadata::(|| Ok(ZONE_CONFIG_ADDRESS)), + tempoGasRate(_) => metadata::(|| self.tempo_gas_rate.read()), + maxWithdrawalsPerBlock(_) => metadata::(|| self.max_withdrawals_per_block.read()), + lastBatch(_) => metadata::(|| self.last_batch()), + withdrawalBatchIndex(_) => metadata::(|| self.withdrawal_batch_index.read()), + lastFinalizedTimestamp(_) => metadata::(|| self.last_finalized_timestamp.read()), + nextWithdrawalIndex(_) => metadata::(|| self.next_withdrawal_index.read()), + pendingWithdrawalsCount(_) => metadata::(|| self.pending_withdrawals_count()), + getPendingWithdrawals(_) => metadata::(|| self.get_pending_withdrawals()), + calculateWithdrawalFee(call) => view(call, |call| self.calculate_withdrawal_fee(call.gasLimit)), + MAX_CALLBACK_DATA_SIZE(_) => metadata::(|| Ok(U256::from(MAX_CALLBACK_DATA_SIZE))), + MAX_WITHDRAWAL_GAS_LIMIT(_) => metadata::(|| Ok(MAX_WITHDRAWAL_GAS_LIMIT)), + MAX_GAS_FEE_RATE(_) => metadata::(|| Ok(MAX_GAS_FEE_RATE)), + WITHDRAWAL_BASE_GAS(_) => metadata::(|| Ok(WITHDRAWAL_BASE_GAS)), + REVEAL_TO_KEY_LENGTH(_) => metadata::(|| Ok(U256::from(REVEAL_TO_KEY_LENGTH))), + AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(_) => metadata::(|| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))), + setTempoGasRate(call) => mutate_void(call, msg_sender, |_, call| self.set_tempo_gas_rate(call)), + setMaxWithdrawalsPerBlock(call) => mutate_void(call, msg_sender, |_, call| self.set_max_withdrawals_per_block(call)), + requestWithdrawal(call) => mutate_void(call, msg_sender, |sender, call| { + self.request_withdrawal( + sender, + tx_context::current_tx_hash().unwrap_or_default(), + call, + ) + }), + enqueueDepositBounceBack(call) => mutate_void(call, msg_sender, |sender, call| self.enqueue_deposit_bounce_back(sender, call)), + finalizeWithdrawalBatch(call) => mutate(call, msg_sender, |_, call| self.finalize_withdrawal_batch(call)), } ILegacyZoneOutbox::ILegacyZoneOutboxCalls { - requestWithdrawal(call) => self.request_withdrawal( - msg_sender, + requestWithdrawal(call) => mutate_void(call, msg_sender, |sender, call| self.request_withdrawal( + sender, tx_context::current_tx_hash().unwrap_or_default(), - IZoneOutbox::requestWithdrawalCall { - token: call.token, - to: call.to, - amount: call.amount, - memo: call.memo, - gasLimit: call.gasLimit, - fallbackRecipient: call.fallbackRecipient, - data: call.data, - revealTo: Bytes::new(), - }, - ), + call.into(), + )), } }) } diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index 018078e75..cf8b0d51f 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -5,16 +5,15 @@ mod dispatch; use alloc::vec::Vec; use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; -use alloy_sol_types::{SolCall, SolError, SolValue}; -use revm::{interpreter::instructions::utility::IntoAddress, precompile::PrecompileResult}; +use alloy_sol_types::{SolCall, SolValue}; +use revm::interpreter::instructions::utility::IntoAddress; use tempo_precompiles::{ - Result as TempoResult, error::TempoPrecompileError, storage::{Handler, StorageCtx}, tip20::{ITIP20, TIP20Token}, }; use tempo_precompiles_macros::{Storable, contract}; -use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi}; +use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi, ZoneOutboxError}; use zone_primitives::constants::{ EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, @@ -53,10 +52,18 @@ impl ZoneOutboxRules { Self { portal } } - fn revert(error: impl SolError) -> CallCheck { - CallCheck::Return(Ok( - StorageCtx::default().revert_output(error.abi_encode().into()) - )) + fn decode_withdrawal(call: ZoneCall<'_>) -> Option { + match call.selector()? { + ZoneOutboxAbi::requestWithdrawalCall::SELECTOR => { + ZoneOutboxAbi::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]).ok() + } + ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { + ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) + .ok() + .map(Into::into) + } + _ => None, + } } } @@ -74,7 +81,18 @@ impl CallRules for ZoneOutboxRules { || selector == ZoneOutboxAbi::enqueueDepositBounceBackCall::SELECTOR }) { - Self::revert(ZoneOutboxAbi::StaticCallNotAllowed {}) + return CallCheck::from_error(ZoneOutboxError::static_call_not_allowed()); + } + + let Some(withdrawal) = Self::decode_withdrawal(call) else { + return CallCheck::Continue; + }; + if withdrawal.fallbackRecipient.is_zero() { + CallCheck::from_error(ZoneOutboxError::invalid_fallback_recipient()) + } else if withdrawal.gasLimit > MAX_WITHDRAWAL_GAS_LIMIT { + CallCheck::from_error(ZoneOutboxError::gas_limit_too_high()) + } else if withdrawal.data.len() > MAX_CALLBACK_DATA_SIZE { + CallCheck::from_error(ZoneOutboxError::callback_data_too_large()) } else { CallCheck::Continue } @@ -89,32 +107,19 @@ impl CallRules for ZoneOutboxRules { .sload(self.portal, U256::from_be_bytes(PORTAL_SEQUENCER_SLOT.0)) { Ok(value) => value.into_address(), - Err(err) => return CallCheck::Return(StorageCtx::default().error_result(err)), + Err(err) => return CallCheck::from_error(err), }; if sequencer != call.caller { - return Self::revert(ZoneOutboxAbi::OnlySequencer {}); + return CallCheck::from_error(ZoneOutboxError::only_sequencer()); } } - let token = match selector { - ZoneOutboxAbi::requestWithdrawalCall::SELECTOR => { - ZoneOutboxAbi::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) - .ok() - .map(|call| call.token) - } - ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { - ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) - .ok() - .map(|call| call.token) - } - _ => None, - }; - if let Some(token) = token { - let slot = keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()).into(); + if let Some(withdrawal) = Self::decode_withdrawal(call) { + let slot = keccak256((withdrawal.token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()).into(); match StorageCtx::default().sload(self.portal, slot) { Ok(value) if value.byte(0) != 0 => {} - Ok(_) => return Self::revert(ZoneOutboxAbi::TokenNotEnabled {}), - Err(err) => return CallCheck::Return(StorageCtx::default().error_result(err)), + Ok(_) => return CallCheck::from_error(ZoneOutboxError::token_not_enabled()), + Err(err) => return CallCheck::from_error(err), } } CallCheck::Continue @@ -137,31 +142,18 @@ pub struct ZoneOutbox { impl ZoneOutbox { /// Initializes the precompile account code. - pub fn initialize(&mut self) -> TempoResult<()> { + pub fn initialize(&mut self) -> tempo_precompiles::Result<()> { self.__initialize() } - fn static_revert(&self) -> Option { - if self.storage.is_static() { - Some(Ok(self.storage.revert_output( - ZoneOutboxAbi::StaticCallNotAllowed {}.abi_encode().into(), - ))) - } else { - None - } - } - - fn validate_gas_limit(&self, gas_limit: u64) -> Option { + fn validate_gas_limit(&self, gas_limit: u64) -> crate::ZoneResult<()> { if gas_limit > MAX_WITHDRAWAL_GAS_LIMIT { - Some(Ok(self.storage.revert_output( - ZoneOutboxAbi::GasLimitTooHigh {}.abi_encode().into(), - ))) - } else { - None + return Err(ZoneOutboxError::gas_limit_too_high().into()); } + Ok(()) } - fn calculate_fee_unchecked(&self, gas_limit: u64) -> TempoResult { + fn calculate_fee_unchecked(&self, gas_limit: u64) -> tempo_precompiles::Result { let gas = u128::from(WITHDRAWAL_BASE_GAS) .checked_add(u128::from(gas_limit)) .ok_or_else(TempoPrecompileError::under_overflow)?; @@ -169,112 +161,68 @@ impl ZoneOutbox { .ok_or_else(TempoPrecompileError::under_overflow) } - fn calculate_withdrawal_fee(&self, gas_limit: u64) -> PrecompileResult { - if let Some(revert) = self.validate_gas_limit(gas_limit) { - return revert; - } - let fee = match self.calculate_fee_unchecked(gas_limit) { - Ok(fee) => fee, - Err(err) => return self.storage.error_result(err), - }; - Ok(self.storage.success_output( - ZoneOutboxAbi::calculateWithdrawalFeeCall::abi_encode_returns(&fee).into(), - )) + fn calculate_withdrawal_fee(&self, gas_limit: u64) -> crate::ZoneResult { + self.validate_gas_limit(gas_limit)?; + Ok(self.calculate_fee_unchecked(gas_limit)?) } - fn validate_reveal_to(&self, reveal_to: &[u8]) -> Option { + fn validate_reveal_to(&self, reveal_to: &[u8]) -> crate::ZoneResult<()> { if reveal_to.is_empty() { - return None; - } - if reveal_to.len() != REVEAL_TO_KEY_LENGTH { - return Some(Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidRevealTo {}.abi_encode().into(), - ))); + return Ok(()); } - let y_parity = reveal_to[0]; - if !matches!(y_parity, 0x02 | 0x03) { - return Some(Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidRevealTo {}.abi_encode().into(), - ))); + if reveal_to.len() != REVEAL_TO_KEY_LENGTH || !matches!(reveal_to[0], 0x02 | 0x03) { + return Err(ZoneOutboxError::invalid_reveal_to().into()); } let mut x = [0u8; 32]; x.copy_from_slice(&reveal_to[1..]); - if recover_point(&x, y_parity).is_none() { - return Some(Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidRevealTo {}.abi_encode().into(), - ))); + if recover_point(&x, reveal_to[0]).is_none() { + return Err(ZoneOutboxError::invalid_reveal_to().into()); } - None + Ok(()) } fn validate_encrypted_sender( &self, reveal_to: &[u8], encrypted_sender: &[u8], - ) -> Option { + ) -> crate::ZoneResult<()> { let expected = if reveal_to.is_empty() { 0 } else { AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE }; if encrypted_sender.len() != expected { - return Some(Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidEncryptedSenderLength { - actual: U256::from(encrypted_sender.len()), - expected: U256::from(expected), - } - .abi_encode() - .into(), - ))); + return Err(ZoneOutboxError::invalid_encrypted_sender_length( + U256::from(encrypted_sender.len()), + U256::from(expected), + ) + .into()); } - None + Ok(()) } - fn enforce_withdrawal_block_cap(&mut self) -> PrecompileResult { - let max = match self.max_withdrawals_per_block.read() { - Ok(max) => max, - Err(err) => return self.storage.error_result(err), - }; + fn enforce_withdrawal_block_cap(&mut self) -> crate::ZoneResult<()> { + let max = self.max_withdrawals_per_block.read()?; if max.is_zero() { - return Ok(self.storage.success_output(Bytes::new())); + return Ok(()); } let block_number = U256::from(self.storage.block_number()); - let current_block = match self.current_block_number.read() { - Ok(current_block) => current_block, - Err(err) => return self.storage.error_result(err), - }; - if block_number != current_block { - if let Err(err) = self.current_block_number.write(block_number) { - return self.storage.error_result(err); - } - if let Err(err) = self.withdrawals_this_block.write(U256::ZERO) { - return self.storage.error_result(err); - } + if block_number != self.current_block_number.read()? { + self.current_block_number.write(block_number)?; + self.withdrawals_this_block.write(U256::ZERO)?; } - let withdrawals = match self.withdrawals_this_block.read() { - Ok(withdrawals) => withdrawals, - Err(err) => return self.storage.error_result(err), - }; + let withdrawals = self.withdrawals_this_block.read()?; if withdrawals >= max { - return Ok(self.storage.revert_output( - ZoneOutboxAbi::TooManyWithdrawalsThisBlock {} - .abi_encode() - .into(), - )); - } - let next = match withdrawals - .checked_add(U256::ONE) - .ok_or_else(TempoPrecompileError::under_overflow) - { - Ok(next) => next, - Err(err) => return self.storage.error_result(err), - }; - if let Err(err) = self.withdrawals_this_block.write(next) { - return self.storage.error_result(err); - } - Ok(self.storage.success_output(Bytes::new())) + return Err(ZoneOutboxError::too_many_withdrawals_this_block().into()); + } + self.withdrawals_this_block.write( + withdrawals + .checked_add(U256::ONE) + .ok_or_else(TempoPrecompileError::under_overflow)?, + )?; + Ok(()) } fn request_withdrawal( @@ -282,77 +230,41 @@ impl ZoneOutbox { caller: Address, current_tx_hash: B256, call: ZoneOutboxAbi::requestWithdrawalCall, - ) -> PrecompileResult { - if let Some(revert) = self.static_revert() { - return revert; - } + ) -> crate::ZoneResult<()> { if call.fallbackRecipient == Address::ZERO { - return Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidFallbackRecipient {} - .abi_encode() - .into(), - )); - } - if let Some(revert) = self.validate_gas_limit(call.gasLimit) { - return revert; + return Err(ZoneOutboxError::invalid_fallback_recipient().into()); } + self.validate_gas_limit(call.gasLimit)?; if call.data.len() > MAX_CALLBACK_DATA_SIZE { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::CallbackDataTooLarge {}.abi_encode().into())); - } - if let Some(revert) = self.validate_reveal_to(&call.revealTo) { - return revert; - } - let cap_check = self.enforce_withdrawal_block_cap()?; - if cap_check.is_revert() { - return Ok(cap_check); + return Err(ZoneOutboxError::callback_data_too_large().into()); } + self.validate_reveal_to(&call.revealTo)?; + self.enforce_withdrawal_block_cap()?; - let fee = match self.calculate_fee_unchecked(call.gasLimit) { - Ok(fee) => fee, - Err(err) => return self.storage.error_result(err), - }; - let total_burn = match call + let fee = self.calculate_fee_unchecked(call.gasLimit)?; + let total_burn = call .amount .checked_add(fee) - .ok_or_else(TempoPrecompileError::under_overflow) - { - Ok(total_burn) => total_burn, - Err(err) => return self.storage.error_result(err), - }; + .ok_or_else(TempoPrecompileError::under_overflow)?; if current_tx_hash.is_zero() { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::InvalidCurrentTxHash {}.abi_encode().into())); + return Err(ZoneOutboxError::invalid_current_tx_hash().into()); } - let mut zone_token = match TIP20Token::from_address(call.token) { - Ok(zone_token) => zone_token, - Err(err) => return self.storage.error_result(err), - }; + let mut zone_token = TIP20Token::from_address(call.token)?; let amount = U256::from(total_burn); - let transferred = match zone_token.transfer_from( + if !zone_token.transfer_from( ZONE_OUTBOX_ADDRESS, ITIP20::transferFromCall { from: caller, to: ZONE_OUTBOX_ADDRESS, amount, }, - ) { - Ok(transferred) => transferred, - Err(err) => return self.storage.error_result(err), - }; - if !transferred { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::TransferFailed {}.abi_encode().into())); - } - if let Err(err) = zone_token.burn(ZONE_OUTBOX_ADDRESS, ITIP20::burnCall { amount }) { - return self.storage.error_result(err); + )? { + return Err(ZoneOutboxError::transfer_failed().into()); } + zone_token.burn(ZONE_OUTBOX_ADDRESS, ITIP20::burnCall { amount })?; - if let Err(err) = self.pending_withdrawals.push(PendingWithdrawalStorage { + self.pending_withdrawals.push(PendingWithdrawalStorage { token: call.token, sender: caller, tx_hash: current_tx_hash, @@ -364,26 +276,15 @@ impl ZoneOutbox { fallback_recipient: call.fallbackRecipient, callback_data: call.data.clone(), reveal_to: call.revealTo.clone(), - }) { - return self.storage.error_result(err); - } - - let index = match self.next_withdrawal_index.read() { - Ok(index) => index, - Err(err) => return self.storage.error_result(err), - }; - let next_index = match index - .checked_add(1) - .ok_or_else(TempoPrecompileError::under_overflow) - { - Ok(next_index) => next_index, - Err(err) => return self.storage.error_result(err), - }; - if let Err(err) = self.next_withdrawal_index.write(next_index) { - return self.storage.error_result(err); - } - - if let Err(err) = self.emit_event(ZoneOutboxAbi::WithdrawalRequested { + })?; + + let index = self.next_withdrawal_index.read()?; + self.next_withdrawal_index.write( + index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?, + )?; + self.emit_event(ZoneOutboxAbi::WithdrawalRequested { withdrawalIndex: index, sender: caller, token: call.token, @@ -395,28 +296,20 @@ impl ZoneOutbox { fallbackRecipient: call.fallbackRecipient, data: call.data, revealTo: call.revealTo, - }) { - return self.storage.error_result(err); - } - - Ok(self.storage.success_output(Bytes::new())) + })?; + Ok(()) } fn enqueue_deposit_bounce_back( &mut self, caller: Address, call: ZoneOutboxAbi::enqueueDepositBounceBackCall, - ) -> PrecompileResult { - if let Some(revert) = self.static_revert() { - return revert; - } + ) -> crate::ZoneResult<()> { if caller != ZONE_INBOX_ADDRESS { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::OnlyZoneInbox {}.abi_encode().into())); + return Err(ZoneOutboxError::only_zone_inbox().into()); } - if let Err(err) = self.pending_withdrawals.push(PendingWithdrawalStorage { + self.pending_withdrawals.push(PendingWithdrawalStorage { token: call.token, sender: Address::ZERO, tx_hash: B256::ZERO, @@ -428,26 +321,15 @@ impl ZoneOutbox { fallback_recipient: Address::ZERO, callback_data: Bytes::new(), reveal_to: Bytes::new(), - }) { - return self.storage.error_result(err); - } - - let index = match self.next_withdrawal_index.read() { - Ok(index) => index, - Err(err) => return self.storage.error_result(err), - }; - let next_index = match index - .checked_add(1) - .ok_or_else(TempoPrecompileError::under_overflow) - { - Ok(next_index) => next_index, - Err(err) => return self.storage.error_result(err), - }; - if let Err(err) = self.next_withdrawal_index.write(next_index) { - return self.storage.error_result(err); - } - - if let Err(err) = self.emit_event(ZoneOutboxAbi::WithdrawalRequested { + })?; + + let index = self.next_withdrawal_index.read()?; + self.next_withdrawal_index.write( + index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?, + )?; + self.emit_event(ZoneOutboxAbi::WithdrawalRequested { withdrawalIndex: index, sender: Address::ZERO, token: call.token, @@ -459,88 +341,49 @@ impl ZoneOutbox { fallbackRecipient: Address::ZERO, data: Bytes::new(), revealTo: Bytes::new(), - }) { - return self.storage.error_result(err); - } - - Ok(self.storage.success_output(Bytes::new())) + })?; + Ok(()) } fn finalize_withdrawal_batch( &mut self, call: ZoneOutboxAbi::finalizeWithdrawalBatchCall, - ) -> PrecompileResult { - if let Some(revert) = self.static_revert() { - return revert; - } + ) -> crate::ZoneResult { if call.blockNumber != self.storage.block_number() { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::InvalidBlockNumber {}.abi_encode().into())); + return Err(ZoneOutboxError::invalid_block_number().into()); } - let len = match self.pending_withdrawals.len() { - Ok(len) => len, - Err(err) => return self.storage.error_result(err), - }; - let head_u256 = match self.pending_withdrawals_head.read() { - Ok(head_u256) => head_u256, - Err(err) => return self.storage.error_result(err), - }; - let head = match checked_usize(head_u256) { - Ok(head) => head, - Err(err) => return self.storage.error_result(err), - }; + let len = self.pending_withdrawals.len()?; + let head = checked_usize(self.pending_withdrawals_head.read()?)?; let pending = len.saturating_sub(head); - let count = match checked_usize(call.count) { - Ok(count) => count, - Err(err) => return self.storage.error_result(err), - }; - + let count = checked_usize(call.count)?; if count != pending { - return Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidWithdrawalCount { - actual: call.count, - expected: U256::from(pending), - } - .abi_encode() - .into(), - )); + return Err( + ZoneOutboxError::invalid_withdrawal_count(call.count, U256::from(pending)).into(), + ); } if call.encryptedSenders.len() != count { - return Ok(self.storage.revert_output( - ZoneOutboxAbi::InvalidEncryptedSenderCount { - actual: U256::from(call.encryptedSenders.len()), - expected: U256::from(count), - } - .abi_encode() - .into(), - )); + return Err(ZoneOutboxError::invalid_encrypted_sender_count( + U256::from(call.encryptedSenders.len()), + U256::from(count), + ) + .into()); } let mut withdrawal_queue_hash = B256::ZERO; if count > 0 { withdrawal_queue_hash = EMPTY_SENTINEL; - let start = head; - let end = start + count; - - for i in (start..end).rev() { - let pending_withdrawal = match self.pending_withdrawals[i].read() { - Ok(pending_withdrawal) => pending_withdrawal, - Err(err) => return self.storage.error_result(err), - }; - let encrypted_sender = call.encryptedSenders[i - start].clone(); - if let Some(revert) = self.validate_encrypted_sender( + let end = head + count; + for i in (head..end).rev() { + let pending_withdrawal = self.pending_withdrawals[i].read()?; + let encrypted_sender = call.encryptedSenders[i - head].clone(); + self.validate_encrypted_sender( &pending_withdrawal.reveal_to, encrypted_sender.as_ref(), - ) { - return revert; - } - - let sender_tag = sender_tag(pending_withdrawal.sender, pending_withdrawal.tx_hash); + )?; let withdrawal = ZoneOutboxAbi::Withdrawal { token: pending_withdrawal.token, - senderTag: sender_tag, + senderTag: sender_tag(pending_withdrawal.sender, pending_withdrawal.tx_hash), to: pending_withdrawal.to, amount: pending_withdrawal.amount, fee: pending_withdrawal.fee, @@ -551,105 +394,61 @@ impl ZoneOutbox { encryptedSender: encrypted_sender, }; withdrawal_queue_hash = keccak256((withdrawal, withdrawal_queue_hash).abi_encode()); - if let Err(err) = self.pending_withdrawals[i].delete() { - return self.storage.error_result(err); - } - } - - if let Err(err) = self.pending_withdrawals_head.write(U256::from(end)) { - return self.storage.error_result(err); + self.pending_withdrawals[i].delete()?; } + self.pending_withdrawals_head.write(U256::from(end))?; if end == len { - if let Err(err) = self.pending_withdrawals.delete() { - return self.storage.error_result(err); - } - if let Err(err) = self.pending_withdrawals_head.write(U256::ZERO) { - return self.storage.error_result(err); - } + self.pending_withdrawals.delete()?; + self.pending_withdrawals_head.write(U256::ZERO)?; } } - let current_batch_index = match self.withdrawal_batch_index.read() { - Ok(current_batch_index) => current_batch_index, - Err(err) => return self.storage.error_result(err), - }; - let next_batch_index = match current_batch_index + let next_batch_index = self + .withdrawal_batch_index + .read()? .checked_add(1) - .ok_or_else(TempoPrecompileError::under_overflow) - { - Ok(next_batch_index) => next_batch_index, - Err(err) => return self.storage.error_result(err), - }; - if let Err(err) = self.withdrawal_batch_index.write(next_batch_index) { - return self.storage.error_result(err); - } - if let Err(err) = self.last_batch.write(LastBatchStorage { + .ok_or_else(TempoPrecompileError::under_overflow)?; + self.withdrawal_batch_index.write(next_batch_index)?; + self.last_batch.write(LastBatchStorage { withdrawal_queue_hash, withdrawal_batch_index: next_batch_index, - }) { - return self.storage.error_result(err); - } - if let Err(err) = self - .last_finalized_timestamp - .write(self.storage.timestamp().to::()) - { - return self.storage.error_result(err); - } - if let Err(err) = self.emit_event(ZoneOutboxAbi::BatchFinalized { + })?; + self.last_finalized_timestamp + .write(self.storage.timestamp().to::())?; + self.emit_event(ZoneOutboxAbi::BatchFinalized { withdrawalQueueHash: withdrawal_queue_hash, withdrawalBatchIndex: next_batch_index, - }) { - return self.storage.error_result(err); - } - - Ok(self.storage.success_output( - ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_encode_returns(&withdrawal_queue_hash) - .into(), - )) + })?; + Ok(withdrawal_queue_hash) } - fn set_tempo_gas_rate(&mut self, call: ZoneOutboxAbi::setTempoGasRateCall) -> PrecompileResult { - if let Some(revert) = self.static_revert() { - return revert; - } + fn set_tempo_gas_rate( + &mut self, + call: ZoneOutboxAbi::setTempoGasRateCall, + ) -> crate::ZoneResult<()> { if call._tempoGasRate > MAX_GAS_FEE_RATE { - return Ok(self - .storage - .revert_output(ZoneOutboxAbi::GasFeeRateTooHigh {}.abi_encode().into())); - } - if let Err(err) = self.tempo_gas_rate.write(call._tempoGasRate) { - return self.storage.error_result(err); + return Err(ZoneOutboxError::gas_fee_rate_too_high().into()); } - if let Err(err) = self.emit_event(ZoneOutboxAbi::TempoGasRateUpdated { + self.tempo_gas_rate.write(call._tempoGasRate)?; + self.emit_event(ZoneOutboxAbi::TempoGasRateUpdated { tempoGasRate: call._tempoGasRate, - }) { - return self.storage.error_result(err); - } - Ok(self.storage.success_output(Bytes::new())) + })?; + Ok(()) } fn set_max_withdrawals_per_block( &mut self, call: ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall, - ) -> PrecompileResult { - if let Some(revert) = self.static_revert() { - return revert; - } - if let Err(err) = self - .max_withdrawals_per_block - .write(call._maxWithdrawalsPerBlock) - { - return self.storage.error_result(err); - } - if let Err(err) = self.emit_event(ZoneOutboxAbi::MaxWithdrawalsPerBlockUpdated { + ) -> tempo_precompiles::Result<()> { + self.max_withdrawals_per_block + .write(call._maxWithdrawalsPerBlock)?; + self.emit_event(ZoneOutboxAbi::MaxWithdrawalsPerBlockUpdated { maxWithdrawalsPerBlock: call._maxWithdrawalsPerBlock, - }) { - return self.storage.error_result(err); - } - Ok(self.storage.success_output(Bytes::new())) + })?; + Ok(()) } - fn pending_withdrawals_count(&self) -> TempoResult { + fn pending_withdrawals_count(&self) -> tempo_precompiles::Result { let len = self.pending_withdrawals.len()?; let head = checked_usize(self.pending_withdrawals_head.read()?)?; if head >= len { @@ -659,13 +458,14 @@ impl ZoneOutbox { } } - fn get_pending_withdrawals(&self) -> TempoResult> { + fn get_pending_withdrawals( + &self, + ) -> tempo_precompiles::Result> { let len = self.pending_withdrawals.len()?; let head = checked_usize(self.pending_withdrawals_head.read()?)?; if head >= len { return Ok(Vec::new()); } - let mut pending = Vec::with_capacity(len - head); for index in head..len { pending.push(self.pending_withdrawals[index].read()?.into_abi()); @@ -673,7 +473,7 @@ impl ZoneOutbox { Ok(pending) } - fn last_batch(&self) -> TempoResult { + fn last_batch(&self) -> tempo_precompiles::Result { Ok(self.last_batch.read()?.into_abi()) } } @@ -726,7 +526,7 @@ impl PendingWithdrawalStorage { } } -fn checked_usize(value: U256) -> TempoResult { +fn checked_usize(value: U256) -> tempo_precompiles::Result { if value > U256::from(u32::MAX) { return Err(TempoPrecompileError::under_overflow()); } diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 091ea6e5a..22cc6ff08 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -5,12 +5,10 @@ //! storage. The zone keeps mutating selectors read-only and otherwise follows upstream dispatch, //! gas, delegate-call, and receive-policy behavior. +use crate::execution::{CallCheck, CallRules, ZoneCall}; use alloy_primitives::Address; -use alloy_sol_types::{SolCall, SolError}; +use alloy_sol_types::SolCall; use tempo_contracts::precompiles::{ITIP403Registry, TIP403_REGISTRY_ADDRESS}; -use tempo_precompiles::storage::StorageCtx; - -use crate::execution::{CallCheck, CallRules, ZoneCall}; /// Canonical TIP-403 registry address, shared with Tempo L1. pub const ZONE_TIP403_PROXY_ADDRESS: Address = TIP403_REGISTRY_ADDRESS; @@ -26,7 +24,7 @@ const TIP403_MUTATING_SELECTORS: &[[u8; 4]] = &[ ]; alloy_sol_types::sol! { - /// Returned when a mutating call is attempted on the zone's read-only, L1-backed registry. + /// Returned when a mutating call is attempted on the zone's read-only, L1-backed, registry. #[derive(Debug, PartialEq, Eq)] error ReadOnlyRegistry(); } @@ -40,9 +38,7 @@ impl CallRules for Tip403Rules { .selector() .is_some_and(|selector| TIP403_MUTATING_SELECTORS.contains(&selector)) { - return CallCheck::Return(Ok( - StorageCtx::default().revert_output(ReadOnlyRegistry {}.abi_encode().into()) - )); + return CallCheck::from_error(ReadOnlyRegistry {}); } CallCheck::Continue @@ -55,8 +51,8 @@ mod tests { use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Bytes, U256, address}; + use alloy_sol_types::SolError; use revm::precompile::{PrecompileError, PrecompileOutput}; - use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{DelegateCallNotAllowed, storage::PrecompileStorageProvider}; use crate::{ @@ -81,7 +77,6 @@ mod tests { impl RegistryHarness { fn new(l1: MockL1Reader) -> Self { let mut ctx = test_context(); - ctx.cfg.spec = TempoHardfork::T8; test_storage_provider(&mut ctx, u64::MAX, false) .sstore( zone_primitives::constants::TEMPO_STATE_ADDRESS, @@ -286,12 +281,13 @@ mod tests { output.bytes, Bytes::from(DelegateCallNotAllowed {}.abi_encode()) ); + assert!(reader.hardfork_requests().is_empty()); assert!(reader.storage_requests().is_empty()); Ok(()) } #[test] - fn registry_reads_every_slot_at_the_exact_tempo_anchor() -> eyre::Result<()> { + fn registry_reads_every_slot_and_hardfork_at_the_exact_tempo_anchor() -> eyre::Result<()> { let reader = seeded_reader(); let mut harness = RegistryHarness::new(reader.clone()); let call = ITIP403Registry::isAuthorizedCall { @@ -302,6 +298,7 @@ mod tests { let output = harness.call(&call, u64::MAX)?; assert!(output.is_success()); + assert_eq!(reader.hardfork_requests(), vec![ANCHOR]); let requests = reader.storage_requests(); assert!(!requests.is_empty()); assert!(requests.iter().all(|(_, _, block)| *block == ANCHOR)); @@ -309,19 +306,29 @@ mod tests { } #[test] - fn anchored_storage_failures_fail_closed() { + fn anchored_hardfork_and_storage_failures_fail_closed() { let call = ITIP403Registry::isAuthorizedCall { policyId: 5, user: ALICE, } .abi_encode(); + let hardfork_reader = MockL1Reader::failing_hardfork(); + let mut harness = RegistryHarness::new(hardfork_reader.clone()); + assert!(matches!( + harness.call(&call, u64::MAX), + Err(PrecompileError::Fatal(message)) if message.contains("hardfork unavailable") + )); + assert_eq!(hardfork_reader.hardfork_requests(), vec![ANCHOR]); + assert!(hardfork_reader.storage_requests().is_empty()); + let storage_reader = MockL1Reader::failing_storage(); let mut harness = RegistryHarness::new(storage_reader.clone()); assert!(matches!( harness.call(&call, u64::MAX), Err(PrecompileError::Fatal(message)) if message.contains("RPC unavailable") )); + assert_eq!(storage_reader.hardfork_requests(), vec![ANCHOR]); assert!( storage_reader .storage_requests() From 088ce1a79396217865aab8102d48907d771311a3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 14:22:51 +0200 Subject: [PATCH 23/30] wip: cleanup impl --- crates/contracts/src/precompiles/mod.rs | 5 +- crates/contracts/src/precompiles/outbox.rs | 80 ++-- crates/precompiles/src/ecies.rs | 334 ++-------------- crates/precompiles/src/outbox/dispatch.rs | 8 +- crates/precompiles/src/outbox/mod.rs | 440 ++++++++++----------- crates/primitives/src/constants.rs | 32 +- specs/ref-impls/src/zone/ZoneOutbox.sol | 48 +-- 7 files changed, 304 insertions(+), 643 deletions(-) diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 732c6851d..e2129466f 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -21,6 +21,7 @@ pub use zone_tx_context::*; // contracts crate, e.g. `tempo_zone_contracts::TEMPO_STATE_ADDRESS`. pub use zone_primitives::constants::{ EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, NO_QUEUE_INDEX, PORTAL_ADMIN_SLOT, - PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT, TEMPO_STATE_ADDRESS, ZONE_CONFIG_ADDRESS, - ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZONE_TX_CONTEXT_ADDRESS, + PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT, PORTAL_TOKEN_CONFIGS_SLOT, + TEMPO_STATE_ADDRESS, ZONE_CONFIG_ADDRESS, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, + ZONE_TOKEN_ADDRESS, ZONE_TX_CONTEXT_ADDRESS, }; diff --git a/crates/contracts/src/precompiles/outbox.rs b/crates/contracts/src/precompiles/outbox.rs index fca6344f8..46fd59d7d 100644 --- a/crates/contracts/src/precompiles/outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -1,40 +1,10 @@ //! `ZoneOutbox` — deployed on the Zone L2. pub use IZoneOutbox::{ - IZoneOutboxErrors as ZoneOutboxError, LastBatch, PendingWithdrawal, StaticCallNotAllowed, + IZoneOutboxErrors as ZoneOutboxError, IZoneOutboxEvents as ZoneOutboxEvent, LastBatch, + PendingWithdrawal, StaticCallNotAllowed, }; -crate::sol! { - /// Legacy seven-argument withdrawal interface. The current overload adds `revealTo`. - #[derive(Debug)] - interface ILegacyZoneOutbox { - function requestWithdrawal( - address token, - address to, - uint128 amount, - bytes32 memo, - uint64 gasLimit, - address fallbackRecipient, - bytes calldata data - ) external; - } -} - -impl From for IZoneOutbox::requestWithdrawalCall { - fn from(call: ILegacyZoneOutbox::requestWithdrawalCall) -> Self { - Self { - token: call.token, - to: call.to, - amount: call.amount, - memo: call.memo, - gasLimit: call.gasLimit, - fallbackRecipient: call.fallbackRecipient, - data: call.data, - revealTo: Default::default(), - } - } -} - crate::sol! { #[derive(Debug, PartialEq, Eq)] contract IZoneOutbox { @@ -58,19 +28,6 @@ crate::sol! { bytes revealTo; } - struct Withdrawal { - address token; - bytes32 senderTag; - address to; - uint128 amount; - uint128 fee; - bytes32 memo; - uint64 gasLimit; - address fallbackRecipient; - bytes callbackData; - bytes encryptedSender; - } - // -- Events -- event WithdrawalRequested( @@ -88,9 +45,7 @@ crate::sol! { ); event BatchFinalized(bytes32 indexed withdrawalQueueHash, uint64 withdrawalBatchIndex); - event TempoGasRateUpdated(uint128 tempoGasRate); - event MaxWithdrawalsPerBlockUpdated(uint256 maxWithdrawalsPerBlock); // -- Errors -- @@ -155,3 +110,34 @@ crate::sol! { function finalizeWithdrawalBatch(uint256 count, uint64 blockNumber, bytes[] calldata encryptedSenders) external returns (bytes32 withdrawalQueueHash); } } + +crate::sol! { + /// Legacy seven-argument withdrawal interface. The current overload adds `revealTo`. + #[derive(Debug)] + interface ILegacyZoneOutbox { + function requestWithdrawal( + address token, + address to, + uint128 amount, + bytes32 memo, + uint64 gasLimit, + address fallbackRecipient, + bytes calldata data + ) external; + } +} + +impl From for IZoneOutbox::requestWithdrawalCall { + fn from(call: ILegacyZoneOutbox::requestWithdrawalCall) -> Self { + Self { + token: call.token, + to: call.to, + amount: call.amount, + memo: call.memo, + gasLimit: call.gasLimit, + fallbackRecipient: call.fallbackRecipient, + data: call.data, + revealTo: Default::default(), + } + } +} diff --git a/crates/precompiles/src/ecies.rs b/crates/precompiles/src/ecies.rs index d1f6e6770..e2874e017 100644 --- a/crates/precompiles/src/ecies.rs +++ b/crates/precompiles/src/ecies.rs @@ -12,6 +12,7 @@ use k256::{ AffinePoint, ProjectivePoint, Scalar, elliptic_curve::{PrimeField, sec1::ToEncodedPoint}, }; +use tempo_zone_contracts::Withdrawal; use crate::{ aes_gcm::decrypt_aes_gcm, @@ -24,14 +25,23 @@ pub const ENCRYPTED_PAYLOAD_PLAINTEXT_SIZE: usize = 64; /// Plaintext size for authenticated-withdrawal sender reveals: 20 bytes (sender) + 32 bytes (tx hash). pub const AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE: usize = 52; -/// Total encoded size of `encryptedSender`. -pub const AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE: usize = 33 + 12 + 52 + 16; +/// Encoded size of a compressed secp256k1 public key. +pub const COMPRESSED_PUBLIC_KEY_SIZE: usize = 33; -const AUTH_WITHDRAWAL_EPHEMERAL_DOMAIN: &[u8] = b"tempo-zone-authenticated-withdrawal-ephemeral-v1"; -const AUTH_WITHDRAWAL_NONCE_DOMAIN: &[u8] = b"tempo-zone-authenticated-withdrawal-nonce-v1"; -const AUTH_WITHDRAWAL_DERIVATION_KEY_DOMAIN: &[u8] = - b"tempo-zone-authenticated-withdrawal-derivation-key-v1"; -const CP_NONCE_DOMAIN: &[u8] = b"tempo-zone-chaum-pedersen-nonce-v1"; +/// Total encoded size of `encryptedSender`. +pub const AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE: usize = + COMPRESSED_PUBLIC_KEY_SIZE + 12 + AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE + 16; + +/// Decode a SEC1-compressed secp256k1 public key. +pub(crate) fn decode_compressed_public_key(encoded: &[u8]) -> Option { + let encoded: &[u8; COMPRESSED_PUBLIC_KEY_SIZE] = encoded.try_into().ok()?; + let parity = encoded[0]; + if !matches!(parity, 0x02 | 0x03) { + return None; + } + let x: &[u8; 32] = encoded[1..].try_into().ok()?; + recover_point(x, parity) +} /// Result of sequencer-side ECDH + Chaum-Pedersen proof derivation. /// @@ -69,7 +79,7 @@ pub struct DecryptedDeposit { /// on-chain contract with a valid proof — enabling the refund path for deposits /// with garbage ciphertext instead of reverting. /// -/// Returns `None` if the ephemeral public key cannot be recovered or proof generation fails. +/// Returns `None` only if the ephemeral public key cannot be recovered (invalid point). pub fn compute_ecdh_proof( sequencer_privkey: &k256::SecretKey, ephemeral_pub_x: &B256, @@ -94,7 +104,7 @@ pub fn compute_ecdh_proof( &ephemeral_pub, &shared_secret_affine, &sequencer_pub, - )?; + ); Some(EcdhProofResult { shared_secret: B256::from(shared_secret_x), @@ -168,83 +178,15 @@ pub fn encrypt_authenticated_withdrawal( sender: Address, tx_hash: B256, ) -> Option> { + let reveal_pub = decode_compressed_public_key(reveal_to)?; + let eph_key = k256::SecretKey::random(&mut rand::thread_rng()); let eph_scalar: Scalar = *eph_key.to_nonzero_scalar(); - let nonce_bytes: [u8; 12] = rand::random(); - - encrypt_authenticated_withdrawal_with_material( - reveal_to, - sender, - tx_hash, - &eph_scalar, - nonce_bytes, - ) -} - -/// Deterministically encrypt `(sender, tx_hash)` for authenticated withdrawals. -/// -/// This is the consensus-safe variant used by zone payload construction. It -/// derives both the ECIES ephemeral scalar and AES-GCM nonce from the sequencer -/// encryption key, zone id, reveal key, sender, and withdrawal transaction hash. -pub fn encrypt_authenticated_withdrawal_deterministic( - encryption_privkey: &k256::SecretKey, - zone_id: u32, - reveal_to: &[u8], - sender: Address, - tx_hash: B256, -) -> Option> { - let derivation_key = authenticated_withdrawal_derivation_key(encryption_privkey); - let eph_scalar = derive_authenticated_withdrawal_ephemeral_scalar( - &derivation_key, - zone_id, - reveal_to, - sender, - tx_hash, - )?; let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * eph_scalar); let eph_encoded = eph_pub.to_encoded_point(true); let eph_pubkey: [u8; 33] = eph_encoded.as_bytes().try_into().ok()?; - let nonce_bytes = derive_authenticated_withdrawal_nonce( - &derivation_key, - zone_id, - reveal_to, - sender, - tx_hash, - &eph_pubkey, - ); - encrypt_authenticated_withdrawal_with_material( - reveal_to, - sender, - tx_hash, - &eph_scalar, - nonce_bytes, - ) -} - -fn encrypt_authenticated_withdrawal_with_material( - reveal_to: &[u8], - sender: Address, - tx_hash: B256, - eph_scalar: &Scalar, - nonce_bytes: [u8; 12], -) -> Option> { - if reveal_to.len() != 33 { - return None; - } - let parity = reveal_to[0]; - if parity != 0x02 && parity != 0x03 { - return None; - } - - let reveal_to_x = B256::from_slice(&reveal_to[1..]); - let reveal_pub = recover_point(&reveal_to_x.0, parity)?; - - let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * *eph_scalar); - let eph_encoded = eph_pub.to_encoded_point(true); - let eph_pubkey: [u8; 33] = eph_encoded.as_bytes().try_into().ok()?; - - let shared_proj = ProjectivePoint::from(reveal_pub) * *eph_scalar; + let shared_proj = ProjectivePoint::from(reveal_pub) * eph_scalar; let shared_affine = AffinePoint::from(shared_proj); let ss_encoded = shared_affine.to_encoded_point(true); let shared_secret_x: [u8; 32] = ss_encoded.x()?.as_slice().try_into().ok()?; @@ -254,6 +196,7 @@ fn encrypt_authenticated_withdrawal_with_material( let plaintext = build_authenticated_withdrawal_plaintext(&sender, &tx_hash); let cipher = Aes256Gcm::new((&aes_key).into()); + let nonce_bytes: [u8; 12] = rand::random(); let nonce = Nonce::from_slice(&nonce_bytes); let encrypted = cipher.encrypt(nonce, plaintext.as_ref()).ok()?; let ciphertext = &encrypted[..encrypted.len() - 16]; @@ -267,86 +210,6 @@ fn encrypt_authenticated_withdrawal_with_material( Some(out) } -fn derive_authenticated_withdrawal_ephemeral_scalar( - derivation_key: &[u8; 32], - zone_id: u32, - reveal_to: &[u8], - sender: Address, - tx_hash: B256, -) -> Option { - for counter in 0u32.. { - let mut msg = authenticated_withdrawal_context( - AUTH_WITHDRAWAL_EPHEMERAL_DOMAIN, - zone_id, - reveal_to, - sender, - tx_hash, - ); - msg.extend_from_slice(&counter.to_be_bytes()); - - let candidate = hmac_sha256(derivation_key, &msg); - if let Ok(key) = k256::SecretKey::from_slice(&candidate) { - return Some(*key.to_nonzero_scalar()); - } - } - - None -} - -fn derive_authenticated_withdrawal_nonce( - derivation_key: &[u8; 32], - zone_id: u32, - reveal_to: &[u8], - sender: Address, - tx_hash: B256, - eph_pubkey: &[u8; 33], -) -> [u8; 12] { - let mut msg = authenticated_withdrawal_context( - AUTH_WITHDRAWAL_NONCE_DOMAIN, - zone_id, - reveal_to, - sender, - tx_hash, - ); - msg.extend_from_slice(eph_pubkey); - - let digest = hmac_sha256(derivation_key, &msg); - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&digest[..12]); - nonce -} - -fn authenticated_withdrawal_derivation_key(encryption_privkey: &k256::SecretKey) -> [u8; 32] { - let secret = secret_scalar_bytes(encryption_privkey); - // Derive a purpose-specific HMAC key first, so the raw ECIES private scalar - // is not reused directly across the ephemeral-scalar and nonce derivations. - hmac_sha256(&secret, AUTH_WITHDRAWAL_DERIVATION_KEY_DOMAIN) -} - -fn authenticated_withdrawal_context( - domain: &[u8], - zone_id: u32, - reveal_to: &[u8], - sender: Address, - tx_hash: B256, -) -> Vec { - let mut msg = Vec::with_capacity(domain.len() + 4 + 4 + reveal_to.len() + 20 + 32); - msg.extend_from_slice(domain); - msg.extend_from_slice(&zone_id.to_be_bytes()); - msg.extend_from_slice(&(reveal_to.len() as u32).to_be_bytes()); - msg.extend_from_slice(reveal_to); - msg.extend_from_slice(sender.as_slice()); - msg.extend_from_slice(tx_hash.as_slice()); - msg -} - -fn secret_scalar_bytes(secret_key: &k256::SecretKey) -> [u8; 32] { - let repr = secret_key.to_nonzero_scalar().to_repr(); - let mut out = [0u8; 32]; - out.copy_from_slice(repr.as_ref()); - out -} - /// Decrypt an authenticated-withdrawal `encryptedSender` payload. pub fn decrypt_authenticated_withdrawal( reveal_privkey: &k256::SecretKey, @@ -451,11 +314,13 @@ fn generate_chaum_pedersen_proof( ephemeral_pub: &AffinePoint, shared_secret: &AffinePoint, sequencer_pub: &AffinePoint, -) -> Option<(Scalar, Scalar)> { - // The proof is included in advanceTempo calldata, so runtime randomness here - // would make otherwise identical zone blocks diverge. Derive the blinding - // scalar from the encryption key and the complete public statement instead. - let k = deterministic_cp_nonce(priv_seq, ephemeral_pub, sequencer_pub, shared_secret)?; +) -> (Scalar, Scalar) { + use k256::elliptic_curve::Field; + + let mut rng = rand::thread_rng(); + + // 1. Prover picks random k + let k = Scalar::random(&mut rng); let r1 = AffinePoint::from(ProjectivePoint::GENERATOR * k); let r2 = AffinePoint::from(ProjectivePoint::from(*ephemeral_pub) * k); @@ -465,45 +330,7 @@ fn generate_chaum_pedersen_proof( // 3. Response: s = k + c * privSeq let s = k + c * priv_seq; - Some((s, c)) -} - -/// Domain-separated deterministic Chaum-Pedersen nonce derivation. -/// -/// Invalid scalar candidates are retried with a counter. -fn deterministic_cp_nonce( - priv_seq: &Scalar, - ephemeral_pub: &AffinePoint, - sequencer_pub: &AffinePoint, - shared_secret: &AffinePoint, -) -> Option { - // Although the latter two points are derived from `priv_seq` and `ephemeral_pub`, - // include the complete public statement so every challenge input also binds the nonce. - let ephemeral_pub = ephemeral_pub.to_encoded_point(true); - let sequencer_pub = sequencer_pub.to_encoded_point(true); - let shared_secret = shared_secret.to_encoded_point(true); - let mut input = Vec::with_capacity(CP_NONCE_DOMAIN.len() + 33 * 3 + 4); - input.extend_from_slice(CP_NONCE_DOMAIN); - input.extend_from_slice(ephemeral_pub.as_bytes()); - input.extend_from_slice(sequencer_pub.as_bytes()); - input.extend_from_slice(shared_secret.as_bytes()); - - let secret = priv_seq.to_bytes(); - for counter in 0..=u32::MAX { - input.extend_from_slice(&counter.to_be_bytes()); - let candidate = hmac_sha256(secret.as_ref(), &input); - - if let Ok(nonce) = k256::SecretKey::from_slice(&candidate) { - return Some(*nonce.to_nonzero_scalar()); - } - - // Reset to try another counter - input.truncate(input.len() - 4); - } - - // Astronomically impossible that we didn't find a valid scalar, so if we're here, its a bug, - // return None - None + (s, c) } /// HMAC-SHA256 implementation matching ZoneInbox._hmacSha256. @@ -575,10 +402,7 @@ pub fn build_authenticated_withdrawal_plaintext( sender: &Address, tx_hash: &B256, ) -> [u8; AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE] { - let mut buf = [0u8; AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE]; - buf[..20].copy_from_slice(sender.as_slice()); - buf[20..].copy_from_slice(tx_hash.as_slice()); - buf + Withdrawal::authenticated_sender_plaintext(*sender, *tx_hash) } /// Build the 84-byte HKDF info parameter: `[portal(20) | key_index(32) | eph_pub_x(32)]`. @@ -617,9 +441,9 @@ pub fn encrypt_plaintext(aes_key: &[u8; 32], plaintext: &[u8]) -> (Vec, [u8; #[cfg(test)] mod tests { use super::{ - AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, compressed_x_and_parity, compute_ecdh_proof, + AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, compressed_x_and_parity, decrypt_authenticated_withdrawal, decrypt_deposit, encrypt_authenticated_withdrawal, - encrypt_authenticated_withdrawal_deterministic, hkdf_sha256, hmac_sha256, + hkdf_sha256, hmac_sha256, }; use crate::test_utils::{EncryptedDepositFixture, assert_cp_proof_valid}; use alloy_primitives::{Address, B256, U256}; @@ -635,17 +459,6 @@ mod tests { assert_cp_proof_valid(&dec, &f.eph_pub, &f.seq_pub); } - #[test] - fn test_cp_proof_is_deterministic() { - let f = EncryptedDepositFixture::new(); - - let proof_a = compute_ecdh_proof(&f.seq_key, &f.eph_pub_x, f.eph_pub_y_parity).unwrap(); - let proof_b = compute_ecdh_proof(&f.seq_key, &f.eph_pub_x, f.eph_pub_y_parity).unwrap(); - - assert_eq!(proof_a.cp_proof_s, proof_b.cp_proof_s); - assert_eq!(proof_a.cp_proof_c, proof_b.cp_proof_c); - } - #[test] fn test_authenticated_withdrawal_roundtrip() { use sha2::{Digest, Sha256}; @@ -667,85 +480,6 @@ mod tests { assert_eq!(decrypted_tx_hash, tx_hash); } - #[test] - fn test_authenticated_withdrawal_deterministic_roundtrip() { - use sha2::{Digest, Sha256}; - - let reveal_key_bytes: [u8; 32] = - Sha256::digest(b"authenticated-withdrawal-reveal-key").into(); - let reveal_key = k256::SecretKey::from_slice(&reveal_key_bytes).unwrap(); - let reveal_pub = reveal_key.public_key(); - let reveal_encoded = reveal_pub.to_encoded_point(true); - - let encryption_key_bytes: [u8; 32] = - Sha256::digest(b"authenticated-withdrawal-encryption-key").into(); - let encryption_key = k256::SecretKey::from_slice(&encryption_key_bytes).unwrap(); - - let zone_id = 17; - let sender = Address::repeat_byte(0x11); - let tx_hash = B256::repeat_byte(0x22); - let encrypted_a = encrypt_authenticated_withdrawal_deterministic( - &encryption_key, - zone_id, - reveal_encoded.as_bytes(), - sender, - tx_hash, - ) - .unwrap(); - let encrypted_b = encrypt_authenticated_withdrawal_deterministic( - &encryption_key, - zone_id, - reveal_encoded.as_bytes(), - sender, - tx_hash, - ) - .unwrap(); - - assert_eq!(encrypted_a, encrypted_b); - assert_eq!(encrypted_a.len(), AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE); - - let (decrypted_sender, decrypted_tx_hash) = - decrypt_authenticated_withdrawal(&reveal_key, &encrypted_a).unwrap(); - assert_eq!(decrypted_sender, sender); - assert_eq!(decrypted_tx_hash, tx_hash); - } - - #[test] - fn test_authenticated_withdrawal_deterministic_changes_by_zone() { - use sha2::{Digest, Sha256}; - - let reveal_key_bytes: [u8; 32] = - Sha256::digest(b"authenticated-withdrawal-zone-reveal-key").into(); - let reveal_key = k256::SecretKey::from_slice(&reveal_key_bytes).unwrap(); - let reveal_pub = reveal_key.public_key(); - let reveal_encoded = reveal_pub.to_encoded_point(true); - - let encryption_key_bytes: [u8; 32] = - Sha256::digest(b"authenticated-withdrawal-zone-encryption-key").into(); - let encryption_key = k256::SecretKey::from_slice(&encryption_key_bytes).unwrap(); - - let sender = Address::repeat_byte(0x11); - let tx_hash = B256::repeat_byte(0x22); - let encrypted_a = encrypt_authenticated_withdrawal_deterministic( - &encryption_key, - 17, - reveal_encoded.as_bytes(), - sender, - tx_hash, - ) - .unwrap(); - let encrypted_b = encrypt_authenticated_withdrawal_deterministic( - &encryption_key, - 18, - reveal_encoded.as_bytes(), - sender, - tx_hash, - ) - .unwrap(); - - assert_ne!(encrypted_a, encrypted_b); - } - #[test] fn test_ecies_decrypt_wrong_key() { let f = EncryptedDepositFixture::new(); diff --git a/crates/precompiles/src/outbox/dispatch.rs b/crates/precompiles/src/outbox/dispatch.rs index 2765900a5..e0df910bb 100644 --- a/crates/precompiles/src/outbox/dispatch.rs +++ b/crates/precompiles/src/outbox/dispatch.rs @@ -8,13 +8,11 @@ use zone_primitives::constants::{MAX_WITHDRAWAL_GAS_LIMIT, ZONE_CONFIG_ADDRESS}; use crate::{ dispatch::{metadata, mutate, mutate_void, view}, - ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, + ecies::{AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, COMPRESSED_PUBLIC_KEY_SIZE}, tx_context, }; -use super::{ - MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, REVEAL_TO_KEY_LENGTH, WITHDRAWAL_BASE_GAS, ZoneOutbox, -}; +use super::{MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, WITHDRAWAL_BASE_GAS, ZoneOutbox}; impl Precompile for ZoneOutbox { fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { @@ -38,7 +36,7 @@ impl Precompile for ZoneOutbox { MAX_WITHDRAWAL_GAS_LIMIT(_) => metadata::(|| Ok(MAX_WITHDRAWAL_GAS_LIMIT)), MAX_GAS_FEE_RATE(_) => metadata::(|| Ok(MAX_GAS_FEE_RATE)), WITHDRAWAL_BASE_GAS(_) => metadata::(|| Ok(WITHDRAWAL_BASE_GAS)), - REVEAL_TO_KEY_LENGTH(_) => metadata::(|| Ok(U256::from(REVEAL_TO_KEY_LENGTH))), + REVEAL_TO_KEY_LENGTH(_) => metadata::(|| Ok(U256::from(COMPRESSED_PUBLIC_KEY_SIZE))), AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(_) => metadata::(|| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))), setTempoGasRate(call) => mutate_void(call, msg_sender, |_, call| self.set_tempo_gas_rate(call)), setMaxWithdrawalsPerBlock(call) => mutate_void(call, msg_sender, |_, call| self.set_max_withdrawals_per_block(call)), diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index cf8b0d51f..caaf1ab2a 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -8,30 +8,30 @@ use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use revm::interpreter::instructions::utility::IntoAddress; use tempo_precompiles::{ + Result as TempoResult, error::TempoPrecompileError, storage::{Handler, StorageCtx}, tip20::{ITIP20, TIP20Token}, }; use tempo_precompiles_macros::{Storable, contract}; -use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi, ZoneOutboxError}; +use tempo_zone_contracts::{ + ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi, Withdrawal, ZoneOutboxError, ZoneOutboxEvent, +}; use zone_primitives::constants::{ - EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, ZONE_INBOX_ADDRESS, - ZONE_OUTBOX_ADDRESS, + EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, PORTAL_TOKEN_CONFIGS_SLOT, + ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, }; use crate::{ - chaum_pedersen::recover_point, - ecies::AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, + ZoneResult, + ecies::{AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, decode_compressed_public_key}, execution::{CallCheck, CallRules, ZoneCall}, }; const MAX_CALLBACK_DATA_SIZE: usize = 1024; const MAX_GAS_FEE_RATE: u128 = 1_000_000_000_000_000_000; const WITHDRAWAL_BASE_GAS: u64 = 50_000; -const REVEAL_TO_KEY_LENGTH: usize = 33; -const PORTAL_TOKEN_CONFIGS_SLOT: B256 = B256::new([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, -]); + const SEQUENCER_SELECTORS: &[[u8; 4]] = &[ ZoneOutboxAbi::setTempoGasRateCall::SELECTOR, ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall::SELECTOR, @@ -51,20 +51,6 @@ impl ZoneOutboxRules { pub(crate) fn new(portal: Address) -> Self { Self { portal } } - - fn decode_withdrawal(call: ZoneCall<'_>) -> Option { - match call.selector()? { - ZoneOutboxAbi::requestWithdrawalCall::SELECTOR => { - ZoneOutboxAbi::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]).ok() - } - ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { - ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) - .ok() - .map(Into::into) - } - _ => None, - } - } } impl CallRules for ZoneOutboxRules { @@ -84,17 +70,12 @@ impl CallRules for ZoneOutboxRules { return CallCheck::from_error(ZoneOutboxError::static_call_not_allowed()); } - let Some(withdrawal) = Self::decode_withdrawal(call) else { + let Some(withdrawal) = decode_withdrawal(call) else { return CallCheck::Continue; }; - if withdrawal.fallbackRecipient.is_zero() { - CallCheck::from_error(ZoneOutboxError::invalid_fallback_recipient()) - } else if withdrawal.gasLimit > MAX_WITHDRAWAL_GAS_LIMIT { - CallCheck::from_error(ZoneOutboxError::gas_limit_too_high()) - } else if withdrawal.data.len() > MAX_CALLBACK_DATA_SIZE { - CallCheck::from_error(ZoneOutboxError::callback_data_too_large()) - } else { - CallCheck::Continue + match validate_withdrawal_request(&withdrawal) { + Ok(()) => CallCheck::Continue, + Err(err) => CallCheck::from_error(err), } } @@ -114,7 +95,7 @@ impl CallRules for ZoneOutboxRules { } } - if let Some(withdrawal) = Self::decode_withdrawal(call) { + if let Some(withdrawal) = decode_withdrawal(call) { let slot = keccak256((withdrawal.token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()).into(); match StorageCtx::default().sload(self.portal, slot) { Ok(value) if value.byte(0) != 0 => {} @@ -131,9 +112,9 @@ pub struct ZoneOutbox { tempo_gas_rate: u128, next_withdrawal_index: u64, withdrawal_batch_index: u64, - last_batch: LastBatchStorage, - pending_withdrawals: Vec, - pending_withdrawals_head: U256, + last_batch: LastBatch, + pending_withdrawals: Vec, + pending_withdrawals_head: u32, max_withdrawals_per_block: U256, withdrawals_this_block: U256, current_block_number: U256, @@ -142,66 +123,30 @@ pub struct ZoneOutbox { impl ZoneOutbox { /// Initializes the precompile account code. - pub fn initialize(&mut self) -> tempo_precompiles::Result<()> { + pub fn initialize(&mut self) -> TempoResult<()> { self.__initialize() } - fn validate_gas_limit(&self, gas_limit: u64) -> crate::ZoneResult<()> { - if gas_limit > MAX_WITHDRAWAL_GAS_LIMIT { - return Err(ZoneOutboxError::gas_limit_too_high().into()); - } - Ok(()) - } - - fn calculate_fee_unchecked(&self, gas_limit: u64) -> tempo_precompiles::Result { - let gas = u128::from(WITHDRAWAL_BASE_GAS) - .checked_add(u128::from(gas_limit)) - .ok_or_else(TempoPrecompileError::under_overflow)?; + fn calculate_fee_unchecked(&self, gas_limit: u64) -> TempoResult { + let gas = u128::from(WITHDRAWAL_BASE_GAS) + u128::from(gas_limit); gas.checked_mul(self.tempo_gas_rate.read()?) .ok_or_else(TempoPrecompileError::under_overflow) } - fn calculate_withdrawal_fee(&self, gas_limit: u64) -> crate::ZoneResult { - self.validate_gas_limit(gas_limit)?; + fn calculate_withdrawal_fee(&self, gas_limit: u64) -> ZoneResult { + validate_gas_limit(gas_limit)?; Ok(self.calculate_fee_unchecked(gas_limit)?) } - fn validate_reveal_to(&self, reveal_to: &[u8]) -> crate::ZoneResult<()> { - if reveal_to.is_empty() { - return Ok(()); - } - if reveal_to.len() != REVEAL_TO_KEY_LENGTH || !matches!(reveal_to[0], 0x02 | 0x03) { - return Err(ZoneOutboxError::invalid_reveal_to().into()); - } - let mut x = [0u8; 32]; - x.copy_from_slice(&reveal_to[1..]); - if recover_point(&x, reveal_to[0]).is_none() { - return Err(ZoneOutboxError::invalid_reveal_to().into()); - } - Ok(()) - } - - fn validate_encrypted_sender( - &self, - reveal_to: &[u8], - encrypted_sender: &[u8], - ) -> crate::ZoneResult<()> { - let expected = if reveal_to.is_empty() { - 0 + fn validate_reveal_to(&self, reveal_to: &[u8]) -> ZoneResult<()> { + if reveal_to.is_empty() || decode_compressed_public_key(reveal_to).is_some() { + Ok(()) } else { - AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE - }; - if encrypted_sender.len() != expected { - return Err(ZoneOutboxError::invalid_encrypted_sender_length( - U256::from(encrypted_sender.len()), - U256::from(expected), - ) - .into()); + Err(ZoneOutboxError::invalid_reveal_to().into()) } - Ok(()) } - fn enforce_withdrawal_block_cap(&mut self) -> crate::ZoneResult<()> { + fn enforce_withdrawal_block_cap(&mut self) -> ZoneResult<()> { let max = self.max_withdrawals_per_block.read()?; if max.is_zero() { return Ok(()); @@ -225,21 +170,28 @@ impl ZoneOutbox { Ok(()) } + fn enqueue(&mut self, pending: PendingWithdrawal) -> ZoneResult<()> { + let index = self.next_withdrawal_index.read()?; + let event = pending.requested_event(index); + self.pending_withdrawals.push(pending)?; + self.next_withdrawal_index.write( + index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?, + )?; + self.emit_event(event)?; + Ok(()) + } + fn request_withdrawal( &mut self, caller: Address, current_tx_hash: B256, call: ZoneOutboxAbi::requestWithdrawalCall, - ) -> crate::ZoneResult<()> { - if call.fallbackRecipient == Address::ZERO { - return Err(ZoneOutboxError::invalid_fallback_recipient().into()); - } - self.validate_gas_limit(call.gasLimit)?; - if call.data.len() > MAX_CALLBACK_DATA_SIZE { - return Err(ZoneOutboxError::callback_data_too_large().into()); - } - self.validate_reveal_to(&call.revealTo)?; + ) -> ZoneResult<()> { + validate_withdrawal_request(&call)?; self.enforce_withdrawal_block_cap()?; + self.validate_reveal_to(&call.revealTo)?; let fee = self.calculate_fee_unchecked(call.gasLimit)?; let total_burn = call @@ -264,102 +216,40 @@ impl ZoneOutbox { } zone_token.burn(ZONE_OUTBOX_ADDRESS, ITIP20::burnCall { amount })?; - self.pending_withdrawals.push(PendingWithdrawalStorage { - token: call.token, - sender: caller, - tx_hash: current_tx_hash, - to: call.to, - amount: call.amount, - fee, - memo: call.memo, - gas_limit: call.gasLimit, - fallback_recipient: call.fallbackRecipient, - callback_data: call.data.clone(), - reveal_to: call.revealTo.clone(), - })?; - - let index = self.next_withdrawal_index.read()?; - self.next_withdrawal_index.write( - index - .checked_add(1) - .ok_or_else(TempoPrecompileError::under_overflow)?, - )?; - self.emit_event(ZoneOutboxAbi::WithdrawalRequested { - withdrawalIndex: index, - sender: caller, - token: call.token, - to: call.to, - amount: call.amount, + self.enqueue(PendingWithdrawal::from_request( + caller, + current_tx_hash, fee, - memo: call.memo, - gasLimit: call.gasLimit, - fallbackRecipient: call.fallbackRecipient, - data: call.data, - revealTo: call.revealTo, - })?; - Ok(()) + call, + )) } fn enqueue_deposit_bounce_back( &mut self, caller: Address, call: ZoneOutboxAbi::enqueueDepositBounceBackCall, - ) -> crate::ZoneResult<()> { + ) -> ZoneResult<()> { if caller != ZONE_INBOX_ADDRESS { return Err(ZoneOutboxError::only_zone_inbox().into()); } - self.pending_withdrawals.push(PendingWithdrawalStorage { - token: call.token, - sender: Address::ZERO, - tx_hash: B256::ZERO, - to: call.bouncebackRecipient, - amount: call.amount, - fee: 0, - memo: B256::ZERO, - gas_limit: 0, - fallback_recipient: Address::ZERO, - callback_data: Bytes::new(), - reveal_to: Bytes::new(), - })?; - - let index = self.next_withdrawal_index.read()?; - self.next_withdrawal_index.write( - index - .checked_add(1) - .ok_or_else(TempoPrecompileError::under_overflow)?, - )?; - self.emit_event(ZoneOutboxAbi::WithdrawalRequested { - withdrawalIndex: index, - sender: Address::ZERO, - token: call.token, - to: call.bouncebackRecipient, - amount: call.amount, - fee: 0, - memo: B256::ZERO, - gasLimit: 0, - fallbackRecipient: Address::ZERO, - data: Bytes::new(), - revealTo: Bytes::new(), - })?; - Ok(()) + self.enqueue(PendingWithdrawal::from_bounce_back(call)) } fn finalize_withdrawal_batch( &mut self, call: ZoneOutboxAbi::finalizeWithdrawalBatchCall, - ) -> crate::ZoneResult { + ) -> ZoneResult { if call.blockNumber != self.storage.block_number() { return Err(ZoneOutboxError::invalid_block_number().into()); } let len = self.pending_withdrawals.len()?; - let head = checked_usize(self.pending_withdrawals_head.read()?)?; - let pending = len.saturating_sub(head); - let count = checked_usize(call.count)?; - if count != pending { + let head = self.pending_withdrawals_head.read()? as usize; + let count = len.saturating_sub(head); + if call.count != U256::from(count) { return Err( - ZoneOutboxError::invalid_withdrawal_count(call.count, U256::from(pending)).into(), + ZoneOutboxError::invalid_withdrawal_count(call.count, U256::from(count)).into(), ); } if call.encryptedSenders.len() != count { @@ -377,29 +267,17 @@ impl ZoneOutbox { for i in (head..end).rev() { let pending_withdrawal = self.pending_withdrawals[i].read()?; let encrypted_sender = call.encryptedSenders[i - head].clone(); - self.validate_encrypted_sender( - &pending_withdrawal.reveal_to, - encrypted_sender.as_ref(), - )?; - let withdrawal = ZoneOutboxAbi::Withdrawal { - token: pending_withdrawal.token, - senderTag: sender_tag(pending_withdrawal.sender, pending_withdrawal.tx_hash), - to: pending_withdrawal.to, - amount: pending_withdrawal.amount, - fee: pending_withdrawal.fee, - memo: pending_withdrawal.memo, - gasLimit: pending_withdrawal.gas_limit, - fallbackRecipient: pending_withdrawal.fallback_recipient, - callbackData: pending_withdrawal.callback_data, - encryptedSender: encrypted_sender, - }; + let withdrawal = pending_withdrawal.into_withdrawal(encrypted_sender)?; withdrawal_queue_hash = keccak256((withdrawal, withdrawal_queue_hash).abi_encode()); self.pending_withdrawals[i].delete()?; } - self.pending_withdrawals_head.write(U256::from(end))?; + self.pending_withdrawals_head.write( + end.try_into() + .map_err(|_| TempoPrecompileError::under_overflow())?, + )?; if end == len { self.pending_withdrawals.delete()?; - self.pending_withdrawals_head.write(U256::ZERO)?; + self.pending_withdrawals_head.write(0)?; } } @@ -409,92 +287,81 @@ impl ZoneOutbox { .checked_add(1) .ok_or_else(TempoPrecompileError::under_overflow)?; self.withdrawal_batch_index.write(next_batch_index)?; - self.last_batch.write(LastBatchStorage { + self.last_batch.write(LastBatch { withdrawal_queue_hash, withdrawal_batch_index: next_batch_index, })?; self.last_finalized_timestamp .write(self.storage.timestamp().to::())?; - self.emit_event(ZoneOutboxAbi::BatchFinalized { - withdrawalQueueHash: withdrawal_queue_hash, - withdrawalBatchIndex: next_batch_index, - })?; + self.emit_event(ZoneOutboxEvent::batch_finalized( + withdrawal_queue_hash, + next_batch_index, + ))?; Ok(withdrawal_queue_hash) } - fn set_tempo_gas_rate( - &mut self, - call: ZoneOutboxAbi::setTempoGasRateCall, - ) -> crate::ZoneResult<()> { + fn set_tempo_gas_rate(&mut self, call: ZoneOutboxAbi::setTempoGasRateCall) -> ZoneResult<()> { if call._tempoGasRate > MAX_GAS_FEE_RATE { return Err(ZoneOutboxError::gas_fee_rate_too_high().into()); } self.tempo_gas_rate.write(call._tempoGasRate)?; - self.emit_event(ZoneOutboxAbi::TempoGasRateUpdated { - tempoGasRate: call._tempoGasRate, - })?; + self.emit_event(ZoneOutboxEvent::tempo_gas_rate_updated(call._tempoGasRate))?; Ok(()) } fn set_max_withdrawals_per_block( &mut self, call: ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall, - ) -> tempo_precompiles::Result<()> { + ) -> TempoResult<()> { self.max_withdrawals_per_block .write(call._maxWithdrawalsPerBlock)?; - self.emit_event(ZoneOutboxAbi::MaxWithdrawalsPerBlockUpdated { - maxWithdrawalsPerBlock: call._maxWithdrawalsPerBlock, - })?; + self.emit_event(ZoneOutboxEvent::max_withdrawals_per_block_updated( + call._maxWithdrawalsPerBlock, + ))?; Ok(()) } - fn pending_withdrawals_count(&self) -> tempo_precompiles::Result { + fn pending_withdrawals_count(&self) -> TempoResult { let len = self.pending_withdrawals.len()?; - let head = checked_usize(self.pending_withdrawals_head.read()?)?; - if head >= len { - Ok(U256::ZERO) - } else { - Ok(U256::from(len - head)) - } + let head = self.pending_withdrawals_head.read()? as usize; + Ok(U256::from(len.saturating_sub(head))) } - fn get_pending_withdrawals( - &self, - ) -> tempo_precompiles::Result> { + fn get_pending_withdrawals(&self) -> TempoResult> { let len = self.pending_withdrawals.len()?; - let head = checked_usize(self.pending_withdrawals_head.read()?)?; + let head = self.pending_withdrawals_head.read()? as usize; if head >= len { return Ok(Vec::new()); } let mut pending = Vec::with_capacity(len - head); for index in head..len { - pending.push(self.pending_withdrawals[index].read()?.into_abi()); + pending.push(self.pending_withdrawals[index].read()?.into()); } Ok(pending) } - fn last_batch(&self) -> tempo_precompiles::Result { - Ok(self.last_batch.read()?.into_abi()) - } -} - -impl LastBatchStorage { - fn into_abi(self) -> ZoneOutboxAbi::LastBatch { - ZoneOutboxAbi::LastBatch { - withdrawalQueueHash: self.withdrawal_queue_hash, - withdrawalBatchIndex: self.withdrawal_batch_index, - } + fn last_batch(&self) -> TempoResult { + Ok(self.last_batch.read()?.into()) } } #[derive(Clone, Debug, Default, PartialEq, Eq, Storable)] -struct LastBatchStorage { +struct LastBatch { withdrawal_queue_hash: B256, withdrawal_batch_index: u64, } +impl From for ZoneOutboxAbi::LastBatch { + fn from(batch: LastBatch) -> Self { + Self { + withdrawalQueueHash: batch.withdrawal_queue_hash, + withdrawalBatchIndex: batch.withdrawal_batch_index, + } + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq, Storable)] -struct PendingWithdrawalStorage { +struct PendingWithdrawal { token: Address, sender: Address, tx_hash: B256, @@ -508,12 +375,71 @@ struct PendingWithdrawalStorage { reveal_to: Bytes, } -impl PendingWithdrawalStorage { - fn into_abi(self) -> ZoneOutboxAbi::PendingWithdrawal { - ZoneOutboxAbi::PendingWithdrawal { +impl PendingWithdrawal { + fn from_request( + sender: Address, + tx_hash: B256, + fee: u128, + call: ZoneOutboxAbi::requestWithdrawalCall, + ) -> Self { + Self { + token: call.token, + sender, + tx_hash, + to: call.to, + amount: call.amount, + fee, + memo: call.memo, + gas_limit: call.gasLimit, + fallback_recipient: call.fallbackRecipient, + callback_data: call.data, + reveal_to: call.revealTo, + } + } + + fn from_bounce_back(call: ZoneOutboxAbi::enqueueDepositBounceBackCall) -> Self { + Self { + token: call.token, + to: call.bouncebackRecipient, + amount: call.amount, + ..Default::default() + } + } + + fn requested_event(&self, index: u64) -> ZoneOutboxEvent { + ZoneOutboxEvent::withdrawal_requested( + index, + self.sender, + self.token, + self.to, + self.amount, + self.fee, + self.memo, + self.gas_limit, + self.fallback_recipient, + self.callback_data.clone(), + self.reveal_to.clone(), + ) + } + + fn into_withdrawal(self, encrypted_sender: Bytes) -> ZoneResult { + let expected = if self.reveal_to.is_empty() { + 0 + } else { + AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE + }; + if encrypted_sender.len() != expected { + return Err(ZoneOutboxError::invalid_encrypted_sender_length( + U256::from(encrypted_sender.len()), + U256::from(expected), + ) + .into()); + } + + let sender_tag = Withdrawal::sender_tag(self.sender, self.tx_hash); + Ok(Withdrawal { token: self.token, - sender: self.sender, - txHash: self.tx_hash, + senderTag: sender_tag, to: self.to, amount: self.amount, fee: self.fee, @@ -521,21 +447,57 @@ impl PendingWithdrawalStorage { gasLimit: self.gas_limit, fallbackRecipient: self.fallback_recipient, callbackData: self.callback_data, - revealTo: self.reveal_to, + encryptedSender: encrypted_sender, + }) + } +} + +impl From for ZoneOutboxAbi::PendingWithdrawal { + fn from(pending: PendingWithdrawal) -> Self { + Self { + token: pending.token, + sender: pending.sender, + txHash: pending.tx_hash, + to: pending.to, + amount: pending.amount, + fee: pending.fee, + memo: pending.memo, + gasLimit: pending.gas_limit, + fallbackRecipient: pending.fallback_recipient, + callbackData: pending.callback_data, + revealTo: pending.reveal_to, + } + } +} + +fn decode_withdrawal(call: ZoneCall<'_>) -> Option { + match call.selector()? { + ZoneOutboxAbi::requestWithdrawalCall::SELECTOR => { + ZoneOutboxAbi::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]).ok() + } + ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { + ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) + .ok() + .map(Into::into) } + _ => None, } } -fn checked_usize(value: U256) -> tempo_precompiles::Result { - if value > U256::from(u32::MAX) { - return Err(TempoPrecompileError::under_overflow()); +fn validate_gas_limit(gas_limit: u64) -> ZoneResult<()> { + if gas_limit > MAX_WITHDRAWAL_GAS_LIMIT { + return Err(ZoneOutboxError::gas_limit_too_high().into()); } - Ok(value.to::()) + Ok(()) } -fn sender_tag(sender: Address, tx_hash: B256) -> B256 { - let mut preimage = [0u8; 52]; - preimage[..20].copy_from_slice(sender.as_slice()); - preimage[20..].copy_from_slice(tx_hash.as_slice()); - keccak256(preimage) +fn validate_withdrawal_request(call: &ZoneOutboxAbi::requestWithdrawalCall) -> ZoneResult<()> { + if call.fallbackRecipient.is_zero() { + return Err(ZoneOutboxError::invalid_fallback_recipient().into()); + } + validate_gas_limit(call.gasLimit)?; + if call.data.len() > MAX_CALLBACK_DATA_SIZE { + return Err(ZoneOutboxError::callback_data_too_large().into()); + } + Ok(()) } diff --git a/crates/primitives/src/constants.rs b/crates/primitives/src/constants.rs index 57ef86d5b..378530322 100644 --- a/crates/primitives/src/constants.rs +++ b/crates/primitives/src/constants.rs @@ -53,18 +53,13 @@ pub const ZONE_TOKEN_ADDRESS: Address = address!("0x20C0000000000000000000000000 pub const PORTAL_SEQUENCER_SLOT: B256 = B256::ZERO; /// ZonePortal storage slot 1: `admin` (address). -pub const PORTAL_ADMIN_SLOT: B256 = { - let mut bytes = [0u8; 32]; - bytes[31] = 1; - B256::new(bytes) -}; +pub const PORTAL_ADMIN_SLOT: B256 = { B256::with_last_byte(1) }; /// ZonePortal storage slot 2: `pendingSequencer` (address). -pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { - let mut bytes = [0u8; 32]; - bytes[31] = 2; - B256::new(bytes) -}; +pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { B256::with_last_byte(2) }; + +/// ZonePortal storage slot 8: `tokenConfigs` mapping. +pub const PORTAL_TOKEN_CONFIGS_SLOT: B256 = { B256::with_last_byte(8) }; // --------------------------------------------------------------------------- // Storage slot constants for the proof system @@ -73,23 +68,6 @@ pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { /// ZoneInbox storage slot 0: `processedDepositQueueHash` (bytes32). pub const ZONE_INBOX_PROCESSED_HASH_SLOT: U256 = U256::ZERO; -/// ZoneOutbox storage slot 1: `_lastBatch.withdrawalQueueHash` (bytes32). -/// -/// Slot 0 is packed `(tempoGasRate, nextWithdrawalIndex, withdrawalBatchIndex)`. -/// The `_lastBatch` struct starts at slot 1 with `withdrawalQueueHash` occupying the full slot. -pub const ZONE_OUTBOX_LAST_BATCH_HASH_SLOT: U256 = { - let mut le = [0u8; 32]; - le[0] = 1; - U256::from_le_bytes(le) -}; - -/// ZoneOutbox storage slot 2: `_lastBatch.withdrawalBatchIndex` (uint64, lower 8 bytes). -pub const ZONE_OUTBOX_LAST_BATCH_INDEX_SLOT: U256 = { - let mut le = [0u8; 32]; - le[0] = 2; - U256::from_le_bytes(le) -}; - /// Base offset for deriving **mainnet** zone chain IDs. /// /// Each zone gets a unique EIP-155 chain ID derived from its on-chain zone ID diff --git a/specs/ref-impls/src/zone/ZoneOutbox.sol b/specs/ref-impls/src/zone/ZoneOutbox.sol index f7a4d8a68..34d987af2 100644 --- a/specs/ref-impls/src/zone/ZoneOutbox.sol +++ b/specs/ref-impls/src/zone/ZoneOutbox.sol @@ -93,12 +93,6 @@ contract ZoneOutbox is IZoneOutbox { /// @notice Timestamp of the latest withdrawal batch finalization. uint64 public lastFinalizedTimestamp; - /// @notice Last nonce assigned to a user withdrawal fallback recipient - uint64 public lastFallbackNonce; - - /// @notice Private fallback recipient lookup used when an L1 withdrawal bounces back - mapping(uint64 fallbackNonce => address recipient) internal _fallbackRecipients; - /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -295,8 +289,6 @@ contract ZoneOutbox is IZoneOutbox { zoneToken.burn(totalBurn); // Store withdrawal in pending array - uint64 fallbackNonce = ++lastFallbackNonce; - _fallbackRecipients[fallbackNonce] = fallbackRecipient; _pendingWithdrawals.push( PendingWithdrawal({ token: token, @@ -307,7 +299,7 @@ contract ZoneOutbox is IZoneOutbox { fee: fee, memo: memo, gasLimit: gasLimit, - fallbackNonce: fallbackNonce, + fallbackRecipient: fallbackRecipient, callbackData: data, revealTo: revealTo }) @@ -317,7 +309,17 @@ contract ZoneOutbox is IZoneOutbox { uint64 index = nextWithdrawalIndex++; emit WithdrawalRequested( - index, msg.sender, token, to, amount, fee, memo, gasLimit, fallbackNonce, data, revealTo + index, + msg.sender, + token, + to, + amount, + fee, + memo, + gasLimit, + fallbackRecipient, + data, + revealTo ); } @@ -342,7 +344,7 @@ contract ZoneOutbox is IZoneOutbox { fee: 0, memo: bytes32(0), gasLimit: 0, - fallbackNonce: 0, + fallbackRecipient: address(0), callbackData: "", revealTo: "" }) @@ -350,20 +352,20 @@ contract ZoneOutbox is IZoneOutbox { uint64 index = nextWithdrawalIndex++; emit WithdrawalRequested( - index, address(0), token, bouncebackRecipient, amount, 0, bytes32(0), 0, 0, "", "" + index, + address(0), + token, + bouncebackRecipient, + amount, + 0, + bytes32(0), + 0, + address(0), + "", + "" ); } - /// @notice Resolve and delete the recipient for a failed L1 withdrawal. - /// @dev The nonce is committed to the L1 withdrawal while the recipient remains private - /// in zone state. Only ZoneInbox may consume a mapping entry. - function consumeFallbackRecipient(uint64 fallbackNonce) external returns (address recipient) { - if (msg.sender != ZONE_INBOX) revert OnlyZoneInbox(); - recipient = _fallbackRecipients[fallbackNonce]; - if (recipient == address(0)) revert InvalidFallbackRecipient(); - delete _fallbackRecipients[fallbackNonce]; - } - /*////////////////////////////////////////////////////////////// BATCH OPERATIONS //////////////////////////////////////////////////////////////*/ @@ -429,7 +431,7 @@ contract ZoneOutbox is IZoneOutbox { fee: pendingWithdrawal.fee, memo: pendingWithdrawal.memo, gasLimit: pendingWithdrawal.gasLimit, - fallbackNonce: pendingWithdrawal.fallbackNonce, + fallbackRecipient: pendingWithdrawal.fallbackRecipient, callbackData: pendingWithdrawal.callbackData, encryptedSender: encryptedSender }); From e7c4694fe2ea021d37508c221e524ce03e9a23ab Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 16:03:07 +0200 Subject: [PATCH 24/30] chore: simplify --- crates/contracts/src/precompiles/outbox.rs | 6 +-- crates/precompiles/src/outbox/mod.rs | 50 +++++++--------------- crates/primitives/src/constants.rs | 6 +-- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/crates/contracts/src/precompiles/outbox.rs b/crates/contracts/src/precompiles/outbox.rs index 46fd59d7d..929999836 100644 --- a/crates/contracts/src/precompiles/outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -46,7 +46,7 @@ crate::sol! { event BatchFinalized(bytes32 indexed withdrawalQueueHash, uint64 withdrawalBatchIndex); event TempoGasRateUpdated(uint128 tempoGasRate); - event MaxWithdrawalsPerBlockUpdated(uint256 maxWithdrawalsPerBlock); + event MaxWithdrawalsPerBlockUpdated(uint32 maxWithdrawalsPerBlock); // -- Errors -- @@ -71,7 +71,7 @@ crate::sol! { function config() external view returns (address); function tempoGasRate() external view returns (uint128); - function maxWithdrawalsPerBlock() external view returns (uint256); + function maxWithdrawalsPerBlock() external view returns (uint32); function lastBatch() external view returns (LastBatch memory); function withdrawalBatchIndex() external view returns (uint64); function lastFinalizedTimestamp() external view returns (uint64); @@ -91,7 +91,7 @@ crate::sol! { // -- State-changing functions -- function setTempoGasRate(uint128 _tempoGasRate) external; - function setMaxWithdrawalsPerBlock(uint256 _maxWithdrawalsPerBlock) external; + function setMaxWithdrawalsPerBlock(uint32 _maxWithdrawalsPerBlock) external; function requestWithdrawal( address token, address to, diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index caaf1ab2a..d70a15dc5 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -114,10 +114,9 @@ pub struct ZoneOutbox { withdrawal_batch_index: u64, last_batch: LastBatch, pending_withdrawals: Vec, - pending_withdrawals_head: u32, - max_withdrawals_per_block: U256, - withdrawals_this_block: U256, - current_block_number: U256, + max_withdrawals_per_block: u32, + withdrawals_this_block: u32, + current_block_number: u64, last_finalized_timestamp: u64, } @@ -148,14 +147,14 @@ impl ZoneOutbox { fn enforce_withdrawal_block_cap(&mut self) -> ZoneResult<()> { let max = self.max_withdrawals_per_block.read()?; - if max.is_zero() { + if max == 0 { return Ok(()); } - let block_number = U256::from(self.storage.block_number()); + let block_number = self.storage.block_number(); if block_number != self.current_block_number.read()? { self.current_block_number.write(block_number)?; - self.withdrawals_this_block.write(U256::ZERO)?; + self.withdrawals_this_block.write(0)?; } let withdrawals = self.withdrawals_this_block.read()?; @@ -164,7 +163,7 @@ impl ZoneOutbox { } self.withdrawals_this_block.write( withdrawals - .checked_add(U256::ONE) + .checked_add(1) .ok_or_else(TempoPrecompileError::under_overflow)?, )?; Ok(()) @@ -244,9 +243,7 @@ impl ZoneOutbox { return Err(ZoneOutboxError::invalid_block_number().into()); } - let len = self.pending_withdrawals.len()?; - let head = self.pending_withdrawals_head.read()? as usize; - let count = len.saturating_sub(head); + let count = self.pending_withdrawals.len()?; if call.count != U256::from(count) { return Err( ZoneOutboxError::invalid_withdrawal_count(call.count, U256::from(count)).into(), @@ -263,22 +260,13 @@ impl ZoneOutbox { let mut withdrawal_queue_hash = B256::ZERO; if count > 0 { withdrawal_queue_hash = EMPTY_SENTINEL; - let end = head + count; - for i in (head..end).rev() { + for i in (0..count).rev() { let pending_withdrawal = self.pending_withdrawals[i].read()?; - let encrypted_sender = call.encryptedSenders[i - head].clone(); + let encrypted_sender = &call.encryptedSenders[i]; let withdrawal = pending_withdrawal.into_withdrawal(encrypted_sender)?; withdrawal_queue_hash = keccak256((withdrawal, withdrawal_queue_hash).abi_encode()); - self.pending_withdrawals[i].delete()?; - } - self.pending_withdrawals_head.write( - end.try_into() - .map_err(|_| TempoPrecompileError::under_overflow())?, - )?; - if end == len { - self.pending_withdrawals.delete()?; - self.pending_withdrawals_head.write(0)?; } + self.pending_withdrawals.delete()?; } let next_batch_index = self @@ -322,19 +310,13 @@ impl ZoneOutbox { } fn pending_withdrawals_count(&self) -> TempoResult { - let len = self.pending_withdrawals.len()?; - let head = self.pending_withdrawals_head.read()? as usize; - Ok(U256::from(len.saturating_sub(head))) + Ok(U256::from(self.pending_withdrawals.len()?)) } fn get_pending_withdrawals(&self) -> TempoResult> { let len = self.pending_withdrawals.len()?; - let head = self.pending_withdrawals_head.read()? as usize; - if head >= len { - return Ok(Vec::new()); - } - let mut pending = Vec::with_capacity(len - head); - for index in head..len { + let mut pending = Vec::with_capacity(len); + for index in 0..len { pending.push(self.pending_withdrawals[index].read()?.into()); } Ok(pending) @@ -422,7 +404,7 @@ impl PendingWithdrawal { ) } - fn into_withdrawal(self, encrypted_sender: Bytes) -> ZoneResult { + fn into_withdrawal(self, encrypted_sender: &Bytes) -> ZoneResult { let expected = if self.reveal_to.is_empty() { 0 } else { @@ -447,7 +429,7 @@ impl PendingWithdrawal { gasLimit: self.gas_limit, fallbackRecipient: self.fallback_recipient, callbackData: self.callback_data, - encryptedSender: encrypted_sender, + encryptedSender: encrypted_sender.to_owned(), }) } } diff --git a/crates/primitives/src/constants.rs b/crates/primitives/src/constants.rs index 378530322..46ef6205b 100644 --- a/crates/primitives/src/constants.rs +++ b/crates/primitives/src/constants.rs @@ -53,13 +53,13 @@ pub const ZONE_TOKEN_ADDRESS: Address = address!("0x20C0000000000000000000000000 pub const PORTAL_SEQUENCER_SLOT: B256 = B256::ZERO; /// ZonePortal storage slot 1: `admin` (address). -pub const PORTAL_ADMIN_SLOT: B256 = { B256::with_last_byte(1) }; +pub const PORTAL_ADMIN_SLOT: B256 = B256::with_last_byte(1); /// ZonePortal storage slot 2: `pendingSequencer` (address). -pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { B256::with_last_byte(2) }; +pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = B256::with_last_byte(2); /// ZonePortal storage slot 8: `tokenConfigs` mapping. -pub const PORTAL_TOKEN_CONFIGS_SLOT: B256 = { B256::with_last_byte(8) }; +pub const PORTAL_TOKEN_CONFIGS_SLOT: B256 = B256::with_last_byte(8); // --------------------------------------------------------------------------- // Storage slot constants for the proof system From 4db53231a07104b9aa535bf12537aef3d098b82b Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 17:39:02 +0200 Subject: [PATCH 25/30] wip: more cleanup --- .../contracts/src/precompiles/zone_portal.rs | 16 +- crates/precompiles/src/outbox/mod.rs | 68 ++-- crates/precompiles/src/outbox/tests.rs | 320 ++++++++++++++++++ 3 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 crates/precompiles/src/outbox/tests.rs diff --git a/crates/contracts/src/precompiles/zone_portal.rs b/crates/contracts/src/precompiles/zone_portal.rs index b70693c4d..2486e7c52 100644 --- a/crates/contracts/src/precompiles/zone_portal.rs +++ b/crates/contracts/src/precompiles/zone_portal.rs @@ -7,7 +7,7 @@ pub use ZonePortal::{ use crate::IZoneOutbox; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_sol_types::SolValue; -use zone_primitives::constants::EMPTY_SENTINEL; +use zone_primitives::constants::{EMPTY_SENTINEL, PORTAL_TOKEN_CONFIGS_SLOT}; crate::sol! { #[derive(Debug)] @@ -388,6 +388,11 @@ impl Withdrawal { } } + /// Hash this withdrawal as one link in a withdrawal queue. + pub fn hash_with_tail(&self, tail: B256) -> B256 { + keccak256((self.clone(), tail).abi_encode_params()) + } + /// Compute the withdrawal queue hash for a slice of withdrawals. /// /// The hash chain has the oldest withdrawal at the outermost layer for efficient FIFO removal: @@ -404,9 +409,14 @@ impl Withdrawal { } let mut hash = EMPTY_SENTINEL; - for w in withdrawals.iter().rev() { - hash = keccak256((w.clone(), hash).abi_encode_params()); + for withdrawal in withdrawals.iter().rev() { + hash = withdrawal.hash_with_tail(hash); } hash } } + +/// Return the storage slot for `token` in the portal token-config mapping. +pub fn portal_token_config_slot(token: Address) -> B256 { + keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()) +} diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index d70a15dc5..2d22bdf63 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -4,8 +4,8 @@ mod dispatch; use alloc::vec::Vec; -use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; -use alloy_sol_types::{SolCall, SolValue}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_sol_types::SolCall; use revm::interpreter::instructions::utility::IntoAddress; use tempo_precompiles::{ Result as TempoResult, @@ -16,10 +16,10 @@ use tempo_precompiles::{ use tempo_precompiles_macros::{Storable, contract}; use tempo_zone_contracts::{ ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi, Withdrawal, ZoneOutboxError, ZoneOutboxEvent, + portal_token_config_slot, }; use zone_primitives::constants::{ - EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, PORTAL_TOKEN_CONFIGS_SLOT, - ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, + MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, }; use crate::{ @@ -73,7 +73,7 @@ impl CallRules for ZoneOutboxRules { let Some(withdrawal) = decode_withdrawal(call) else { return CallCheck::Continue; }; - match validate_withdrawal_request(&withdrawal) { + match check_withdrawal_request(&withdrawal) { Ok(()) => CallCheck::Continue, Err(err) => CallCheck::from_error(err), } @@ -96,7 +96,7 @@ impl CallRules for ZoneOutboxRules { } if let Some(withdrawal) = decode_withdrawal(call) { - let slot = keccak256((withdrawal.token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()).into(); + let slot = portal_token_config_slot(withdrawal.token).into(); match StorageCtx::default().sload(self.portal, slot) { Ok(value) if value.byte(0) != 0 => {} Ok(_) => return CallCheck::from_error(ZoneOutboxError::token_not_enabled()), @@ -134,15 +134,7 @@ impl ZoneOutbox { fn calculate_withdrawal_fee(&self, gas_limit: u64) -> ZoneResult { validate_gas_limit(gas_limit)?; - Ok(self.calculate_fee_unchecked(gas_limit)?) - } - - fn validate_reveal_to(&self, reveal_to: &[u8]) -> ZoneResult<()> { - if reveal_to.is_empty() || decode_compressed_public_key(reveal_to).is_some() { - Ok(()) - } else { - Err(ZoneOutboxError::invalid_reveal_to().into()) - } + self.calculate_fee_unchecked(gas_limit).map_err(Into::into) } fn enforce_withdrawal_block_cap(&mut self) -> ZoneResult<()> { @@ -171,14 +163,14 @@ impl ZoneOutbox { fn enqueue(&mut self, pending: PendingWithdrawal) -> ZoneResult<()> { let index = self.next_withdrawal_index.read()?; - let event = pending.requested_event(index); + self.emit_event(pending.requested_event(index))?; + self.pending_withdrawals.push(pending)?; self.next_withdrawal_index.write( index .checked_add(1) .ok_or_else(TempoPrecompileError::under_overflow)?, )?; - self.emit_event(event)?; Ok(()) } @@ -188,32 +180,35 @@ impl ZoneOutbox { current_tx_hash: B256, call: ZoneOutboxAbi::requestWithdrawalCall, ) -> ZoneResult<()> { - validate_withdrawal_request(&call)?; + if current_tx_hash.is_zero() { + return Err(ZoneOutboxError::invalid_current_tx_hash().into()); + } + check_withdrawal_request(&call)?; self.enforce_withdrawal_block_cap()?; - self.validate_reveal_to(&call.revealTo)?; + + // If necessary, validate reveal + if !call.revealTo.is_empty() && decode_compressed_public_key(&call.revealTo).is_none() { + return Err(ZoneOutboxError::invalid_reveal_to().into()); + } let fee = self.calculate_fee_unchecked(call.gasLimit)?; let total_burn = call .amount .checked_add(fee) .ok_or_else(TempoPrecompileError::under_overflow)?; - if current_tx_hash.is_zero() { - return Err(ZoneOutboxError::invalid_current_tx_hash().into()); - } - let mut zone_token = TIP20Token::from_address(call.token)?; let amount = U256::from(total_burn); if !zone_token.transfer_from( - ZONE_OUTBOX_ADDRESS, + self.address, ITIP20::transferFromCall { from: caller, - to: ZONE_OUTBOX_ADDRESS, + to: self.address, amount, }, )? { return Err(ZoneOutboxError::transfer_failed().into()); } - zone_token.burn(ZONE_OUTBOX_ADDRESS, ITIP20::burnCall { amount })?; + zone_token.burn(self.address, ITIP20::burnCall { amount })?; self.enqueue(PendingWithdrawal::from_request( caller, @@ -259,12 +254,11 @@ impl ZoneOutbox { let mut withdrawal_queue_hash = B256::ZERO; if count > 0 { - withdrawal_queue_hash = EMPTY_SENTINEL; - for i in (0..count).rev() { - let pending_withdrawal = self.pending_withdrawals[i].read()?; - let encrypted_sender = &call.encryptedSenders[i]; - let withdrawal = pending_withdrawal.into_withdrawal(encrypted_sender)?; - withdrawal_queue_hash = keccak256((withdrawal, withdrawal_queue_hash).abi_encode()); + withdrawal_queue_hash = zone_primitives::constants::EMPTY_SENTINEL; + for (index, encrypted_sender) in call.encryptedSenders.into_iter().enumerate().rev() { + let pending = self.pending_withdrawals[index].read()?; + let withdrawal = pending.into_withdrawal(encrypted_sender)?; + withdrawal_queue_hash = withdrawal.hash_with_tail(withdrawal_queue_hash); } self.pending_withdrawals.delete()?; } @@ -310,7 +304,7 @@ impl ZoneOutbox { } fn pending_withdrawals_count(&self) -> TempoResult { - Ok(U256::from(self.pending_withdrawals.len()?)) + self.pending_withdrawals.len().map(|val| U256::from(val)) } fn get_pending_withdrawals(&self) -> TempoResult> { @@ -323,7 +317,7 @@ impl ZoneOutbox { } fn last_batch(&self) -> TempoResult { - Ok(self.last_batch.read()?.into()) + self.last_batch.read().map(Into::into) } } @@ -404,7 +398,7 @@ impl PendingWithdrawal { ) } - fn into_withdrawal(self, encrypted_sender: &Bytes) -> ZoneResult { + fn into_withdrawal(self, encrypted_sender: Bytes) -> ZoneResult { let expected = if self.reveal_to.is_empty() { 0 } else { @@ -429,7 +423,7 @@ impl PendingWithdrawal { gasLimit: self.gas_limit, fallbackRecipient: self.fallback_recipient, callbackData: self.callback_data, - encryptedSender: encrypted_sender.to_owned(), + encryptedSender: encrypted_sender, }) } } @@ -473,7 +467,7 @@ fn validate_gas_limit(gas_limit: u64) -> ZoneResult<()> { Ok(()) } -fn validate_withdrawal_request(call: &ZoneOutboxAbi::requestWithdrawalCall) -> ZoneResult<()> { +fn check_withdrawal_request(call: &ZoneOutboxAbi::requestWithdrawalCall) -> ZoneResult<()> { if call.fallbackRecipient.is_zero() { return Err(ZoneOutboxError::invalid_fallback_recipient().into()); } diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs new file mode 100644 index 000000000..77ff2cb73 --- /dev/null +++ b/crates/precompiles/src/outbox/tests.rs @@ -0,0 +1,320 @@ +use super::*; + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::{Bytes, address}; +use alloy_sol_types::{SolCall, SolInterface}; +use revm::precompile::PrecompileResult; +use tempo_precompiles::{Precompile as _, tip20::ISSUER_ROLE}; +use tempo_zone_contracts::portal_token_config_slot; + +use crate::{ + L1StorageReader, execution, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, + test_storage_provider, + }, + tx_context, +}; + +const ANCHOR: u64 = 7; +const GAS: u64 = 10_000_000; +const TX_HASH: B256 = B256::repeat_byte(0x42); +const ALICE: Address = address!("0x00000000000000000000000000000000000000a1"); +const BOB: Address = address!("0x00000000000000000000000000000000000000b2"); +const SEQUENCER: Address = address!("0x00000000000000000000000000000000000000c3"); + +struct Harness { + ctx: TestContext, + l1: MockL1Reader, + precompile: DynPrecompile, + token: Address, +} + +impl Harness { + fn new() -> eyre::Result { + let mut ctx = test_context(); + let l1 = MockL1Reader::allow_all(); + let portal = l1.portal_address(); + let token = tempo_precompiles::PATH_USD_ADDRESS; + + l1.set_u256( + portal, + U256::from_be_bytes(PORTAL_SEQUENCER_SLOT.0), + ANCHOR, + U256::from_be_slice(SEQUENCER.as_slice()), + ); + l1.set_u256( + portal, + U256::from_be_bytes(portal_token_config_slot(token).0), + ANCHOR, + U256::ONE, + ); + l1.seed_transfer_policy_id(token, ANCHOR); + + { + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + StorageCtx::default().sstore( + zone_primitives::constants::TEMPO_STATE_ADDRESS, + crate::tempo_state::slots::TEMPO_BLOCK_NUMBER, + U256::from(ANCHOR), + )?; + + ZoneOutbox::new().initialize()?; + let mut token_contract = + TIP20Token::from_address(token).expect("PATH_USD is a valid TIP20 address"); + token_contract.initialize( + ALICE, + "Zone USD", + "zUSD", + "USD", + Address::ZERO, + ALICE, + )?; + token_contract.grant_role_internal(ALICE, *ISSUER_ROLE)?; + token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; + token_contract.mint( + ALICE, + ITIP20::mintCall { + to: ALICE, + amount: U256::from(1_000_000u64), + }, + )?; + token_contract.approve( + ALICE, + ITIP20::approveCall { + spender: ZONE_OUTBOX_ADDRESS, + amount: U256::MAX, + }, + )?; + Ok(()) + })?; + } + + let env = test_l1_env(&ctx, l1.clone()); + let precompile = execution::create_l1_backed_precompile( + "ZoneOutboxTest", + env, + ZoneOutboxRules::new(portal), + |data, caller| ZoneOutbox::new().call(data, caller), + ); + + Ok(Self { + ctx, + l1, + precompile, + token, + }) + } + + fn call(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + let _guard = tx_context::set_current_tx_hash(TX_HASH); + call_precompile( + &mut self.ctx, + &self.precompile, + caller, + data.as_ref(), + GAS, + false, + ZONE_OUTBOX_ADDRESS, + ZONE_OUTBOX_ADDRESS, + ) + } + + fn call_without_hash(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + call_precompile( + &mut self.ctx, + &self.precompile, + caller, + data.as_ref(), + GAS, + false, + ZONE_OUTBOX_ADDRESS, + ZONE_OUTBOX_ADDRESS, + ) + } + + fn pending(&mut self) -> eyre::Result> { + let output = self.call( + Address::ZERO, + ZoneOutboxAbi::getPendingWithdrawalsCall {}.abi_encode(), + )?; + Ok(ZoneOutboxAbi::getPendingWithdrawalsCall::abi_decode_returns(&output.bytes)?) + } + + fn request(&mut self, amount: u128, to: Address, memo: B256) -> PrecompileResult { + let token = self.token; + self.call( + ALICE, + ZoneOutboxAbi::requestWithdrawalCall { + token, + to, + amount, + memo, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + } + .abi_encode(), + ) + } + + fn finalize(&mut self, count: usize) -> PrecompileResult { + self.call( + SEQUENCER, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::from(count), + blockNumber: 0, + encryptedSenders: vec![Bytes::new(); count], + } + .abi_encode(), + ) + } +} + +fn assert_revert(result: PrecompileResult, error: ZoneOutboxError) { + let output = result.expect("precompile error"); + assert!(output.is_revert()); + assert_eq!(output.bytes, error.abi_encode()); +} + +#[test] +fn request_withdrawal_stores_fields_and_fifo_order() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(500, ALICE, B256::repeat_byte(1))?; + harness.request(300, BOB, B256::repeat_byte(2))?; + + let pending = harness.pending()?; + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].sender, ALICE); + assert_eq!(pending[0].txHash, TX_HASH); + assert_eq!(pending[0].to, ALICE); + assert_eq!(pending[0].amount, 500); + assert_eq!(pending[1].to, BOB); + assert_eq!(pending[1].amount, 300); + Ok(()) +} + +#[test] +fn request_withdrawal_rejects_disabled_token() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let portal = harness.l1.portal_address(); + harness.l1.set_u256( + portal, + U256::from_be_bytes(portal_token_config_slot(harness.token).0), + ANCHOR, + U256::ZERO, + ); + let result = harness.request(1, BOB, B256::ZERO); + assert_revert(result, ZoneOutboxError::token_not_enabled()); + Ok(()) +} + +#[test] +fn request_withdrawal_rejects_missing_transaction_hash() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + let result = harness.call_without_hash( + ALICE, + ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + } + .abi_encode(), + ); + assert_revert(result, ZoneOutboxError::invalid_current_tx_hash()); + Ok(()) +} + +#[test] +fn enqueue_bounce_back_is_inbox_only() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let call = ZoneOutboxAbi::enqueueDepositBounceBackCall { + token: harness.token, + amount: 100, + bouncebackRecipient: BOB, + } + .abi_encode(); + + assert_revert( + harness.call(ALICE, &call), + ZoneOutboxError::only_zone_inbox(), + ); + harness.call(ZONE_INBOX_ADDRESS, call)?; + let pending = harness.pending()?; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].sender, Address::ZERO); + assert_eq!(pending[0].fee, 0); + Ok(()) +} + +#[test] +fn finalize_empty_queue_returns_zero() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let output = harness.finalize(0)?; + assert_eq!( + ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_decode_returns(&output.bytes)?, + B256::ZERO + ); + Ok(()) +} + +#[test] +fn finalize_single_and_multiple_withdrawals_match_canonical_queue_hash() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(100, ALICE, B256::repeat_byte(1))?; + harness.request(200, BOB, B256::repeat_byte(2))?; + let pending = harness.pending()?; + let expected: Vec = pending + .iter() + .map(|pending| Withdrawal { + token: pending.token, + senderTag: Withdrawal::sender_tag(pending.sender, pending.txHash), + to: pending.to, + amount: pending.amount, + fee: pending.fee, + memo: pending.memo, + gasLimit: pending.gasLimit, + fallbackRecipient: pending.fallbackRecipient, + callbackData: pending.callbackData.clone(), + encryptedSender: Bytes::new(), + }) + .collect(); + + let output = harness.finalize(2)?; + assert_eq!( + ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_decode_returns(&output.bytes)?, + Withdrawal::queue_hash(&expected) + ); + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn finalize_rejects_wrong_count_and_non_sequencer() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(100, ALICE, B256::ZERO)?; + assert_revert( + harness.finalize(0), + ZoneOutboxError::invalid_withdrawal_count(U256::ZERO, U256::ONE), + ); + + let result = harness.call( + ALICE, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::ONE, + blockNumber: 0, + encryptedSenders: vec![Bytes::new()], + } + .abi_encode(), + ); + assert_revert(result, ZoneOutboxError::only_sequencer()); + Ok(()) +} From 996616a3813da5f1b134ab5b75a0548def2b9634 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 18:53:15 +0200 Subject: [PATCH 26/30] test: migrate test suite --- crates/precompiles/src/outbox/mod.rs | 31 +- crates/precompiles/src/outbox/tests.rs | 375 ++++++++++++++++++++++--- crates/precompiles/src/test_utils.rs | 7 +- 3 files changed, 356 insertions(+), 57 deletions(-) diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index 2d22bdf63..1f322ab3f 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -1,6 +1,8 @@ //! Native `ZoneOutbox` precompile. //! mod dispatch; +#[cfg(test)] +mod tests; use alloc::vec::Vec; @@ -111,13 +113,13 @@ impl CallRules for ZoneOutboxRules { pub struct ZoneOutbox { tempo_gas_rate: u128, next_withdrawal_index: u64, + withdrawal_queue_hash: B256, withdrawal_batch_index: u64, - last_batch: LastBatch, - pending_withdrawals: Vec, max_withdrawals_per_block: u32, withdrawals_this_block: u32, current_block_number: u64, last_finalized_timestamp: u64, + pending_withdrawals: Vec, } impl ZoneOutbox { @@ -269,10 +271,7 @@ impl ZoneOutbox { .checked_add(1) .ok_or_else(TempoPrecompileError::under_overflow)?; self.withdrawal_batch_index.write(next_batch_index)?; - self.last_batch.write(LastBatch { - withdrawal_queue_hash, - withdrawal_batch_index: next_batch_index, - })?; + self.withdrawal_queue_hash.write(withdrawal_queue_hash)?; self.last_finalized_timestamp .write(self.storage.timestamp().to::())?; self.emit_event(ZoneOutboxEvent::batch_finalized( @@ -317,22 +316,10 @@ impl ZoneOutbox { } fn last_batch(&self) -> TempoResult { - self.last_batch.read().map(Into::into) - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Storable)] -struct LastBatch { - withdrawal_queue_hash: B256, - withdrawal_batch_index: u64, -} - -impl From for ZoneOutboxAbi::LastBatch { - fn from(batch: LastBatch) -> Self { - Self { - withdrawalQueueHash: batch.withdrawal_queue_hash, - withdrawalBatchIndex: batch.withdrawal_batch_index, - } + Ok(ZoneOutboxAbi::LastBatch { + withdrawalQueueHash: self.withdrawal_queue_hash.read()?, + withdrawalBatchIndex: self.withdrawal_batch_index.read()?, + }) } } diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs index 77ff2cb73..99ae0f70d 100644 --- a/crates/precompiles/src/outbox/tests.rs +++ b/crates/precompiles/src/outbox/tests.rs @@ -107,31 +107,30 @@ impl Harness { }) } - fn call(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { - let _guard = tx_context::set_current_tx_hash(TX_HASH); - call_precompile( - &mut self.ctx, - &self.precompile, - caller, - data.as_ref(), - GAS, - false, - ZONE_OUTBOX_ADDRESS, - ZONE_OUTBOX_ADDRESS, - ) + #[rustfmt::skip] + fn call_inner(&mut self, caller: Address, data: impl AsRef<[u8]>, with_hash: bool, is_static: bool) -> PrecompileResult { + let mut call = || { call_precompile( + &mut self.ctx, &self.precompile, caller, data.as_ref(), GAS, is_static, ZONE_OUTBOX_ADDRESS, ZONE_OUTBOX_ADDRESS + )}; + + if with_hash { + let _guard = tx_context::set_current_tx_hash(TX_HASH); + call() + } else { + call() + } } fn call_without_hash(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { - call_precompile( - &mut self.ctx, - &self.precompile, - caller, - data.as_ref(), - GAS, - false, - ZONE_OUTBOX_ADDRESS, - ZONE_OUTBOX_ADDRESS, - ) + self.call_inner(caller, data, false, false) + } + + fn call(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + self.call_inner(caller, data, true, false) + } + + fn call_static(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + self.call_inner(caller, data, false, true) } fn pending(&mut self) -> eyre::Result> { @@ -143,23 +142,50 @@ impl Harness { } fn request(&mut self, amount: u128, to: Address, memo: B256) -> PrecompileResult { - let token = self.token; + self.request_custom(ZoneOutboxAbi::requestWithdrawalCall { + token: self.token, + to, + amount, + memo, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + }) + } + + fn request_custom(&mut self, call: ZoneOutboxAbi::requestWithdrawalCall) -> PrecompileResult { + self.call(ALICE, call.abi_encode()) + } + + fn set_gas_rate(&mut self, rate: u128) -> PrecompileResult { self.call( - ALICE, - ZoneOutboxAbi::requestWithdrawalCall { - token, - to, - amount, - memo, - gasLimit: 0, - fallbackRecipient: ALICE, - data: Bytes::new(), - revealTo: Bytes::new(), + SEQUENCER, + ZoneOutboxAbi::setTempoGasRateCall { + _tempoGasRate: rate, + } + .abi_encode(), + ) + } + + fn set_max_withdrawals(&mut self, max: u32) -> PrecompileResult { + self.call( + SEQUENCER, + ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall { + _maxWithdrawalsPerBlock: max, } .abi_encode(), ) } + fn balance_of(&mut self, account: Address) -> eyre::Result { + let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || { + let token = TIP20Token::from_address(self.token).expect("initialized token"); + Ok(token.balance_of(ITIP20::balanceOfCall { account })?) + }) + } + fn finalize(&mut self, count: usize) -> PrecompileResult { self.call( SEQUENCER, @@ -318,3 +344,286 @@ fn finalize_rejects_wrong_count_and_non_sequencer() -> eyre::Result<()> { assert_revert(result, ZoneOutboxError::only_sequencer()); Ok(()) } + +#[test] +fn fee_rate_and_gas_limit_validation_match_reference() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.set_gas_rate(3)?; + + let output = harness.call( + ALICE, + ZoneOutboxAbi::calculateWithdrawalFeeCall { gasLimit: 7 }.abi_encode(), + )?; + assert_eq!( + ZoneOutboxAbi::calculateWithdrawalFeeCall::abi_decode_returns(&output.bytes)?, + u128::from(WITHDRAWAL_BASE_GAS + 7) * 3 + ); + + assert_revert( + harness.call( + ALICE, + ZoneOutboxAbi::calculateWithdrawalFeeCall { + gasLimit: MAX_WITHDRAWAL_GAS_LIMIT + 1, + } + .abi_encode(), + ), + ZoneOutboxError::gas_limit_too_high(), + ); + assert_revert( + harness.set_gas_rate(MAX_GAS_FEE_RATE + 1), + ZoneOutboxError::gas_fee_rate_too_high(), + ); + Ok(()) +} + +#[test] +fn callback_and_reveal_boundaries_are_enforced() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + let base = |data: Bytes, reveal_to: Bytes| ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: ALICE, + data, + revealTo: reveal_to, + }; + + harness.request_custom(base( + Bytes::from(vec![0; MAX_CALLBACK_DATA_SIZE]), + Bytes::new(), + ))?; + assert_revert( + harness.request_custom(base( + Bytes::from(vec![0; MAX_CALLBACK_DATA_SIZE + 1]), + Bytes::new(), + )), + ZoneOutboxError::callback_data_too_large(), + ); + assert_revert( + harness.request_custom(base(Bytes::new(), Bytes::from(vec![2; 32]))), + ZoneOutboxError::invalid_reveal_to(), + ); + assert_revert( + harness.request_custom(base(Bytes::new(), Bytes::from(vec![4; 33]))), + ZoneOutboxError::invalid_reveal_to(), + ); + + let valid = Bytes::copy_from_slice(&alloy_primitives::hex!( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + )); + harness.request_custom(base(Bytes::new(), valid))?; + Ok(()) +} + +#[test] +fn fallback_recipient_and_zero_amount_semantics_match_reference() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + assert_revert( + harness.request_custom(ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: Address::ZERO, + data: Bytes::new(), + revealTo: Bytes::new(), + }), + ZoneOutboxError::invalid_fallback_recipient(), + ); + harness.request(0, BOB, B256::ZERO)?; + assert_eq!(harness.pending()?[0].amount, 0); + Ok(()) +} + +#[test] +fn request_burns_amount_plus_fee_and_rejects_insufficient_funds() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.set_gas_rate(2)?; + let before = harness.balance_of(ALICE)?; + let fee = u128::from(WITHDRAWAL_BASE_GAS) * 2; + harness.request(100, BOB, B256::ZERO)?; + assert_eq!(harness.balance_of(ALICE)?, before - U256::from(100 + fee)); + + let result = harness.request(u128::MAX, BOB, B256::ZERO); + assert!(result.expect("precompile result").is_revert()); + Ok(()) +} + +#[test] +fn encrypted_sender_count_and_length_are_validated() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(1, BOB, B256::ZERO)?; + assert_revert( + harness.call( + SEQUENCER, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::ONE, + blockNumber: 0, + encryptedSenders: Vec::new(), + } + .abi_encode(), + ), + ZoneOutboxError::invalid_encrypted_sender_count(U256::ZERO, U256::ONE), + ); + assert_revert( + harness.call( + SEQUENCER, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::ONE, + blockNumber: 0, + encryptedSenders: vec![Bytes::from(vec![1])], + } + .abi_encode(), + ), + ZoneOutboxError::invalid_encrypted_sender_length(U256::ONE, U256::ZERO), + ); + Ok(()) +} + +#[test] +fn indices_last_batch_and_timestamp_advance_across_batches() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.ctx.block.timestamp = U256::from(123); + harness.request(1, BOB, B256::ZERO)?; + harness.finalize(1)?; + harness.request(2, BOB, B256::ZERO)?; + let second_hash = ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_decode_returns( + &harness.finalize(1)?.bytes, + )?; + + let next = harness.call( + Address::ZERO, + ZoneOutboxAbi::nextWithdrawalIndexCall {}.abi_encode(), + )?; + assert_eq!( + ZoneOutboxAbi::nextWithdrawalIndexCall::abi_decode_returns(&next.bytes)?, + 2 + ); + let batch = harness.call(Address::ZERO, ZoneOutboxAbi::lastBatchCall {}.abi_encode())?; + let batch = ZoneOutboxAbi::lastBatchCall::abi_decode_returns(&batch.bytes)?; + assert_eq!(batch.withdrawalBatchIndex, 2); + assert_eq!(batch.withdrawalQueueHash, second_hash); + let timestamp = harness.call( + Address::ZERO, + ZoneOutboxAbi::lastFinalizedTimestampCall {}.abi_encode(), + )?; + assert_eq!( + ZoneOutboxAbi::lastFinalizedTimestampCall::abi_decode_returns(×tamp.bytes)?, + 123 + ); + Ok(()) +} + +#[test] +fn per_block_cap_is_unlimited_resettable_and_updateable() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.set_max_withdrawals(0)?; + for _ in 0..3 { + harness.request(1, BOB, B256::ZERO)?; + } + + harness.set_max_withdrawals(1)?; + harness.request(1, BOB, B256::ZERO)?; + assert_revert( + harness.request(1, BOB, B256::ZERO), + ZoneOutboxError::too_many_withdrawals_this_block(), + ); + harness.ctx.block.number = U256::ONE; + harness.request(1, BOB, B256::ZERO)?; + assert_revert( + harness.request(1, BOB, B256::ZERO), + ZoneOutboxError::too_many_withdrawals_this_block(), + ); + harness.set_max_withdrawals(2)?; + harness.request(1, BOB, B256::ZERO)?; + Ok(()) +} + +#[test] +fn many_withdrawals_finalize_and_clear_pending_state() -> eyre::Result<()> { + let mut harness = Harness::new()?; + for i in 0..20u64 { + harness.request(1, BOB, B256::from(U256::from(i)))?; + } + harness.finalize(20)?; + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn legacy_withdrawal_matches_current_overload_and_defaults_reveal_to() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + let memo = B256::repeat_byte(0x11); + let data = Bytes::from_static(b"callback"); + + harness.request_custom(ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 123, + memo, + gasLimit: 7, + fallbackRecipient: ALICE, + data: data.clone(), + revealTo: Bytes::new(), + })?; + harness.call( + ALICE, + ILegacyZoneOutbox::requestWithdrawalCall { + token, + to: BOB, + amount: 123, + memo, + gasLimit: 7, + fallbackRecipient: ALICE, + data, + } + .abi_encode(), + )?; + + let pending = harness.pending()?; + assert_eq!(pending.len(), 2); + assert_eq!(pending[0], pending[1]); + assert!(pending[1].revealTo.is_empty()); + Ok(()) +} + +#[test] +fn malformed_legacy_withdrawal_reverts_with_empty_data() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let output = harness.call(ALICE, ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR)?; + assert!(output.is_revert()); + assert!(output.bytes.is_empty()); + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn static_mutation_reverts_with_static_call_not_allowed() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + assert_revert( + harness.call_static( + ALICE, + ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + } + .abi_encode(), + ), + ZoneOutboxError::static_call_not_allowed(), + ); + assert!(harness.pending()?.is_empty()); + Ok(()) +} diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index 285b724bd..a6daceaf3 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -18,7 +18,7 @@ use k256::{ }; use revm::{ Context, - context::{BlockEnv, CfgEnv, TxEnv}, + context::{CfgEnv, TxEnv}, database::{CacheDB, EmptyDB}, precompile::{PrecompileError, PrecompileResult}, }; @@ -32,6 +32,8 @@ use tempo_precompiles::{ tip403_registry::{CompoundPolicyData, PolicyData, TIP403Registry}, }; +use tempo_primitives::TempoBlockEnv; + use crate::{ L1StorageReader, chaum_pedersen::{challenge_hash, recover_point}, @@ -42,7 +44,8 @@ use crate::{ pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; /// EVM context used by precompile tests. -pub(crate) type TestContext = Context, CacheDB>; +pub(crate) type TestContext = + Context, CacheDB>; type L1Slot = (Address, B256, u64); type Shared = Arc>; From 0f6322f07f827773dc56027e62793f8f9d33bf7e Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 22:17:36 +0200 Subject: [PATCH 27/30] fix: align outbox with fallback nonce stack --- crates/evm/Cargo.toml | 3 +- crates/precompiles/src/ecies.rs | 302 +++++++++++++++++++-- crates/precompiles/src/execution.rs | 8 +- crates/precompiles/src/outbox/dispatch.rs | 2 + crates/precompiles/src/outbox/mod.rs | 41 ++- crates/precompiles/src/outbox/tests.rs | 35 ++- crates/precompiles/src/tip403_proxy/mod.rs | 20 +- specs/ref-impls/src/zone/ZoneOutbox.sol | 48 ++-- 8 files changed, 389 insertions(+), 70 deletions(-) diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index 05df44c10..1058bb7be 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -36,6 +36,7 @@ reth-revm.workspace = true # alloy alloy-consensus.workspace = true alloy-evm.workspace = true +alloy-primitives.workspace = true alloy-provider = { workspace = true, features = ["reqwest"] } # revm @@ -43,7 +44,7 @@ revm.workspace = true # misc tokio.workspace = true + [dev-dependencies] -alloy-primitives.workspace = true eyre.workspace = true tempo-precompiles = { workspace = true, features = ["test-utils"] } diff --git a/crates/precompiles/src/ecies.rs b/crates/precompiles/src/ecies.rs index e2874e017..77a9c38c6 100644 --- a/crates/precompiles/src/ecies.rs +++ b/crates/precompiles/src/ecies.rs @@ -43,6 +43,12 @@ pub(crate) fn decode_compressed_public_key(encoded: &[u8]) -> Option Option> { - let reveal_pub = decode_compressed_public_key(reveal_to)?; - let eph_key = k256::SecretKey::random(&mut rand::thread_rng()); let eph_scalar: Scalar = *eph_key.to_nonzero_scalar(); + let nonce_bytes: [u8; 12] = rand::random(); + + encrypt_authenticated_withdrawal_with_material( + reveal_to, + sender, + tx_hash, + &eph_scalar, + nonce_bytes, + ) +} + +/// Deterministically encrypt `(sender, tx_hash)` for authenticated withdrawals. +/// +/// This is the consensus-safe variant used by zone payload construction. It +/// derives both the ECIES ephemeral scalar and AES-GCM nonce from the sequencer +/// encryption key, zone id, reveal key, sender, and withdrawal transaction hash. +pub fn encrypt_authenticated_withdrawal_deterministic( + encryption_privkey: &k256::SecretKey, + zone_id: u32, + reveal_to: &[u8], + sender: Address, + tx_hash: B256, +) -> Option> { + let derivation_key = authenticated_withdrawal_derivation_key(encryption_privkey); + let eph_scalar = derive_authenticated_withdrawal_ephemeral_scalar( + &derivation_key, + zone_id, + reveal_to, + sender, + tx_hash, + )?; let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * eph_scalar); let eph_encoded = eph_pub.to_encoded_point(true); let eph_pubkey: [u8; 33] = eph_encoded.as_bytes().try_into().ok()?; + let nonce_bytes = derive_authenticated_withdrawal_nonce( + &derivation_key, + zone_id, + reveal_to, + sender, + tx_hash, + &eph_pubkey, + ); + + encrypt_authenticated_withdrawal_with_material( + reveal_to, + sender, + tx_hash, + &eph_scalar, + nonce_bytes, + ) +} + +fn encrypt_authenticated_withdrawal_with_material( + reveal_to: &[u8], + sender: Address, + tx_hash: B256, + eph_scalar: &Scalar, + nonce_bytes: [u8; 12], +) -> Option> { + let reveal_pub = decode_compressed_public_key(reveal_to)?; + + let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * *eph_scalar); + let eph_encoded = eph_pub.to_encoded_point(true); + let eph_pubkey: [u8; 33] = eph_encoded.as_bytes().try_into().ok()?; - let shared_proj = ProjectivePoint::from(reveal_pub) * eph_scalar; + let shared_proj = ProjectivePoint::from(reveal_pub) * *eph_scalar; let shared_affine = AffinePoint::from(shared_proj); let ss_encoded = shared_affine.to_encoded_point(true); let shared_secret_x: [u8; 32] = ss_encoded.x()?.as_slice().try_into().ok()?; @@ -196,7 +261,6 @@ pub fn encrypt_authenticated_withdrawal( let plaintext = build_authenticated_withdrawal_plaintext(&sender, &tx_hash); let cipher = Aes256Gcm::new((&aes_key).into()); - let nonce_bytes: [u8; 12] = rand::random(); let nonce = Nonce::from_slice(&nonce_bytes); let encrypted = cipher.encrypt(nonce, plaintext.as_ref()).ok()?; let ciphertext = &encrypted[..encrypted.len() - 16]; @@ -210,6 +274,86 @@ pub fn encrypt_authenticated_withdrawal( Some(out) } +fn derive_authenticated_withdrawal_ephemeral_scalar( + derivation_key: &[u8; 32], + zone_id: u32, + reveal_to: &[u8], + sender: Address, + tx_hash: B256, +) -> Option { + for counter in 0u32.. { + let mut msg = authenticated_withdrawal_context( + AUTH_WITHDRAWAL_EPHEMERAL_DOMAIN, + zone_id, + reveal_to, + sender, + tx_hash, + ); + msg.extend_from_slice(&counter.to_be_bytes()); + + let candidate = hmac_sha256(derivation_key, &msg); + if let Ok(key) = k256::SecretKey::from_slice(&candidate) { + return Some(*key.to_nonzero_scalar()); + } + } + + None +} + +fn derive_authenticated_withdrawal_nonce( + derivation_key: &[u8; 32], + zone_id: u32, + reveal_to: &[u8], + sender: Address, + tx_hash: B256, + eph_pubkey: &[u8; 33], +) -> [u8; 12] { + let mut msg = authenticated_withdrawal_context( + AUTH_WITHDRAWAL_NONCE_DOMAIN, + zone_id, + reveal_to, + sender, + tx_hash, + ); + msg.extend_from_slice(eph_pubkey); + + let digest = hmac_sha256(derivation_key, &msg); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&digest[..12]); + nonce +} + +fn authenticated_withdrawal_derivation_key(encryption_privkey: &k256::SecretKey) -> [u8; 32] { + let secret = secret_scalar_bytes(encryption_privkey); + // Derive a purpose-specific HMAC key first, so the raw ECIES private scalar + // is not reused directly across the ephemeral-scalar and nonce derivations. + hmac_sha256(&secret, AUTH_WITHDRAWAL_DERIVATION_KEY_DOMAIN) +} + +fn authenticated_withdrawal_context( + domain: &[u8], + zone_id: u32, + reveal_to: &[u8], + sender: Address, + tx_hash: B256, +) -> Vec { + let mut msg = Vec::with_capacity(domain.len() + 4 + 4 + reveal_to.len() + 20 + 32); + msg.extend_from_slice(domain); + msg.extend_from_slice(&zone_id.to_be_bytes()); + msg.extend_from_slice(&(reveal_to.len() as u32).to_be_bytes()); + msg.extend_from_slice(reveal_to); + msg.extend_from_slice(sender.as_slice()); + msg.extend_from_slice(tx_hash.as_slice()); + msg +} + +fn secret_scalar_bytes(secret_key: &k256::SecretKey) -> [u8; 32] { + let repr = secret_key.to_nonzero_scalar().to_repr(); + let mut out = [0u8; 32]; + out.copy_from_slice(repr.as_ref()); + out +} + /// Decrypt an authenticated-withdrawal `encryptedSender` payload. pub fn decrypt_authenticated_withdrawal( reveal_privkey: &k256::SecretKey, @@ -314,13 +458,11 @@ fn generate_chaum_pedersen_proof( ephemeral_pub: &AffinePoint, shared_secret: &AffinePoint, sequencer_pub: &AffinePoint, -) -> (Scalar, Scalar) { - use k256::elliptic_curve::Field; - - let mut rng = rand::thread_rng(); - - // 1. Prover picks random k - let k = Scalar::random(&mut rng); +) -> Option<(Scalar, Scalar)> { + // The proof is included in advanceTempo calldata, so runtime randomness here + // would make otherwise identical zone blocks diverge. Derive the blinding + // scalar from the encryption key and the complete public statement instead. + let k = deterministic_cp_nonce(priv_seq, ephemeral_pub, sequencer_pub, shared_secret)?; let r1 = AffinePoint::from(ProjectivePoint::GENERATOR * k); let r2 = AffinePoint::from(ProjectivePoint::from(*ephemeral_pub) * k); @@ -330,7 +472,45 @@ fn generate_chaum_pedersen_proof( // 3. Response: s = k + c * privSeq let s = k + c * priv_seq; - (s, c) + Some((s, c)) +} + +/// Domain-separated deterministic Chaum-Pedersen nonce derivation. +/// +/// Invalid scalar candidates are retried with a counter. +fn deterministic_cp_nonce( + priv_seq: &Scalar, + ephemeral_pub: &AffinePoint, + sequencer_pub: &AffinePoint, + shared_secret: &AffinePoint, +) -> Option { + // Although the latter two points are derived from `priv_seq` and `ephemeral_pub`, + // include the complete public statement so every challenge input also binds the nonce. + let ephemeral_pub = ephemeral_pub.to_encoded_point(true); + let sequencer_pub = sequencer_pub.to_encoded_point(true); + let shared_secret = shared_secret.to_encoded_point(true); + let mut input = Vec::with_capacity(CP_NONCE_DOMAIN.len() + 33 * 3 + 4); + input.extend_from_slice(CP_NONCE_DOMAIN); + input.extend_from_slice(ephemeral_pub.as_bytes()); + input.extend_from_slice(sequencer_pub.as_bytes()); + input.extend_from_slice(shared_secret.as_bytes()); + + let secret = priv_seq.to_bytes(); + for counter in 0..=u32::MAX { + input.extend_from_slice(&counter.to_be_bytes()); + let candidate = hmac_sha256(secret.as_ref(), &input); + + if let Ok(nonce) = k256::SecretKey::from_slice(&candidate) { + return Some(*nonce.to_nonzero_scalar()); + } + + // Reset to try another counter + input.truncate(input.len() - 4); + } + + // Astronomically impossible that we didn't find a valid scalar, so if we're here, its a bug, + // return None + None } /// HMAC-SHA256 implementation matching ZoneInbox._hmacSha256. @@ -441,9 +621,9 @@ pub fn encrypt_plaintext(aes_key: &[u8; 32], plaintext: &[u8]) -> (Vec, [u8; #[cfg(test)] mod tests { use super::{ - AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, compressed_x_and_parity, + AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, compressed_x_and_parity, compute_ecdh_proof, decrypt_authenticated_withdrawal, decrypt_deposit, encrypt_authenticated_withdrawal, - hkdf_sha256, hmac_sha256, + encrypt_authenticated_withdrawal_deterministic, hkdf_sha256, hmac_sha256, }; use crate::test_utils::{EncryptedDepositFixture, assert_cp_proof_valid}; use alloy_primitives::{Address, B256, U256}; @@ -459,6 +639,17 @@ mod tests { assert_cp_proof_valid(&dec, &f.eph_pub, &f.seq_pub); } + #[test] + fn test_cp_proof_is_deterministic() { + let f = EncryptedDepositFixture::new(); + + let proof_a = compute_ecdh_proof(&f.seq_key, &f.eph_pub_x, f.eph_pub_y_parity).unwrap(); + let proof_b = compute_ecdh_proof(&f.seq_key, &f.eph_pub_x, f.eph_pub_y_parity).unwrap(); + + assert_eq!(proof_a.cp_proof_s, proof_b.cp_proof_s); + assert_eq!(proof_a.cp_proof_c, proof_b.cp_proof_c); + } + #[test] fn test_authenticated_withdrawal_roundtrip() { use sha2::{Digest, Sha256}; @@ -480,6 +671,85 @@ mod tests { assert_eq!(decrypted_tx_hash, tx_hash); } + #[test] + fn test_authenticated_withdrawal_deterministic_roundtrip() { + use sha2::{Digest, Sha256}; + + let reveal_key_bytes: [u8; 32] = + Sha256::digest(b"authenticated-withdrawal-reveal-key").into(); + let reveal_key = k256::SecretKey::from_slice(&reveal_key_bytes).unwrap(); + let reveal_pub = reveal_key.public_key(); + let reveal_encoded = reveal_pub.to_encoded_point(true); + + let encryption_key_bytes: [u8; 32] = + Sha256::digest(b"authenticated-withdrawal-encryption-key").into(); + let encryption_key = k256::SecretKey::from_slice(&encryption_key_bytes).unwrap(); + + let zone_id = 17; + let sender = Address::repeat_byte(0x11); + let tx_hash = B256::repeat_byte(0x22); + let encrypted_a = encrypt_authenticated_withdrawal_deterministic( + &encryption_key, + zone_id, + reveal_encoded.as_bytes(), + sender, + tx_hash, + ) + .unwrap(); + let encrypted_b = encrypt_authenticated_withdrawal_deterministic( + &encryption_key, + zone_id, + reveal_encoded.as_bytes(), + sender, + tx_hash, + ) + .unwrap(); + + assert_eq!(encrypted_a, encrypted_b); + assert_eq!(encrypted_a.len(), AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE); + + let (decrypted_sender, decrypted_tx_hash) = + decrypt_authenticated_withdrawal(&reveal_key, &encrypted_a).unwrap(); + assert_eq!(decrypted_sender, sender); + assert_eq!(decrypted_tx_hash, tx_hash); + } + + #[test] + fn test_authenticated_withdrawal_deterministic_changes_by_zone() { + use sha2::{Digest, Sha256}; + + let reveal_key_bytes: [u8; 32] = + Sha256::digest(b"authenticated-withdrawal-zone-reveal-key").into(); + let reveal_key = k256::SecretKey::from_slice(&reveal_key_bytes).unwrap(); + let reveal_pub = reveal_key.public_key(); + let reveal_encoded = reveal_pub.to_encoded_point(true); + + let encryption_key_bytes: [u8; 32] = + Sha256::digest(b"authenticated-withdrawal-zone-encryption-key").into(); + let encryption_key = k256::SecretKey::from_slice(&encryption_key_bytes).unwrap(); + + let sender = Address::repeat_byte(0x11); + let tx_hash = B256::repeat_byte(0x22); + let encrypted_a = encrypt_authenticated_withdrawal_deterministic( + &encryption_key, + 17, + reveal_encoded.as_bytes(), + sender, + tx_hash, + ) + .unwrap(); + let encrypted_b = encrypt_authenticated_withdrawal_deterministic( + &encryption_key, + 18, + reveal_encoded.as_bytes(), + sender, + tx_hash, + ) + .unwrap(); + + assert_ne!(encrypted_a, encrypted_b); + } + #[test] fn test_ecies_decrypt_wrong_key() { let f = EncryptedDepositFixture::new(); diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index d652c2813..e31edf9ff 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -348,6 +348,7 @@ mod tests { cell::{Cell, RefCell}, rc::Rc, }; + use tempo_precompiles::storage::PrecompileStorageProvider; const FIXED_GAS: u64 = 123; type RuleRecord = Rc, Address)>>>; @@ -432,10 +433,11 @@ mod tests { let reader = MockL1Reader::default(); let observed_spec = Rc::new(Cell::new(None)); let execute_spec = observed_spec.clone(); - let cfg = revm::context::CfgEnv::::default(); + let mut cfg = revm::context::CfgEnv::::default(); + cfg.spec = TempoHardfork::T8; let env = L1BackedPrecompileEnv::new( &cfg, - reader.clone(), + reader, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), ); @@ -454,7 +456,6 @@ mod tests { .is_revert() ); assert!(checked.get()); - assert!(reader.hardfork_requests().is_empty()); let precompile = create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { @@ -473,7 +474,6 @@ mod tests { .call(input(&mut ctx, &[], Address::ZERO, u64::MAX)) .unwrap(); - assert_eq!(reader.hardfork_requests(), vec![anchor]); assert_eq!(observed_spec.get(), Some(TempoHardfork::T8)); } diff --git a/crates/precompiles/src/outbox/dispatch.rs b/crates/precompiles/src/outbox/dispatch.rs index e0df910bb..47818a9a6 100644 --- a/crates/precompiles/src/outbox/dispatch.rs +++ b/crates/precompiles/src/outbox/dispatch.rs @@ -29,6 +29,7 @@ impl Precompile for ZoneOutbox { withdrawalBatchIndex(_) => metadata::(|| self.withdrawal_batch_index.read()), lastFinalizedTimestamp(_) => metadata::(|| self.last_finalized_timestamp.read()), nextWithdrawalIndex(_) => metadata::(|| self.next_withdrawal_index.read()), + lastFallbackNonce(_) => metadata::(|| self.last_fallback_nonce.read()), pendingWithdrawalsCount(_) => metadata::(|| self.pending_withdrawals_count()), getPendingWithdrawals(_) => metadata::(|| self.get_pending_withdrawals()), calculateWithdrawalFee(call) => view(call, |call| self.calculate_withdrawal_fee(call.gasLimit)), @@ -48,6 +49,7 @@ impl Precompile for ZoneOutbox { ) }), enqueueDepositBounceBack(call) => mutate_void(call, msg_sender, |sender, call| self.enqueue_deposit_bounce_back(sender, call)), + consumeFallbackRecipient(call) => mutate(call, msg_sender, |sender, call| self.consume_fallback_recipient(sender, call.fallbackNonce)), finalizeWithdrawalBatch(call) => mutate(call, msg_sender, |_, call| self.finalize_withdrawal_batch(call)), } ILegacyZoneOutbox::ILegacyZoneOutboxCalls { diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index 1f322ab3f..5132fd31d 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -12,7 +12,7 @@ use revm::interpreter::instructions::utility::IntoAddress; use tempo_precompiles::{ Result as TempoResult, error::TempoPrecompileError, - storage::{Handler, StorageCtx}, + storage::{Handler, Mapping, StorageCtx}, tip20::{ITIP20, TIP20Token}, }; use tempo_precompiles_macros::{Storable, contract}; @@ -66,7 +66,11 @@ impl CallRules for ZoneOutboxRules { if call.is_static && call.selector().is_some_and(|selector| { self.requires_l1(Some(selector)) - || selector == ZoneOutboxAbi::enqueueDepositBounceBackCall::SELECTOR + || matches!( + selector, + ZoneOutboxAbi::enqueueDepositBounceBackCall::SELECTOR + | ZoneOutboxAbi::consumeFallbackRecipientCall::SELECTOR + ) }) { return CallCheck::from_error(ZoneOutboxError::static_call_not_allowed()); @@ -119,6 +123,8 @@ pub struct ZoneOutbox { withdrawals_this_block: u32, current_block_number: u64, last_finalized_timestamp: u64, + last_fallback_nonce: u64, + fallback_recipients: Mapping, pending_withdrawals: Vec, } @@ -212,10 +218,18 @@ impl ZoneOutbox { } zone_token.burn(self.address, ITIP20::burnCall { amount })?; + let fallback_nonce = self + .last_fallback_nonce + .read()? + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?; + self.last_fallback_nonce.write(fallback_nonce)?; + self.fallback_recipients[fallback_nonce].write(call.fallbackRecipient)?; self.enqueue(PendingWithdrawal::from_request( caller, current_tx_hash, fee, + fallback_nonce, call, )) } @@ -232,6 +246,18 @@ impl ZoneOutbox { self.enqueue(PendingWithdrawal::from_bounce_back(call)) } + fn consume_fallback_recipient(&mut self, caller: Address, nonce: u64) -> ZoneResult

{ + if caller != ZONE_INBOX_ADDRESS { + return Err(ZoneOutboxError::only_zone_inbox().into()); + } + let recipient = self.fallback_recipients[nonce].read()?; + if recipient.is_zero() { + return Err(ZoneOutboxError::invalid_fallback_recipient().into()); + } + self.fallback_recipients[nonce].delete()?; + Ok(recipient) + } + fn finalize_withdrawal_batch( &mut self, call: ZoneOutboxAbi::finalizeWithdrawalBatchCall, @@ -333,7 +359,7 @@ struct PendingWithdrawal { fee: u128, memo: B256, gas_limit: u64, - fallback_recipient: Address, + fallback_nonce: u64, callback_data: Bytes, reveal_to: Bytes, } @@ -343,6 +369,7 @@ impl PendingWithdrawal { sender: Address, tx_hash: B256, fee: u128, + fallback_nonce: u64, call: ZoneOutboxAbi::requestWithdrawalCall, ) -> Self { Self { @@ -354,7 +381,7 @@ impl PendingWithdrawal { fee, memo: call.memo, gas_limit: call.gasLimit, - fallback_recipient: call.fallbackRecipient, + fallback_nonce, callback_data: call.data, reveal_to: call.revealTo, } @@ -379,7 +406,7 @@ impl PendingWithdrawal { self.fee, self.memo, self.gas_limit, - self.fallback_recipient, + self.fallback_nonce, self.callback_data.clone(), self.reveal_to.clone(), ) @@ -408,7 +435,7 @@ impl PendingWithdrawal { fee: self.fee, memo: self.memo, gasLimit: self.gas_limit, - fallbackRecipient: self.fallback_recipient, + fallbackNonce: self.fallback_nonce, callbackData: self.callback_data, encryptedSender: encrypted_sender, }) @@ -426,7 +453,7 @@ impl From for ZoneOutboxAbi::PendingWithdrawal { fee: pending.fee, memo: pending.memo, gasLimit: pending.gas_limit, - fallbackRecipient: pending.fallback_recipient, + fallbackNonce: pending.fallback_nonce, callbackData: pending.callback_data, revealTo: pending.reveal_to, } diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs index 99ae0f70d..53ef403f9 100644 --- a/crates/precompiles/src/outbox/tests.rs +++ b/crates/precompiles/src/outbox/tests.rs @@ -308,7 +308,7 @@ fn finalize_single_and_multiple_withdrawals_match_canonical_queue_hash() -> eyre fee: pending.fee, memo: pending.memo, gasLimit: pending.gasLimit, - fallbackRecipient: pending.fallbackRecipient, + fallbackNonce: pending.fallbackNonce, callbackData: pending.callbackData.clone(), encryptedSender: Bytes::new(), }) @@ -588,7 +588,11 @@ fn legacy_withdrawal_matches_current_overload_and_defaults_reveal_to() -> eyre:: let pending = harness.pending()?; assert_eq!(pending.len(), 2); - assert_eq!(pending[0], pending[1]); + assert_eq!(pending[0].fallbackNonce, 1); + assert_eq!(pending[1].fallbackNonce, 2); + let mut legacy = pending[1].clone(); + legacy.fallbackNonce = pending[0].fallbackNonce; + assert_eq!(pending[0], legacy); assert!(pending[1].revealTo.is_empty()); Ok(()) } @@ -627,3 +631,30 @@ fn static_mutation_reverts_with_static_call_not_allowed() -> eyre::Result<()> { assert!(harness.pending()?.is_empty()); Ok(()) } + +#[test] +fn fallback_recipient_nonce_is_private_and_consumed_once_by_inbox() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(1, BOB, B256::ZERO)?; + let nonce = harness.pending()?[0].fallbackNonce; + assert_eq!(nonce, 1); + + let calldata = ZoneOutboxAbi::consumeFallbackRecipientCall { + fallbackNonce: nonce, + } + .abi_encode(); + assert_revert( + harness.call(ALICE, &calldata), + ZoneOutboxError::only_zone_inbox(), + ); + let output = harness.call(ZONE_INBOX_ADDRESS, &calldata)?; + assert_eq!( + ZoneOutboxAbi::consumeFallbackRecipientCall::abi_decode_returns(&output.bytes)?, + ALICE + ); + assert_revert( + harness.call(ZONE_INBOX_ADDRESS, calldata), + ZoneOutboxError::invalid_fallback_recipient(), + ); + Ok(()) +} diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 22cc6ff08..6e4b23657 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -24,7 +24,7 @@ const TIP403_MUTATING_SELECTORS: &[[u8; 4]] = &[ ]; alloy_sol_types::sol! { - /// Returned when a mutating call is attempted on the zone's read-only, L1-backed, registry. + /// Returned when a mutating call is attempted on the zone's read-only, L1-backed registry. #[derive(Debug, PartialEq, Eq)] error ReadOnlyRegistry(); } @@ -53,6 +53,7 @@ mod tests { use alloy_primitives::{Bytes, U256, address}; use alloy_sol_types::SolError; use revm::precompile::{PrecompileError, PrecompileOutput}; + use tempo_chainspec::hardfork::TempoHardfork; use tempo_precompiles::{DelegateCallNotAllowed, storage::PrecompileStorageProvider}; use crate::{ @@ -77,6 +78,7 @@ mod tests { impl RegistryHarness { fn new(l1: MockL1Reader) -> Self { let mut ctx = test_context(); + ctx.cfg.spec = TempoHardfork::T8; test_storage_provider(&mut ctx, u64::MAX, false) .sstore( zone_primitives::constants::TEMPO_STATE_ADDRESS, @@ -281,13 +283,12 @@ mod tests { output.bytes, Bytes::from(DelegateCallNotAllowed {}.abi_encode()) ); - assert!(reader.hardfork_requests().is_empty()); assert!(reader.storage_requests().is_empty()); Ok(()) } #[test] - fn registry_reads_every_slot_and_hardfork_at_the_exact_tempo_anchor() -> eyre::Result<()> { + fn registry_reads_every_slot_at_the_exact_tempo_anchor() -> eyre::Result<()> { let reader = seeded_reader(); let mut harness = RegistryHarness::new(reader.clone()); let call = ITIP403Registry::isAuthorizedCall { @@ -298,7 +299,6 @@ mod tests { let output = harness.call(&call, u64::MAX)?; assert!(output.is_success()); - assert_eq!(reader.hardfork_requests(), vec![ANCHOR]); let requests = reader.storage_requests(); assert!(!requests.is_empty()); assert!(requests.iter().all(|(_, _, block)| *block == ANCHOR)); @@ -306,29 +306,19 @@ mod tests { } #[test] - fn anchored_hardfork_and_storage_failures_fail_closed() { + fn anchored_storage_failures_fail_closed() { let call = ITIP403Registry::isAuthorizedCall { policyId: 5, user: ALICE, } .abi_encode(); - let hardfork_reader = MockL1Reader::failing_hardfork(); - let mut harness = RegistryHarness::new(hardfork_reader.clone()); - assert!(matches!( - harness.call(&call, u64::MAX), - Err(PrecompileError::Fatal(message)) if message.contains("hardfork unavailable") - )); - assert_eq!(hardfork_reader.hardfork_requests(), vec![ANCHOR]); - assert!(hardfork_reader.storage_requests().is_empty()); - let storage_reader = MockL1Reader::failing_storage(); let mut harness = RegistryHarness::new(storage_reader.clone()); assert!(matches!( harness.call(&call, u64::MAX), Err(PrecompileError::Fatal(message)) if message.contains("RPC unavailable") )); - assert_eq!(storage_reader.hardfork_requests(), vec![ANCHOR]); assert!( storage_reader .storage_requests() diff --git a/specs/ref-impls/src/zone/ZoneOutbox.sol b/specs/ref-impls/src/zone/ZoneOutbox.sol index 34d987af2..f7a4d8a68 100644 --- a/specs/ref-impls/src/zone/ZoneOutbox.sol +++ b/specs/ref-impls/src/zone/ZoneOutbox.sol @@ -93,6 +93,12 @@ contract ZoneOutbox is IZoneOutbox { /// @notice Timestamp of the latest withdrawal batch finalization. uint64 public lastFinalizedTimestamp; + /// @notice Last nonce assigned to a user withdrawal fallback recipient + uint64 public lastFallbackNonce; + + /// @notice Private fallback recipient lookup used when an L1 withdrawal bounces back + mapping(uint64 fallbackNonce => address recipient) internal _fallbackRecipients; + /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -289,6 +295,8 @@ contract ZoneOutbox is IZoneOutbox { zoneToken.burn(totalBurn); // Store withdrawal in pending array + uint64 fallbackNonce = ++lastFallbackNonce; + _fallbackRecipients[fallbackNonce] = fallbackRecipient; _pendingWithdrawals.push( PendingWithdrawal({ token: token, @@ -299,7 +307,7 @@ contract ZoneOutbox is IZoneOutbox { fee: fee, memo: memo, gasLimit: gasLimit, - fallbackRecipient: fallbackRecipient, + fallbackNonce: fallbackNonce, callbackData: data, revealTo: revealTo }) @@ -309,17 +317,7 @@ contract ZoneOutbox is IZoneOutbox { uint64 index = nextWithdrawalIndex++; emit WithdrawalRequested( - index, - msg.sender, - token, - to, - amount, - fee, - memo, - gasLimit, - fallbackRecipient, - data, - revealTo + index, msg.sender, token, to, amount, fee, memo, gasLimit, fallbackNonce, data, revealTo ); } @@ -344,7 +342,7 @@ contract ZoneOutbox is IZoneOutbox { fee: 0, memo: bytes32(0), gasLimit: 0, - fallbackRecipient: address(0), + fallbackNonce: 0, callbackData: "", revealTo: "" }) @@ -352,20 +350,20 @@ contract ZoneOutbox is IZoneOutbox { uint64 index = nextWithdrawalIndex++; emit WithdrawalRequested( - index, - address(0), - token, - bouncebackRecipient, - amount, - 0, - bytes32(0), - 0, - address(0), - "", - "" + index, address(0), token, bouncebackRecipient, amount, 0, bytes32(0), 0, 0, "", "" ); } + /// @notice Resolve and delete the recipient for a failed L1 withdrawal. + /// @dev The nonce is committed to the L1 withdrawal while the recipient remains private + /// in zone state. Only ZoneInbox may consume a mapping entry. + function consumeFallbackRecipient(uint64 fallbackNonce) external returns (address recipient) { + if (msg.sender != ZONE_INBOX) revert OnlyZoneInbox(); + recipient = _fallbackRecipients[fallbackNonce]; + if (recipient == address(0)) revert InvalidFallbackRecipient(); + delete _fallbackRecipients[fallbackNonce]; + } + /*////////////////////////////////////////////////////////////// BATCH OPERATIONS //////////////////////////////////////////////////////////////*/ @@ -431,7 +429,7 @@ contract ZoneOutbox is IZoneOutbox { fee: pendingWithdrawal.fee, memo: pendingWithdrawal.memo, gasLimit: pendingWithdrawal.gasLimit, - fallbackRecipient: pendingWithdrawal.fallbackRecipient, + fallbackNonce: pendingWithdrawal.fallbackNonce, callbackData: pendingWithdrawal.callbackData, encryptedSender: encryptedSender }); From 68f6d2adf3484607c344df2760513dbb64d70af4 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 09:38:56 +0200 Subject: [PATCH 28/30] test: use `TIP20Setup` --- crates/precompiles/src/outbox/mod.rs | 11 +++-- crates/precompiles/src/outbox/tests.rs | 50 ++++++--------------- crates/precompiles/src/test_utils.rs | 4 +- crates/precompiles/src/ztip20/mod.rs | 62 ++++++++------------------ 4 files changed, 38 insertions(+), 89 deletions(-) diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index 5132fd31d..3a284f219 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -90,12 +90,11 @@ impl CallRules for ZoneOutboxRules { return CallCheck::Continue; }; if SEQUENCER_SELECTORS.contains(&selector) && call.caller != Address::ZERO { - let sequencer = match StorageCtx::default() - .sload(self.portal, U256::from_be_bytes(PORTAL_SEQUENCER_SLOT.0)) - { - Ok(value) => value.into_address(), - Err(err) => return CallCheck::from_error(err), - }; + let sequencer = + match StorageCtx::default().sload(self.portal, PORTAL_SEQUENCER_SLOT.into()) { + Ok(value) => value.into_address(), + Err(err) => return CallCheck::from_error(err), + }; if sequencer != call.caller { return CallCheck::from_error(ZoneOutboxError::only_sequencer()); } diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs index 53ef403f9..be90788c8 100644 --- a/crates/precompiles/src/outbox/tests.rs +++ b/crates/precompiles/src/outbox/tests.rs @@ -4,11 +4,11 @@ use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{Bytes, address}; use alloy_sol_types::{SolCall, SolInterface}; use revm::precompile::PrecompileResult; -use tempo_precompiles::{Precompile as _, tip20::ISSUER_ROLE}; +use tempo_precompiles::{Precompile as _, storage::FromWord, test_util::TIP20Setup}; use tempo_zone_contracts::portal_token_config_slot; use crate::{ - L1StorageReader, execution, + L1StorageReader, TempoState, execution, test_utils::{ MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, test_storage_provider, @@ -39,13 +39,13 @@ impl Harness { l1.set_u256( portal, - U256::from_be_bytes(PORTAL_SEQUENCER_SLOT.0), + PORTAL_SEQUENCER_SLOT.into(), ANCHOR, - U256::from_be_slice(SEQUENCER.as_slice()), + SEQUENCER.to_word(), ); l1.set_u256( portal, - U256::from_be_bytes(portal_token_config_slot(token).0), + portal_token_config_slot(token).into(), ANCHOR, U256::ONE, ); @@ -54,39 +54,15 @@ impl Harness { { let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || -> eyre::Result<()> { - StorageCtx::default().sstore( - zone_primitives::constants::TEMPO_STATE_ADDRESS, - crate::tempo_state::slots::TEMPO_BLOCK_NUMBER, - U256::from(ANCHOR), - )?; + TempoState::new().tempo_block_number.write(ANCHOR)?; ZoneOutbox::new().initialize()?; - let mut token_contract = - TIP20Token::from_address(token).expect("PATH_USD is a valid TIP20 address"); - token_contract.initialize( - ALICE, - "Zone USD", - "zUSD", - "USD", - Address::ZERO, - ALICE, - )?; - token_contract.grant_role_internal(ALICE, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.mint( - ALICE, - ITIP20::mintCall { - to: ALICE, - amount: U256::from(1_000_000u64), - }, - )?; - token_contract.approve( - ALICE, - ITIP20::approveCall { - spender: ZONE_OUTBOX_ADDRESS, - amount: U256::MAX, - }, - )?; + TIP20Setup::path_usd(ALICE) + .with_issuer(ALICE) + .with_issuer(ZONE_OUTBOX_ADDRESS) + .with_mint(ALICE, U256::from(1_000_000u64)) + .with_approval(ALICE, ZONE_OUTBOX_ADDRESS, U256::MAX) + .apply()?; Ok(()) })?; } @@ -228,7 +204,7 @@ fn request_withdrawal_rejects_disabled_token() -> eyre::Result<()> { let portal = harness.l1.portal_address(); harness.l1.set_u256( portal, - U256::from_be_bytes(portal_token_config_slot(harness.token).0), + portal_token_config_slot(harness.token).into(), ANCHOR, U256::ZERO, ); diff --git a/crates/precompiles/src/test_utils.rs b/crates/precompiles/src/test_utils.rs index a6daceaf3..c5166c2a7 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -283,7 +283,7 @@ impl L1StorageReader for MockL1Reader { return Ok(value); } - let key = U256::from_be_bytes(slot.0); + let key = slot.into(); let value = self .registry_storage .lock() @@ -293,7 +293,7 @@ impl L1StorageReader for MockL1Reader { if value.is_zero() { Ok(self.fallback) } else { - Ok(B256::from(value.to_be_bytes())) + Ok(value.into()) } } } diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 2cc740425..50fce0fc9 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -173,14 +173,18 @@ mod tests { use tempo_contracts::precompiles::TIP20Error; use tempo_precompiles::{ PATH_USD_ADDRESS, - storage::StorageCtx, + storage::{Handler, StorageCtx}, + test_util::TIP20Setup, tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, RolesAuthError, TIP20Token}, }; use tempo_zone_contracts::Unauthorized; - use crate::test_utils::{ - MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, - test_storage_provider, + use crate::{ + TempoState, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, + test_storage_provider, + }, }; #[derive(Clone, Copy)] @@ -222,46 +226,16 @@ mod tests { { let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || -> eyre::Result<()> { - StorageCtx::default().sstore( - zone_primitives::constants::TEMPO_STATE_ADDRESS, - crate::tempo_state::slots::TEMPO_BLOCK_NUMBER, - U256::from(7u64), - )?; - let mut token_contract = - TIP20Token::from_address(token).expect("PATH_USD must be valid"); - token_contract.initialize( - admin, - "Zone USD", - "zUSD", - "USD", - Address::ZERO, - admin, - )?; - token_contract.grant_role_internal(admin, *ISSUER_ROLE)?; - token_contract.grant_role_internal(issuer, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_INBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.grant_role_internal(ZONE_OUTBOX_ADDRESS, *ISSUER_ROLE)?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: alice, - amount: U256::from(1_000_000u64), - }, - )?; - token_contract.mint( - admin, - ITIP20::mintCall { - to: ZONE_OUTBOX_ADDRESS, - amount: U256::from(10_000u64), - }, - )?; - token_contract.approve( - alice, - ITIP20::approveCall { - spender, - amount: U256::from(300_000u64), - }, - )?; + TempoState::new().tempo_block_number.write(7)?; + TIP20Setup::path_usd(admin) + .with_issuer(admin) + .with_issuer(issuer) + .with_issuer(ZONE_INBOX_ADDRESS) + .with_issuer(ZONE_OUTBOX_ADDRESS) + .with_mint(alice, U256::from(1_000_000u64)) + .with_mint(ZONE_OUTBOX_ADDRESS, U256::from(10_000u64)) + .with_approval(alice, spender, U256::from(300_000u64)) + .apply()?; Ok(()) })?; } From c296e4a148d38ea18135f5b27c4a60b78113faa1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 10:24:19 +0200 Subject: [PATCH 29/30] fix: merge conflicts --- Cargo.lock | 1 - crates/evm/Cargo.toml | 1 - crates/evm/src/lib.rs | 1 - crates/node/tests/it/e2e.rs | 24 +++++++++--------- crates/node/tests/it/restart_e2e.rs | 6 ++--- crates/node/tests/it/tip403_transfers.rs | 4 +-- crates/node/tests/it/utils.rs | 4 +-- crates/precompiles/src/outbox/tests.rs | 32 ++++++++++++++++++++++++ 8 files changed, 51 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 599e1d450..6ce544fdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13409,7 +13409,6 @@ dependencies = [ "tempo-precompiles", "tempo-primitives", "tempo-revm", - "tempo-zone-contracts", "tokio", "zone-chainspec", "zone-l1", diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index 1058bb7be..15316a5fd 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -12,7 +12,6 @@ workspace = true [dependencies] # zones -tempo-zone-contracts = { workspace = true, features = ["std", "serde", "rpc"] } zone-chainspec.workspace = true zone-l1.workspace = true zone-primitives.workspace = true diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index b2d21241a..c22199999 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -45,7 +45,6 @@ use tempo_precompiles::{storage::actions::StorageActions, storage_credits::NonCr use tempo_primitives::{ Block, TempoHeader, TempoPrimitives, TempoReceipt, TempoTxEnvelope, TempoTxType, }; -use tempo_zone_contracts::ZONE_TX_CONTEXT_ADDRESS; use zone_chainspec::ZoneChainSpec; use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; diff --git a/crates/node/tests/it/e2e.rs b/crates/node/tests/it/e2e.rs index 9d796347a..a9a2909e8 100644 --- a/crates/node/tests/it/e2e.rs +++ b/crates/node/tests/it/e2e.rs @@ -14,8 +14,8 @@ use alloy_sol_types::SolCall; use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; use tempo_precompiles::PATH_USD_ADDRESS; use tempo_zone_contracts::{ - TEMPO_STATE_ADDRESS, TempoState, Withdrawal, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, - ZoneInbox, ZoneOutbox, + IZoneOutbox, TEMPO_STATE_ADDRESS, TempoState, Withdrawal, ZONE_INBOX_ADDRESS, + ZONE_OUTBOX_ADDRESS, ZoneInbox, }; use zone_l1::ChainTempoStateExt; @@ -417,7 +417,7 @@ async fn test_withdrawal_batch_finalization() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - let zone_outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); + let zone_outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); let initial_batch_index = zone_outbox.withdrawalBatchIndex().call().await?; // Local test nodes finalize empty batches every eight zone blocks. @@ -538,7 +538,7 @@ async fn submit_withdrawal( dev_address: Address, amount: u128, ) -> eyre::Result { - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); let pending = outbox .requestWithdrawal( PATH_USD_ADDRESS, @@ -573,7 +573,7 @@ async fn test_withdrawal_requests_finalize_next_block() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); let deposit_amount: u128 = 2_000_000; let deposit = fixture.make_deposit(PATH_USD_ADDRESS, dev_address, dev_address, deposit_amount); @@ -686,7 +686,7 @@ async fn test_withdrawal_requests_finalize_next_block() -> eyre::Result<()> { .await? .ok_or_else(|| eyre::eyre!("finalizeWithdrawalBatch tx {tx_hash} not found"))?; let finalize_call = - ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; + IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; assert_eq!( finalize_call.count, U256::from(1), @@ -708,7 +708,7 @@ async fn test_consecutive_withdrawal_blocks_joined_into_one_batch() -> eyre::Res let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); let deposit = fixture.make_deposit(PATH_USD_ADDRESS, dev_address, dev_address, 2_000_000); fixture.inject_deposits(zone.deposit_queue(), vec![deposit]); @@ -805,7 +805,7 @@ async fn test_consecutive_withdrawal_blocks_joined_into_one_batch() -> eyre::Res .await? .ok_or_else(|| eyre::eyre!("finalizeWithdrawalBatch tx {tx_hash} not found"))?; let finalize_call = - ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; + IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; assert_eq!( finalize_call.count, U256::from(2), @@ -823,7 +823,7 @@ async fn test_current_only_block_finalizes_at_batch_boundary() -> eyre::Result<( let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); // Local test nodes finalize empty batches every eight zone blocks. const BATCH_INTERVAL_BLOCKS: u64 = 8; @@ -953,7 +953,7 @@ async fn test_current_only_block_finalizes_at_batch_boundary() -> eyre::Result<( .await? .ok_or_else(|| eyre::eyre!("finalizeWithdrawalBatch tx {tx_hash} not found"))?; let finalize_call = - ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; + IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; assert_eq!(finalize_call.count, U256::from(1)); Ok(()) @@ -962,7 +962,7 @@ async fn test_current_only_block_finalizes_at_batch_boundary() -> eyre::Result<( /// Submit a signed L2 withdrawal request with an over-cap callback gas limit. /// /// This exercises the RPC transaction path: the transaction is accepted into a -/// zone block, reverts in `ZoneOutbox`, and does not enter the pending +/// zone block, reverts in `IZoneOutbox`, and does not enter the pending /// withdrawal queue. #[tokio::test(flavor = "multi_thread")] async fn test_withdrawal_request_rejects_over_max_callback_gas() -> eyre::Result<()> { @@ -971,7 +971,7 @@ async fn test_withdrawal_request_rejects_over_max_callback_gas() -> eyre::Result let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); let deposit_amount: u128 = 1_000_000; let deposit = fixture.make_deposit(PATH_USD_ADDRESS, dev_address, dev_address, deposit_amount); diff --git a/crates/node/tests/it/restart_e2e.rs b/crates/node/tests/it/restart_e2e.rs index caf5f9c0a..59aaef617 100644 --- a/crates/node/tests/it/restart_e2e.rs +++ b/crates/node/tests/it/restart_e2e.rs @@ -9,7 +9,7 @@ use crate::utils::{L1TestNode, ZoneAccount, ZoneTestNode, spawn_sequencer}; use alloy::primitives::{Address, U256}; -use tempo_zone_contracts::{ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZoneOutbox, ZonePortal}; +use tempo_zone_contracts::{IZoneOutbox, ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZonePortal}; /// Longer timeout for real L1 tests. const L1_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -46,7 +46,7 @@ async fn wait_for_withdrawal_requested( sender: Address, amount: u128, ) -> eyre::Result { - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); let expected_amount = U256::from(amount); crate::utils::poll_until( @@ -459,7 +459,7 @@ async fn test_deferred_withdrawal_survives_sequencer_restart() -> eyre::Result<( let withdrawal_block = wait_for_withdrawal_requested(&zone, account.address(), withdrawal_amount).await?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); let same_block_finalized = outbox .BatchFinalized_filter() .from_block(withdrawal_block) diff --git a/crates/node/tests/it/tip403_transfers.rs b/crates/node/tests/it/tip403_transfers.rs index 4f226014b..dc7dbf330 100644 --- a/crates/node/tests/it/tip403_transfers.rs +++ b/crates/node/tests/it/tip403_transfers.rs @@ -16,7 +16,7 @@ use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; use tempo_contracts::precompiles::ITIP20; use tempo_node::rpc::NATIVE_BALANCE_PLACEHOLDER; use tempo_precompiles::PATH_USD_ADDRESS; -use tempo_zone_contracts::{ZONE_OUTBOX_ADDRESS, ZoneOutbox}; +use tempo_zone_contracts::{IZoneOutbox, ZONE_OUTBOX_ADDRESS}; use crate::utils::{ DEFAULT_TIMEOUT, TEST_MNEMONIC, TIP20_TX_GAS, WITHDRAWAL_TX_GAS, approve_outbox, @@ -120,7 +120,7 @@ async fn test_deposit_then_request_withdrawal() -> eyre::Result<()> { let withdrawal_amount: u128 = 250_000; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); let withdrawal_fee = outbox.calculateWithdrawalFee(0).call().await?; let gas_buffer = u128::from(TIP20_TX_GAS) diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 78cc451c9..cc58bcfb3 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -2534,7 +2534,7 @@ impl ZoneAccount { args: WithdrawalArgs, ) -> eyre::Result<()> { use tempo_contracts::precompiles::ITIP20; - use tempo_zone_contracts::{ZONE_OUTBOX_ADDRESS, ZoneOutbox}; + use tempo_zone_contracts::{IZoneOutbox, ZONE_OUTBOX_ADDRESS}; // Approve outbox for this token ITIP20::new(token, &self.l2_provider) @@ -2548,7 +2548,7 @@ impl ZoneAccount { let to = args.to.unwrap_or(self.address); let fallback_recipient = args.fallback_recipient.unwrap_or(self.address); - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &self.l2_provider); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &self.l2_provider); let receipt = outbox .requestWithdrawal( token, diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs index be90788c8..b2e8955e1 100644 --- a/crates/precompiles/src/outbox/tests.rs +++ b/crates/precompiles/src/outbox/tests.rs @@ -117,6 +117,16 @@ impl Harness { Ok(ZoneOutboxAbi::getPendingWithdrawalsCall::abi_decode_returns(&output.bytes)?) } + fn last_fallback_nonce(&mut self) -> eyre::Result { + let output = self.call( + Address::ZERO, + ZoneOutboxAbi::lastFallbackNonceCall {}.abi_encode(), + )?; + Ok(ZoneOutboxAbi::lastFallbackNonceCall::abi_decode_returns( + &output.bytes, + )?) + } + fn request(&mut self, amount: u128, to: Address, memo: B256) -> PrecompileResult { self.request_custom(ZoneOutboxAbi::requestWithdrawalCall { token: self.token, @@ -254,6 +264,12 @@ fn enqueue_bounce_back_is_inbox_only() -> eyre::Result<()> { assert_eq!(pending.len(), 1); assert_eq!(pending[0].sender, Address::ZERO); assert_eq!(pending[0].fee, 0); + assert_eq!(pending[0].fallbackNonce, 0); + assert_eq!(harness.last_fallback_nonce()?, 0); + + harness.request(1, BOB, B256::ZERO)?; + assert_eq!(harness.pending()?[1].fallbackNonce, 1); + assert_eq!(harness.last_fallback_nonce()?, 1); Ok(()) } @@ -623,6 +639,22 @@ fn fallback_recipient_nonce_is_private_and_consumed_once_by_inbox() -> eyre::Res harness.call(ALICE, &calldata), ZoneOutboxError::only_zone_inbox(), ); + assert_revert( + harness.call( + ZONE_INBOX_ADDRESS, + ZoneOutboxAbi::consumeFallbackRecipientCall { + fallbackNonce: nonce + 1, + } + .abi_encode(), + ), + ZoneOutboxError::invalid_fallback_recipient(), + ); + assert_revert( + harness.call_static(ZONE_INBOX_ADDRESS, &calldata), + ZoneOutboxError::static_call_not_allowed(), + ); + + // The failed static call must not consume the mapping entry. let output = harness.call(ZONE_INBOX_ADDRESS, &calldata)?; assert_eq!( ZoneOutboxAbi::consumeFallbackRecipientCall::abi_decode_returns(&output.bytes)?, From 12244ddb29ceb81d55c1b5821c08b728671c2c64 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 10:53:43 +0200 Subject: [PATCH 30/30] chore: use ZonePortalError --- crates/contracts/src/precompiles/outbox.rs | 2 - .../contracts/src/precompiles/zone_portal.rs | 5 +- crates/precompiles/src/error.rs | 6 ++- crates/precompiles/src/outbox/mod.rs | 48 +++++++++---------- crates/precompiles/src/outbox/tests.rs | 6 +-- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/crates/contracts/src/precompiles/outbox.rs b/crates/contracts/src/precompiles/outbox.rs index 929999836..bdad50348 100644 --- a/crates/contracts/src/precompiles/outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -8,7 +8,6 @@ pub use IZoneOutbox::{ crate::sol! { #[derive(Debug, PartialEq, Eq)] contract IZoneOutbox { - struct LastBatch { bytes32 withdrawalQueueHash; uint64 withdrawalBatchIndex; @@ -64,7 +63,6 @@ crate::sol! { error TooManyWithdrawalsThisBlock(); error InvalidRevealTo(); error InvalidCurrentTxHash(); - error TokenNotEnabled(); error StaticCallNotAllowed(); // -- View functions -- diff --git a/crates/contracts/src/precompiles/zone_portal.rs b/crates/contracts/src/precompiles/zone_portal.rs index 2486e7c52..243dedc53 100644 --- a/crates/contracts/src/precompiles/zone_portal.rs +++ b/crates/contracts/src/precompiles/zone_portal.rs @@ -2,6 +2,7 @@ pub use ZonePortal::{ BlockTransition, DepositQueueTransition, EncryptedDeposit, EncryptedDepositPayload, Withdrawal, + ZonePortalErrors as ZonePortalError, }; use crate::IZoneOutbox; @@ -10,7 +11,7 @@ use alloy_sol_types::SolValue; use zone_primitives::constants::{EMPTY_SENTINEL, PORTAL_TOKEN_CONFIGS_SLOT}; crate::sol! { - #[derive(Debug)] + #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] contract ZonePortal { // -- Shared types -- @@ -168,6 +169,7 @@ crate::sol! { error InvalidTempoBlockNumber(); error PolicyForbids(); error InvalidBouncebackRecipient(); + error TokenNotEnabled(); // -- View functions -- @@ -344,6 +346,7 @@ impl core::fmt::Display for ZonePortal::ZonePortalErrors { Self::InvalidTempoBlockNumber(_) => f.write_str("InvalidTempoBlockNumber"), Self::PolicyForbids(_) => f.write_str("PolicyForbids"), Self::InvalidBouncebackRecipient(_) => f.write_str("InvalidBouncebackRecipient"), + Self::TokenNotEnabled(_) => f.write_str("TokenNotEnabled"), } } } diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 56b201926..2293e4e25 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -3,7 +3,7 @@ use alloy_sol_types::{SolError, SolInterface}; use revm::precompile::{PrecompileOutput, PrecompileResult}; use tempo_precompiles::IntoPrecompileResult; -use tempo_zone_contracts::ZoneOutboxError; +use tempo_zone_contracts::{ZoneOutboxError, ZonePortalError}; use crate::{tip20_factory::ZoneTokenFactoryError, tip403_proxy::ReadOnlyRegistry}; @@ -23,6 +23,9 @@ pub enum ZonePrecompileError { /// An error originating in the upstream Tempo precompiles crate. #[error(transparent)] Tempo(TempoPrecompileError), + /// Error from the ZonePortal. + #[error("ZonePortal error: {0:?}")] + Portal(ZonePortalError), /// Error from the ZoneOutbox. #[error("ZoneOutbox error: {0:?}")] Outbox(ZoneOutboxError), @@ -38,6 +41,7 @@ impl IntoPrecompileResult for ZonePrecompileError { fn into_precompile_result(self, gas: u64, reservoir: u64) -> PrecompileResult { let data = match self { Self::Tempo(error) => return error.into_precompile_result(gas, reservoir), + Self::Portal(error) => error.abi_encode(), Self::Outbox(error) => error.abi_encode(), Self::ZoneTokenFactory(error) => error.abi_encode(), Self::Zone403Registry(error) => error.abi_encode(), diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs index 3a284f219..0939f81ec 100644 --- a/crates/precompiles/src/outbox/mod.rs +++ b/crates/precompiles/src/outbox/mod.rs @@ -17,7 +17,7 @@ use tempo_precompiles::{ }; use tempo_precompiles_macros::{Storable, contract}; use tempo_zone_contracts::{ - ILegacyZoneOutbox, IZoneOutbox as ZoneOutboxAbi, Withdrawal, ZoneOutboxError, ZoneOutboxEvent, + ILegacyZoneOutbox, IZoneOutbox, Withdrawal, ZoneOutboxError, ZoneOutboxEvent, ZonePortalError, portal_token_config_slot, }; use zone_primitives::constants::{ @@ -35,12 +35,12 @@ const MAX_GAS_FEE_RATE: u128 = 1_000_000_000_000_000_000; const WITHDRAWAL_BASE_GAS: u64 = 50_000; const SEQUENCER_SELECTORS: &[[u8; 4]] = &[ - ZoneOutboxAbi::setTempoGasRateCall::SELECTOR, - ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall::SELECTOR, - ZoneOutboxAbi::finalizeWithdrawalBatchCall::SELECTOR, + IZoneOutbox::setTempoGasRateCall::SELECTOR, + IZoneOutbox::setMaxWithdrawalsPerBlockCall::SELECTOR, + IZoneOutbox::finalizeWithdrawalBatchCall::SELECTOR, ]; const WITHDRAWAL_SELECTORS: &[[u8; 4]] = &[ - ZoneOutboxAbi::requestWithdrawalCall::SELECTOR, + IZoneOutbox::requestWithdrawalCall::SELECTOR, ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR, ]; @@ -68,8 +68,8 @@ impl CallRules for ZoneOutboxRules { self.requires_l1(Some(selector)) || matches!( selector, - ZoneOutboxAbi::enqueueDepositBounceBackCall::SELECTOR - | ZoneOutboxAbi::consumeFallbackRecipientCall::SELECTOR + IZoneOutbox::enqueueDepositBounceBackCall::SELECTOR + | IZoneOutbox::consumeFallbackRecipientCall::SELECTOR ) }) { @@ -104,7 +104,7 @@ impl CallRules for ZoneOutboxRules { let slot = portal_token_config_slot(withdrawal.token).into(); match StorageCtx::default().sload(self.portal, slot) { Ok(value) if value.byte(0) != 0 => {} - Ok(_) => return CallCheck::from_error(ZoneOutboxError::token_not_enabled()), + Ok(_) => return CallCheck::from_error(ZonePortalError::token_not_enabled()), Err(err) => return CallCheck::from_error(err), } } @@ -122,9 +122,9 @@ pub struct ZoneOutbox { withdrawals_this_block: u32, current_block_number: u64, last_finalized_timestamp: u64, + pending_withdrawals: Vec, last_fallback_nonce: u64, fallback_recipients: Mapping, - pending_withdrawals: Vec, } impl ZoneOutbox { @@ -185,7 +185,7 @@ impl ZoneOutbox { &mut self, caller: Address, current_tx_hash: B256, - call: ZoneOutboxAbi::requestWithdrawalCall, + call: IZoneOutbox::requestWithdrawalCall, ) -> ZoneResult<()> { if current_tx_hash.is_zero() { return Err(ZoneOutboxError::invalid_current_tx_hash().into()); @@ -236,7 +236,7 @@ impl ZoneOutbox { fn enqueue_deposit_bounce_back( &mut self, caller: Address, - call: ZoneOutboxAbi::enqueueDepositBounceBackCall, + call: IZoneOutbox::enqueueDepositBounceBackCall, ) -> ZoneResult<()> { if caller != ZONE_INBOX_ADDRESS { return Err(ZoneOutboxError::only_zone_inbox().into()); @@ -259,7 +259,7 @@ impl ZoneOutbox { fn finalize_withdrawal_batch( &mut self, - call: ZoneOutboxAbi::finalizeWithdrawalBatchCall, + call: IZoneOutbox::finalizeWithdrawalBatchCall, ) -> ZoneResult { if call.blockNumber != self.storage.block_number() { return Err(ZoneOutboxError::invalid_block_number().into()); @@ -306,7 +306,7 @@ impl ZoneOutbox { Ok(withdrawal_queue_hash) } - fn set_tempo_gas_rate(&mut self, call: ZoneOutboxAbi::setTempoGasRateCall) -> ZoneResult<()> { + fn set_tempo_gas_rate(&mut self, call: IZoneOutbox::setTempoGasRateCall) -> ZoneResult<()> { if call._tempoGasRate > MAX_GAS_FEE_RATE { return Err(ZoneOutboxError::gas_fee_rate_too_high().into()); } @@ -317,7 +317,7 @@ impl ZoneOutbox { fn set_max_withdrawals_per_block( &mut self, - call: ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall, + call: IZoneOutbox::setMaxWithdrawalsPerBlockCall, ) -> TempoResult<()> { self.max_withdrawals_per_block .write(call._maxWithdrawalsPerBlock)?; @@ -331,7 +331,7 @@ impl ZoneOutbox { self.pending_withdrawals.len().map(|val| U256::from(val)) } - fn get_pending_withdrawals(&self) -> TempoResult> { + fn get_pending_withdrawals(&self) -> TempoResult> { let len = self.pending_withdrawals.len()?; let mut pending = Vec::with_capacity(len); for index in 0..len { @@ -340,8 +340,8 @@ impl ZoneOutbox { Ok(pending) } - fn last_batch(&self) -> TempoResult { - Ok(ZoneOutboxAbi::LastBatch { + fn last_batch(&self) -> TempoResult { + Ok(IZoneOutbox::LastBatch { withdrawalQueueHash: self.withdrawal_queue_hash.read()?, withdrawalBatchIndex: self.withdrawal_batch_index.read()?, }) @@ -369,7 +369,7 @@ impl PendingWithdrawal { tx_hash: B256, fee: u128, fallback_nonce: u64, - call: ZoneOutboxAbi::requestWithdrawalCall, + call: IZoneOutbox::requestWithdrawalCall, ) -> Self { Self { token: call.token, @@ -386,7 +386,7 @@ impl PendingWithdrawal { } } - fn from_bounce_back(call: ZoneOutboxAbi::enqueueDepositBounceBackCall) -> Self { + fn from_bounce_back(call: IZoneOutbox::enqueueDepositBounceBackCall) -> Self { Self { token: call.token, to: call.bouncebackRecipient, @@ -441,7 +441,7 @@ impl PendingWithdrawal { } } -impl From for ZoneOutboxAbi::PendingWithdrawal { +impl From for IZoneOutbox::PendingWithdrawal { fn from(pending: PendingWithdrawal) -> Self { Self { token: pending.token, @@ -459,10 +459,10 @@ impl From for ZoneOutboxAbi::PendingWithdrawal { } } -fn decode_withdrawal(call: ZoneCall<'_>) -> Option { +fn decode_withdrawal(call: ZoneCall<'_>) -> Option { match call.selector()? { - ZoneOutboxAbi::requestWithdrawalCall::SELECTOR => { - ZoneOutboxAbi::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]).ok() + IZoneOutbox::requestWithdrawalCall::SELECTOR => { + IZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]).ok() } ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) @@ -480,7 +480,7 @@ fn validate_gas_limit(gas_limit: u64) -> ZoneResult<()> { Ok(()) } -fn check_withdrawal_request(call: &ZoneOutboxAbi::requestWithdrawalCall) -> ZoneResult<()> { +fn check_withdrawal_request(call: &IZoneOutbox::requestWithdrawalCall) -> ZoneResult<()> { if call.fallbackRecipient.is_zero() { return Err(ZoneOutboxError::invalid_fallback_recipient().into()); } diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs index b2e8955e1..914e018a6 100644 --- a/crates/precompiles/src/outbox/tests.rs +++ b/crates/precompiles/src/outbox/tests.rs @@ -5,7 +5,7 @@ use alloy_primitives::{Bytes, address}; use alloy_sol_types::{SolCall, SolInterface}; use revm::precompile::PrecompileResult; use tempo_precompiles::{Precompile as _, storage::FromWord, test_util::TIP20Setup}; -use tempo_zone_contracts::portal_token_config_slot; +use tempo_zone_contracts::{IZoneOutbox as ZoneOutboxAbi, portal_token_config_slot}; use crate::{ L1StorageReader, TempoState, execution, @@ -185,7 +185,7 @@ impl Harness { } } -fn assert_revert(result: PrecompileResult, error: ZoneOutboxError) { +fn assert_revert(result: PrecompileResult, error: impl SolInterface) { let output = result.expect("precompile error"); assert!(output.is_revert()); assert_eq!(output.bytes, error.abi_encode()); @@ -219,7 +219,7 @@ fn request_withdrawal_rejects_disabled_token() -> eyre::Result<()> { U256::ZERO, ); let result = harness.request(1, BOB, B256::ZERO); - assert_revert(result, ZoneOutboxError::token_not_enabled()); + assert_revert(result, ZonePortalError::token_not_enabled()); Ok(()) }