From a09380cd03176b3fa11e270df9c19f10b9fd5985 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 13 Jul 2026 18:49:11 +0200 Subject: [PATCH 01/22] feat(l1): make raw state cache hardfork and mutation aware --- Cargo.lock | 2 + crates/l1/Cargo.toml | 2 + crates/l1/src/state/cache.rs | 228 ++++++++++++++++++++++++++++++-- crates/l1/src/state/provider.rs | 109 ++++++++++++++- crates/l1/src/subscriber.rs | 78 ++++++++--- crates/l1/src/tests.rs | 33 ++++- 6 files changed, 421 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa722d697..11976602a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13290,6 +13290,7 @@ dependencies = [ "k256", "metrics", "parking_lot", + "reth-chainspec", "reth-metrics", "reth-primitives-traits", "reth-provider", @@ -13300,6 +13301,7 @@ dependencies = [ "serde", "serde_json", "tempo-alloy", + "tempo-chainspec", "tempo-contracts", "tempo-precompiles", "tempo-primitives", diff --git a/crates/l1/Cargo.toml b/crates/l1/Cargo.toml index 069557690..cc73ad3bf 100644 --- a/crates/l1/Cargo.toml +++ b/crates/l1/Cargo.toml @@ -19,6 +19,7 @@ zone-primitives = { workspace = true, features = ["std", "serde"] } # tempo tempo-alloy.workspace = true tempo-contracts.workspace = true +tempo-chainspec = { workspace = true, features = ["reth"] } tempo-precompiles.workspace = true tempo-primitives.workspace = true tempo-revm.workspace = true @@ -28,6 +29,7 @@ tempo-transaction-pool.workspace = true reth-metrics.workspace = true reth-primitives-traits.workspace = true reth-provider.workspace = true +reth-chainspec.workspace = true reth-storage-api.workspace = true reth-tasks.workspace = true reth-transaction-pool.workspace = true diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index bf62cf3e8..f22155c1b 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -28,9 +28,10 @@ use alloy_primitives::{Address, B256}; use derive_more::Deref; use parking_lot::RwLock; use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, sync::Arc, }; +use tempo_chainspec::hardfork::TempoHardfork; /// Thread-safe L1 state cache backed by an `Arc>`. #[derive(Debug, Clone, Deref, Default)] @@ -55,6 +56,12 @@ impl L1StateCache { /// i.e. the value that was current at that height. This allows the zone to read L1 state at /// the `tempoBlockNumber` it committed to, even if the L1 chain has since advanced. /// +/// To enable delegation to upstream Tempo precompiles, the cache also tracks contract mutation +/// barriers and hardfork activations. Barriers prevent values from crossing blocks where logs +/// signal a possible storage change without requiring slot-level decoding, while hardfork metadata +/// selects the Tempo rules active at the same anchor. Reorgs clear all three atomically: slot +/// values, mutation history, and protocol-version metadata. +/// /// The anchor tracks the latest L1 block the cache has received data for, used by the /// [`L1Subscriber`](crate::l1::L1Subscriber) for reorg detection. #[derive(Debug, Default)] @@ -63,6 +70,14 @@ pub struct L1StateCacheInner { /// Per-slot value history: `(address, slot) → { block_number → value }`. /// The `BTreeMap` enables efficient range lookups for "latest value at or before block N". slots: HashMap<(Address, B256), BTreeMap>, + /// Per-address mutation barriers: `address → { block_number }`. + /// A slot value cached at block V may serve block N only when no barrier exists in `(V, N]`. + /// The subscriber records barriers for contracts whose logs imply possible storage changes. + invalidations: HashMap>, + /// First canonical L1 block where each observed Tempo hardfork is active. + hardfork_schedule: BTreeSet<(u64, TempoHardfork)>, + /// Highest block covered by the cached activation schedule. + hardfork_schedule_head: Option, /// Latest L1 block the cache has received data for, used for reorg detection. anchor: NumHash, } @@ -81,9 +96,30 @@ impl L1StateCacheInner { /// Returns the most recent value at or before `block_number`, or `None` if no /// value has been cached for this slot at or before the requested block. pub fn get(&self, address: Address, slot: B256, block_number: u64) -> Option { - self.slots - .get(&(address, slot)) - .and_then(|history| history.range(..=block_number).next_back().map(|(_, v)| *v)) + let (value_block, value) = self + .slots + .get(&(address, slot))? + .range(..=block_number) + .next_back()?; + let latest_invalidation = self + .invalidations + .get(&address) + .and_then(|blocks| blocks.range(..=block_number).next_back()) + .copied(); + if latest_invalidation.is_some_and(|invalidated_at| invalidated_at > *value_block) { + return None; + } + Some(*value) + } + + /// Invalidates inherited slot values for `address` starting at `block_number`. + /// + /// Values subsequently inserted at the same block are post-block state and remain valid. + pub fn invalidate(&mut self, address: Address, block_number: u64) { + self.invalidations + .entry(address) + .or_default() + .insert(block_number); } /// Sets a storage slot value in the cache at the given block number. @@ -94,6 +130,63 @@ impl L1StateCacheInner { .insert(block_number, value); } + /// Returns the active Tempo hardfork when the cached schedule covers `block_number`. + pub fn hardfork_at(&self, block_number: u64) -> Option { + if self.hardfork_schedule_head? < block_number { + return None; + } + self.hardfork_schedule + .range(..=(block_number, TempoHardfork::T9)) + .next_back() + .map(|(_, hardfork)| *hardfork) + } + + /// Extends the known Tempo activation schedule through `block_number`. + pub fn extend_hardfork_schedule( + &mut self, + block_number: u64, + activations: impl IntoIterator, + ) { + self.hardfork_schedule.extend(activations); + self.hardfork_schedule_head = Some( + self.hardfork_schedule_head + .unwrap_or_default() + .max(block_number), + ); + } + + /// Advance an initialized schedule from a contiguous confirmed L1 block. + /// + /// A single observation cannot initialize historical activation boundaries. Until a full + /// schedule has been resolved, observations are ignored and the provider remains responsible + /// for initialization through canonical constants/RPC. + pub fn observe_hardfork(&mut self, block_number: u64, hardfork: TempoHardfork) { + let Some(head) = self.hardfork_schedule_head else { + return; + }; + if block_number != head.saturating_add(1) { + return; + } + + let previous = self + .hardfork_schedule + .range(..=(head, TempoHardfork::T9)) + .next_back() + .map(|(_, hardfork)| *hardfork); + let Some(previous) = previous else { + return; + }; + if hardfork < previous { + // Tempo hardforks never downgrade. Leave the head unchanged so the provider resolves + // this block instead of extending the cache from stale/incorrect chain metadata. + return; + } + if hardfork > previous { + self.hardfork_schedule.insert((block_number, hardfork)); + } + self.hardfork_schedule_head = Some(block_number); + } + /// Updates the anchor block that this cache has received data up to. pub fn update_anchor(&mut self, anchor: NumHash) { self.anchor = anchor; @@ -112,6 +205,9 @@ impl L1StateCacheInner { /// Clears all cached slot values but retains the tracked-contract set. pub fn clear(&mut self) { self.slots.clear(); + self.invalidations.clear(); + self.hardfork_schedule.clear(); + self.hardfork_schedule_head = None; self.anchor = NumHash::default(); } @@ -132,6 +228,24 @@ impl L1StateCacheInner { } self.slots.retain(|_, history| !history.is_empty()); + + // Retain the latest pre-boundary invalidation so a pruned stale value cannot become valid. + for blocks in self.invalidations.values_mut() { + let baseline = blocks.range(..=min_block).next_back().copied(); + blocks.retain(|block| *block >= min_block || Some(*block) == baseline); + } + self.invalidations.retain(|_, blocks| !blocks.is_empty()); + + // Keep the latest activation before the pruning boundary as the baseline, plus all newer + // activations. This preserves hardfork lookup for every retained block. + let baseline = self + .hardfork_schedule + .range(..=(min_block, TempoHardfork::T9)) + .next_back() + .copied(); + self.hardfork_schedule.retain(|(block, hardfork)| { + *block >= min_block || Some((*block, *hardfork)) == baseline + }); } } @@ -194,10 +308,11 @@ mod tests { } #[test] - fn clear_removes_slots_and_resets_anchor() { + fn clear_removes_slots_invalidations_and_resets_anchor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1)); + cache.invalidate(PORTAL, 101); cache.update_anchor(NumHash { number: 100, hash: B256::with_last_byte(0xab), @@ -206,15 +321,101 @@ mod tests { cache.clear(); assert_eq!(cache.get(PORTAL, B256::ZERO, 100), None); + assert!(cache.invalidations.is_empty()); assert_eq!(cache.anchor(), NumHash::default()); } + #[test] + fn invalidation_blocks_inheritance_until_slot_is_refetched() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + let slot = B256::with_last_byte(1); + let old = B256::with_last_byte(0x0a); + let new = B256::with_last_byte(0x14); + let other = Address::with_last_byte(0x43); + + cache.set(PORTAL, slot, 10, old); + cache.set(other, slot, 10, old); + cache.invalidate(PORTAL, 20); + cache.invalidate(PORTAL, 20); // Multiple logs in one block deduplicate. + + assert_eq!(cache.get(PORTAL, slot, 19), Some(old)); + assert_eq!(cache.get(PORTAL, slot, 20), None); + assert_eq!(cache.get(PORTAL, slot, 30), None); + assert_eq!(cache.get(other, slot, 30), Some(old)); + assert_eq!(cache.invalidations[&PORTAL].len(), 1); + + cache.set(PORTAL, slot, 20, new); + assert_eq!(cache.get(PORTAL, slot, 20), Some(new)); + assert_eq!(cache.get(PORTAL, slot, 30), Some(new)); + + cache.invalidate(PORTAL, 31); + assert_eq!(cache.get(PORTAL, slot, 31), None); + } + #[test] fn anchor_defaults_to_zero() { let cache = L1StateCacheInner::new(HashSet::from([PORTAL])); assert_eq!(cache.anchor(), NumHash::default()); } + #[test] + fn hardfork_lookup_uses_bounded_activation_schedule() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + cache.extend_hardfork_schedule(20, [(10, TempoHardfork::T2), (20, TempoHardfork::T8)]); + + assert_eq!(cache.hardfork_at(9), None); + assert_eq!(cache.hardfork_at(10), Some(TempoHardfork::T2)); + assert_eq!(cache.hardfork_at(19), Some(TempoHardfork::T2)); + assert_eq!(cache.hardfork_at(20), Some(TempoHardfork::T8)); + assert_eq!(cache.hardfork_at(21), None); + } + + #[test] + fn confirmed_headers_advance_initialized_hardfork_schedule() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + cache.extend_hardfork_schedule(10, [(0, TempoHardfork::T0)]); + + cache.observe_hardfork(11, TempoHardfork::T0); + cache.observe_hardfork(12, TempoHardfork::T2); + + assert_eq!(cache.hardfork_at(11), Some(TempoHardfork::T0)); + assert_eq!(cache.hardfork_at(12), Some(TempoHardfork::T2)); + assert_eq!(cache.hardfork_at(13), None); + } + + #[test] + fn hardfork_observation_does_not_initialize_or_cross_a_gap() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + cache.observe_hardfork(10, TempoHardfork::T2); + assert_eq!(cache.hardfork_at(10), None); + + cache.extend_hardfork_schedule(10, [(0, TempoHardfork::T0)]); + cache.observe_hardfork(12, TempoHardfork::T2); + assert_eq!(cache.hardfork_at(11), None); + assert_eq!(cache.hardfork_at(12), None); + + cache.observe_hardfork(11, TempoHardfork::Genesis); + assert_eq!(cache.hardfork_at(11), None); + } + + #[test] + fn prune_keeps_hardfork_activation_baseline() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + cache.extend_hardfork_schedule( + 30, + [ + (0, TempoHardfork::T0), + (10, TempoHardfork::T2), + (20, TempoHardfork::T8), + ], + ); + + cache.prune_before(15); + + assert_eq!(cache.hardfork_at(15), Some(TempoHardfork::T2)); + assert_eq!(cache.hardfork_at(20), Some(TempoHardfork::T8)); + } + #[test] fn update_anchor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); @@ -255,13 +456,15 @@ mod tests { } #[test] - fn prune_keeps_baseline_entry() { + fn prune_keeps_baseline_entry_and_invalidation() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05)); cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a)); cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14)); + cache.invalidate(PORTAL, 12); + cache.invalidate(PORTAL, 18); cache.prune_before(15); @@ -270,13 +473,18 @@ mod tests { cache.get(PORTAL, slot, 10), Some(B256::with_last_byte(0x0a)) ); - assert_eq!( - cache.get(PORTAL, slot, 15), - Some(B256::with_last_byte(0x0a)) - ); + assert_eq!(cache.get(PORTAL, slot, 15), None); + assert_eq!(cache.get(PORTAL, slot, 19), None); assert_eq!( cache.get(PORTAL, slot, 20), Some(B256::with_last_byte(0x14)) ); + assert_eq!( + cache.invalidations[&PORTAL] + .iter() + .copied() + .collect::>(), + vec![12, 18] + ); } } diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index cb5a3fd4d..8e6889acb 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -10,13 +10,19 @@ //! intended for use inside EVM precompiles where async is unavailable — it retries the RPC //! call indefinitely with exponential backoff to avoid bricking the chain on transient outages. +use alloy_consensus::BlockHeader; use alloy_primitives::{Address, B256, U256}; use alloy_provider::{DynProvider, Provider, ProviderBuilder}; use alloy_rpc_client::RpcClient; -use alloy_rpc_types_eth::BlockId; +use alloy_rpc_types_eth::{BlockId, BlockNumberOrTag}; use alloy_transport::layers::RetryBackoffLayer; use eyre::Result; +use reth_chainspec::ForkCondition; use tempo_alloy::TempoNetwork; +use tempo_chainspec::{ + hardfork::TempoHardfork, + spec::{DEV, TempoHardforks, chainspec_from_chain_id}, +}; use tracing::{debug, info, warn}; use zone_precompiles::{L1StorageReader, SequencerExt}; @@ -258,11 +264,80 @@ impl L1StateProvider { Ok(value) } + /// Resolve the Tempo hardfork active at an exact L1 block, using cached metadata first. + pub fn get_hardfork(&self, block_number: u64) -> Result { + if let Some(hardfork) = self.cache.read().hardfork_at(block_number) { + return Ok(hardfork); + } + + let activations = tokio::task::block_in_place(|| { + self.runtime_handle + .block_on(self.fetch_hardfork_schedule(block_number)) + })?; + let mut cache = self.cache.write(); + cache.extend_hardfork_schedule(block_number, activations); + cache + .hardfork_at(block_number) + .ok_or_else(|| eyre::eyre!("no Tempo hardfork active at L1 block {block_number}")) + } + /// Expose the shared cache handle for external use (e.g. the engine). pub fn cache(&self) -> &L1StateCache { &self.cache } + async fn fetch_hardfork_schedule( + &self, + block_number: u64, + ) -> Result> { + let (chain_id, block) = tokio::try_join!( + self.provider.get_chain_id(), + self.provider + .get_block_by_number(BlockNumberOrTag::Number(block_number)), + )?; + let block = block.ok_or_else(|| eyre::eyre!("L1 block {block_number} not found"))?; + let chain_spec = chainspec_from_chain_id(chain_id).unwrap_or_else(|| DEV.clone()); + let block_ts = block.header.timestamp(); + let mut activations = Vec::new(); + + for &hardfork in TempoHardfork::VARIANTS { + let ForkCondition::Timestamp(fork_ts) = chain_spec.tempo_fork_activation(hardfork) + else { + continue; + }; + if fork_ts > block_ts { + continue; + } + let activation_block = if let Some(block) = known_activation_block(chain_id, hardfork) { + block + } else { + self.first_block_at_or_after(fork_ts, block_number).await? + }; + activations.push((activation_block, hardfork)); + } + + Ok(activations) + } + + async fn first_block_at_or_after(&self, timestamp: u64, high: u64) -> Result { + let mut low = 0u64; + let mut high = high; + while low < high { + let mid = low + (high - low) / 2; + let block = self + .provider + .get_block_by_number(BlockNumberOrTag::Number(mid)) + .await? + .ok_or_else(|| eyre::eyre!("L1 block {mid} not found"))?; + if block.header.timestamp() < timestamp { + low = mid + 1; + } else { + high = mid; + } + } + Ok(low) + } + /// Fetch a single storage slot from L1 at a specific block via the shared HTTP provider. async fn fetch_slot(&self, address: Address, slot: B256, block_number: u64) -> Result { let key = U256::from_be_bytes(slot.0); @@ -278,6 +353,14 @@ impl L1StateProvider { } } +fn known_activation_block(chain_id: u64, hardfork: TempoHardfork) -> Option { + match chain_id { + 4217 => hardfork.mainnet_activation_block(), + 42431 => hardfork.moderato_activation_block(), + _ => None, + } +} + impl L1StorageReader for L1StateProvider { fn read_l1_storage( &self, @@ -298,3 +381,27 @@ impl SequencerExt for L1StateProvider { self.get_latest_sequencer().ok() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_activation_blocks_come_from_tempo_hardfork_constants() { + assert_eq!( + known_activation_block(4217, TempoHardfork::T2), + TempoHardfork::T2.mainnet_activation_block() + ); + assert_eq!( + known_activation_block(42431, TempoHardfork::T2), + TempoHardfork::T2.moderato_activation_block() + ); + assert_eq!(known_activation_block(1337, TempoHardfork::T2), None); + } + + #[test] + fn unknown_future_activation_blocks_fall_back_to_rpc_resolution() { + assert_eq!(known_activation_block(4217, TempoHardfork::T8), None); + assert_eq!(known_activation_block(42431, TempoHardfork::T8), None); + } +} diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index d6cab03a1..bab0aab7c 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -1,4 +1,6 @@ use super::*; +use std::collections::HashSet; +use tempo_chainspec::hardfork::TempoHardfork; /// Poll interval for the HTTP block filter fallback (500ms, matching L1 block time). const HTTP_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); @@ -283,6 +285,7 @@ impl L1Subscriber { async fn sync_to_l1_tip( &mut self, l1_provider: &impl Provider, + chain_id: u64, ) -> eyre::Result<()> { let Some(mut from) = self.resolve_start_block(l1_provider).await? else { self.subscriber_metrics.current_l1_lag_blocks.set(0.0); @@ -318,7 +321,7 @@ impl L1Subscriber { "Backfilling deposit events" ); let start = std::time::Instant::now(); - let result = self.backfill(l1_provider, from, tip).await; + let result = self.backfill(l1_provider, chain_id, from, tip).await; self.subscriber_metrics .backfill_duration_seconds .record(start.elapsed().as_secs_f64()); @@ -338,6 +341,7 @@ impl L1Subscriber { async fn backfill( &mut self, l1_provider: &impl Provider, + chain_id: u64, from: u64, to: u64, ) -> eyre::Result<()> { @@ -398,11 +402,19 @@ impl L1Subscriber { while let Some((header, receipts)) = fetched.try_next().await? { let block_number = header.number(); - let (events, policy_events) = self.extract_events(block_number, &receipts); + let (events, policy_events, invalidated_accounts) = + self.extract_events(block_number, &receipts); self.record_seen_block(block_number, to.saturating_sub(block_number)); let sealed = SealedHeader::seal_slow(header); - self.update_l1_state_anchor(block_number, sealed.hash(), sealed.parent_hash()); + self.update_l1_state_anchor( + block_number, + sealed.hash(), + sealed.parent_hash(), + chain_id, + sealed.timestamp(), + &invalidated_accounts, + ); self.apply_policy_events(block_number, &policy_events); self.apply_portal_state_events(block_number, &events); self.deposit_queue @@ -454,10 +466,11 @@ impl L1Subscriber { self.tracked_tokens = self.config.policy_cache.read().tracked_tokens(); let provider = self.connect().await?; + let chain_id = provider.get_chain_id().await?; // Backfill to the current tip before subscribing. // Backfilled blocks are historical and considered confirmed. - self.sync_to_l1_tip(&provider).await?; + self.sync_to_l1_tip(&provider, chain_id).await?; info!(portal = %self.config.portal_address, "Listening for L1 blocks"); let mut stream = self.l1_block_stream(&provider).await?; @@ -466,10 +479,12 @@ impl L1Subscriber { // A block is only flushed to the deposit queue once the NEXT block // arrives with a matching parent hash, proving the buffered block // is on the canonical chain. + #[allow(clippy::type_complexity)] let mut unconfirmed_tip: Option<( SealedHeader, L1PortalEvents, Vec, + HashSet
, )> = None; loop { @@ -483,18 +498,27 @@ impl L1Subscriber { }; let block_number = header.number(); let sealed = SealedHeader::seal_slow(header.inner.into_consensus()); - let (events, policy_events) = self.extract_events(block_number, &receipts); + let (events, policy_events, mutated_acc) = self.extract_events(block_number, &receipts); self.record_seen_block(block_number, 0); // If we have a buffered tip, check if the new block confirms it. - if let Some((tip_header, tip_events, tip_policy_events)) = unconfirmed_tip.take() { + if let Some((tip_header, tip_events, tip_policy_events, tip_mutated_acc)) = + unconfirmed_tip.take() + { if sealed.parent_hash() == tip_header.hash() { // Confirmed — update the L1 state anchor, apply events, and // flush to the queue. let tip_number = tip_header.number(); let tip_hash = tip_header.hash(); let tip_parent = tip_header.parent_hash(); - self.update_l1_state_anchor(tip_number, tip_hash, tip_parent); + self.update_l1_state_anchor( + tip_number, + tip_hash, + tip_parent, + chain_id, + tip_header.timestamp(), + &tip_mutated_acc, + ); self.apply_policy_events(tip_number, &tip_policy_events); self.apply_portal_state_events(tip_number, &tip_events); match self @@ -515,7 +539,7 @@ impl L1Subscriber { tip = tip_number, "Backfilling gap before confirmed tip" ); - self.backfill(&provider, from, tip_number).await?; + self.backfill(&provider, chain_id, from, tip_number).await?; } } } else { @@ -535,24 +559,25 @@ impl L1Subscriber { } // Buffer the new block as unconfirmed tip. - unconfirmed_tip = Some((sealed, events, policy_events)); + unconfirmed_tip = Some((sealed, events, policy_events, mutated_acc)); } warn!("L1 block subscription stream ended"); Ok(()) } - /// Extract portal and policy events from pre-fetched receipts (no RPC). + /// Extract portal and policy events from pre-fetched receipts (no RPC) and mutated accounts. fn extract_events( &mut self, block_number: u64, receipts: &[tempo_alloy::rpc::TempoTransactionReceipt], - ) -> (L1PortalEvents, Vec) { + ) -> (L1PortalEvents, Vec, HashSet
) { use tempo_contracts::precompiles::{ITIP20::TransferPolicyUpdate, TIP403_REGISTRY_ADDRESS}; let portal_address = self.config.portal_address; let mut portal_events = L1PortalEvents::default(); let mut policy_events = Vec::new(); + let mut mutated_accounts = HashSet::new(); for receipt in receipts { for log in receipt.logs() { @@ -571,20 +596,24 @@ impl L1Subscriber { } } } else if addr == TIP403_REGISTRY_ADDRESS { + mutated_accounts.insert(addr); if let Some(event) = PolicyEvent::decode_registry(log) { policy_events.push(event); } } else if self.tracked_tokens.contains(&addr) && log.topics().first() == Some(&TransferPolicyUpdate::SIGNATURE_HASH) - && let Some(event) = PolicyEvent::decode_tip20(log) { - policy_events.push(event); + // TODO: remove once tempo migrated policy ids to the 403 registry. + mutated_accounts.insert(addr); + if let Some(event) = PolicyEvent::decode_tip20(log) { + policy_events.push(event); + } } } } self.record_portal_event_metrics(&portal_events); - (portal_events, policy_events) + (portal_events, policy_events, mutated_accounts) } fn record_seen_block(&self, block_number: u64, lag_blocks: u64) { @@ -677,10 +706,17 @@ impl L1Subscriber { ); } - /// Update the L1 state cache anchor. Detects reorgs by comparing - /// `parent_hash` against the current anchor and clears the cache when they - /// diverge. - pub(crate) fn update_l1_state_anchor(&self, number: u64, hash: B256, parent_hash: B256) { + /// Update the L1 state cache anchor. Detects reorgs by comparing `parent_hash` + /// against the current anchor and clears the cache when they diverge. + pub(crate) fn update_l1_state_anchor( + &self, + number: u64, + hash: B256, + parent_hash: B256, + chain_id: u64, + timestamp: u64, + mutated_accounts: &HashSet
, + ) { let mut guard = self.config.l1_state_cache.write(); let anchor = guard.anchor(); if anchor.hash != B256::ZERO && parent_hash != anchor.hash { @@ -694,6 +730,12 @@ impl L1Subscriber { guard.clear(); self.config.policy_cache.write().clear(); } + for &address in mutated_accounts { + guard.invalidate(address, number); + } + if let Some(hardfork) = TempoHardfork::from_chain_and_timestamp(chain_id, timestamp) { + guard.observe_hardfork(number, hardfork); + } guard.update_anchor(NumHash::new(number, hash)); } } diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index e0c8db9bf..f0b7140de 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -412,7 +412,20 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_state() { let old_header = make_test_header(10); let old_hash = header_hash(&old_header); - subscriber.update_l1_state_anchor(10, old_hash, old_header.inner.parent_hash); + subscriber.update_l1_state_anchor( + 10, + old_hash, + old_header.inner.parent_hash, + 4217, + old_header.timestamp(), + &HashSet::new(), + ); + subscriber.config.l1_state_cache.write().set( + token, + B256::with_last_byte(1), + 10, + B256::with_last_byte(0xaa), + ); { let mut cache = subscriber.config.policy_cache.write(); cache.set_token_policy(token, 10, 2); @@ -423,7 +436,23 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_state() { let replacement_parent = B256::with_last_byte(0x44); let replacement_header = make_chained_header(11, replacement_parent); - subscriber.update_l1_state_anchor(11, header_hash(&replacement_header), replacement_parent); + subscriber.update_l1_state_anchor( + 11, + header_hash(&replacement_header), + replacement_parent, + 4217, + replacement_header.timestamp(), + &HashSet::new(), + ); + assert_eq!( + subscriber + .config + .l1_state_cache + .read() + .get(token, B256::with_last_byte(1), 10), + None, + "reorg must clear raw L1 state" + ); subscriber.apply_policy_events( 11, &[ From 8598ffe33b57052b0af00d7629654b8a939e09d3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 13 Jul 2026 19:31:49 +0200 Subject: [PATCH 02/22] chore: cleanup --- crates/l1/src/state/cache.rs | 42 ++++++------- crates/l1/src/state/provider.rs | 89 +++++++++++++++++---------- crates/l1/src/subscriber.rs | 67 ++++++++------------ crates/l1/src/tests.rs | 105 ++++++++++++++++++++++---------- 4 files changed, 176 insertions(+), 127 deletions(-) diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index f22155c1b..444c71550 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -76,7 +76,7 @@ pub struct L1StateCacheInner { invalidations: HashMap>, /// First canonical L1 block where each observed Tempo hardfork is active. hardfork_schedule: BTreeSet<(u64, TempoHardfork)>, - /// Highest block covered by the cached activation schedule. + /// Highest block for which the activation schedule is known to be complete. hardfork_schedule_head: Option, /// Latest L1 block the cache has received data for, used for reorg detection. anchor: NumHash, @@ -135,10 +135,16 @@ impl L1StateCacheInner { if self.hardfork_schedule_head? < block_number { return None; } + self.latest_hardfork_activation_at(block_number) + .map(|(_, hardfork)| hardfork) + } + + fn latest_hardfork_activation_at(&self, block_number: u64) -> Option<(u64, TempoHardfork)> { + let latest = TempoHardfork::VARIANTS.last().copied()?; self.hardfork_schedule - .range(..=(block_number, TempoHardfork::T9)) + .range(..=(block_number, latest)) .next_back() - .map(|(_, hardfork)| *hardfork) + .copied() } /// Extends the known Tempo activation schedule through `block_number`. @@ -155,11 +161,11 @@ impl L1StateCacheInner { ); } - /// Advance an initialized schedule from a contiguous confirmed L1 block. + /// Extends an initialized schedule with the next confirmed L1 block. /// - /// A single observation cannot initialize historical activation boundaries. Until a full - /// schedule has been resolved, observations are ignored and the provider remains responsible - /// for initialization through canonical constants/RPC. + /// An observed active hardfork does not reveal its historical activation block, so it cannot + /// initialize the schedule. After provider initialization, a contiguous observation either + /// advances coverage or records a newly activated fork; gaps and downgrades are ignored. pub fn observe_hardfork(&mut self, block_number: u64, hardfork: TempoHardfork) { let Some(head) = self.hardfork_schedule_head else { return; @@ -169,20 +175,15 @@ impl L1StateCacheInner { } let previous = self - .hardfork_schedule - .range(..=(head, TempoHardfork::T9)) - .next_back() - .map(|(_, hardfork)| *hardfork); + .latest_hardfork_activation_at(head) + .map(|(_, hardfork)| hardfork); let Some(previous) = previous else { return; }; - if hardfork < previous { - // Tempo hardforks never downgrade. Leave the head unchanged so the provider resolves - // this block instead of extending the cache from stale/incorrect chain metadata. - return; - } if hardfork > previous { self.hardfork_schedule.insert((block_number, hardfork)); + } else if hardfork < previous { + return; // Tempo hardforks never downgrade. } self.hardfork_schedule_head = Some(block_number); } @@ -238,11 +239,7 @@ impl L1StateCacheInner { // Keep the latest activation before the pruning boundary as the baseline, plus all newer // activations. This preserves hardfork lookup for every retained block. - let baseline = self - .hardfork_schedule - .range(..=(min_block, TempoHardfork::T9)) - .next_back() - .copied(); + let baseline = self.latest_hardfork_activation_at(min_block); self.hardfork_schedule.retain(|(block, hardfork)| { *block >= min_block || Some((*block, *hardfork)) == baseline }); @@ -313,6 +310,7 @@ mod tests { cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1)); cache.invalidate(PORTAL, 101); + cache.extend_hardfork_schedule(101, [(0, TempoHardfork::T0)]); cache.update_anchor(NumHash { number: 100, hash: B256::with_last_byte(0xab), @@ -322,6 +320,8 @@ mod tests { assert_eq!(cache.get(PORTAL, B256::ZERO, 100), None); assert!(cache.invalidations.is_empty()); + assert_eq!(cache.hardfork_at(100), None); + assert_eq!(cache.hardfork_schedule_head, None); assert_eq!(cache.anchor(), NumHash::default()); } diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index 8e6889acb..78e93ecbd 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -21,7 +21,7 @@ use reth_chainspec::ForkCondition; use tempo_alloy::TempoNetwork; use tempo_chainspec::{ hardfork::TempoHardfork, - spec::{DEV, TempoHardforks, chainspec_from_chain_id}, + spec::{TempoHardforks, chainspec_from_chain_id}, }; use tracing::{debug, info, warn}; use zone_precompiles::{L1StorageReader, SequencerExt}; @@ -296,7 +296,8 @@ impl L1StateProvider { .get_block_by_number(BlockNumberOrTag::Number(block_number)), )?; let block = block.ok_or_else(|| eyre::eyre!("L1 block {block_number} not found"))?; - let chain_spec = chainspec_from_chain_id(chain_id).unwrap_or_else(|| DEV.clone()); + let chain_spec = chainspec_from_chain_id(chain_id) + .ok_or_else(|| eyre::eyre!("unsupported Tempo L1 chain ID {chain_id}"))?; let block_ts = block.header.timestamp(); let mut activations = Vec::new(); @@ -308,10 +309,14 @@ impl L1StateProvider { if fork_ts > block_ts { continue; } - let activation_block = if let Some(block) = known_activation_block(chain_id, hardfork) { - block - } else { - self.first_block_at_or_after(fork_ts, block_number).await? + let known_block = match chain_id { + 4217 => hardfork.mainnet_activation_block(), + 42431 => hardfork.moderato_activation_block(), + _ => None, + }; + let activation_block = match known_block { + Some(block) => block, + None => self.first_block_at_or_after(fork_ts, block_number).await?, }; activations.push((activation_block, hardfork)); } @@ -319,9 +324,8 @@ impl L1StateProvider { Ok(activations) } - async fn first_block_at_or_after(&self, timestamp: u64, high: u64) -> Result { + async fn first_block_at_or_after(&self, timestamp: u64, mut high: u64) -> Result { let mut low = 0u64; - let mut high = high; while low < high { let mid = low + (high - low) / 2; let block = self @@ -353,14 +357,6 @@ impl L1StateProvider { } } -fn known_activation_block(chain_id: u64, hardfork: TempoHardfork) -> Option { - match chain_id { - 4217 => hardfork.mainnet_activation_block(), - 42431 => hardfork.moderato_activation_block(), - _ => None, - } -} - impl L1StorageReader for L1StateProvider { fn read_l1_storage( &self, @@ -385,23 +381,54 @@ impl SequencerExt for L1StateProvider { #[cfg(test)] mod tests { use super::*; + use alloy_transport::mock::Asserter; + + #[tokio::test(flavor = "multi_thread")] + async fn get_hardfork_fetches_exact_block_writes_back_and_hits_cache() { + let asserter = Asserter::new(); + asserter.push_success(&4217u64); + let consensus = tempo_primitives::TempoHeader { + inner: alloy_consensus::Header { + number: 0, + timestamp: 0, + ..Default::default() + }, + ..Default::default() + }; + let header = tempo_alloy::rpc::TempoHeaderResponse { + inner: alloy_rpc_types_eth::Header::new(consensus), + timestamp_millis: 0, + }; + let block: ::BlockResponse = + alloy_rpc_types_eth::Block::empty(header); + asserter.push_success(&Some(block)); - #[test] - fn known_activation_blocks_come_from_tempo_hardfork_constants() { - assert_eq!( - known_activation_block(4217, TempoHardfork::T2), - TempoHardfork::T2.mainnet_activation_block() - ); - assert_eq!( - known_activation_block(42431, TempoHardfork::T2), - TempoHardfork::T2.moderato_activation_block() + let cache = L1StateCache::default(); + let provider = ProviderBuilder::new_with_network::() + .connect_mocked_client(asserter) + .erased(); + let provider = L1StateProvider::new_raw( + L1StateProviderConfig::default(), + cache.clone(), + provider, + tokio::runtime::Handle::current(), ); - assert_eq!(known_activation_block(1337, TempoHardfork::T2), None); - } + let fetched = tokio::task::spawn_blocking({ + let provider = provider.clone(); + move || provider.get_hardfork(0) + }) + .await + .unwrap() + .unwrap(); + + assert_eq!(fetched, TempoHardfork::T0); + assert_eq!(cache.read().hardfork_at(0), Some(TempoHardfork::T0)); - #[test] - fn unknown_future_activation_blocks_fall_back_to_rpc_resolution() { - assert_eq!(known_activation_block(4217, TempoHardfork::T8), None); - assert_eq!(known_activation_block(42431, TempoHardfork::T8), None); + // No further mock response is configured, so this can only succeed from the cache. + let cached = tokio::task::spawn_blocking(move || provider.get_hardfork(0)) + .await + .unwrap() + .unwrap(); + assert_eq!(cached, TempoHardfork::T0); } } diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index bab0aab7c..864d34d2d 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -402,23 +402,16 @@ impl L1Subscriber { while let Some((header, receipts)) = fetched.try_next().await? { let block_number = header.number(); - let (events, policy_events, invalidated_accounts) = + let (portal_events, policy_events, mutated_accounts) = self.extract_events(block_number, &receipts); self.record_seen_block(block_number, to.saturating_sub(block_number)); let sealed = SealedHeader::seal_slow(header); - self.update_l1_state_anchor( - block_number, - sealed.hash(), - sealed.parent_hash(), - chain_id, - sealed.timestamp(), - &invalidated_accounts, - ); + self.update_l1_state_anchor(&sealed, chain_id, &mutated_accounts); self.apply_policy_events(block_number, &policy_events); - self.apply_portal_state_events(block_number, &events); + self.apply_portal_state_events(block_number, &portal_events); self.deposit_queue - .enqueue_sealed(sealed, events, policy_events); + .enqueue_sealed(sealed, portal_events, policy_events); enqueued += 1; self.subscriber_metrics.blocks_enqueued.increment(1); @@ -482,9 +475,7 @@ impl L1Subscriber { #[allow(clippy::type_complexity)] let mut unconfirmed_tip: Option<( SealedHeader, - L1PortalEvents, - Vec, - HashSet
, + (L1PortalEvents, Vec, HashSet
), )> = None; loop { @@ -498,32 +489,23 @@ impl L1Subscriber { }; let block_number = header.number(); let sealed = SealedHeader::seal_slow(header.inner.into_consensus()); - let (events, policy_events, mutated_acc) = self.extract_events(block_number, &receipts); + let events = self.extract_events(block_number, &receipts); self.record_seen_block(block_number, 0); // If we have a buffered tip, check if the new block confirms it. - if let Some((tip_header, tip_events, tip_policy_events, tip_mutated_acc)) = + if let Some((tip_header, (portal_events, policy_events, mutated_accounts))) = unconfirmed_tip.take() { if sealed.parent_hash() == tip_header.hash() { // Confirmed — update the L1 state anchor, apply events, and // flush to the queue. let tip_number = tip_header.number(); - let tip_hash = tip_header.hash(); - let tip_parent = tip_header.parent_hash(); - self.update_l1_state_anchor( - tip_number, - tip_hash, - tip_parent, - chain_id, - tip_header.timestamp(), - &tip_mutated_acc, - ); - self.apply_policy_events(tip_number, &tip_policy_events); - self.apply_portal_state_events(tip_number, &tip_events); + self.update_l1_state_anchor(&tip_header, chain_id, &mutated_accounts); + self.apply_policy_events(tip_number, &policy_events); + self.apply_portal_state_events(tip_number, &portal_events); match self .deposit_queue - .try_enqueue(tip_header, tip_events, tip_policy_events) + .try_enqueue(tip_header, portal_events, policy_events) { EnqueueOutcome::Accepted => { self.subscriber_metrics.blocks_enqueued.increment(1); @@ -559,7 +541,7 @@ impl L1Subscriber { } // Buffer the new block as unconfirmed tip. - unconfirmed_tip = Some((sealed, events, policy_events, mutated_acc)); + unconfirmed_tip = Some((sealed, events)); } warn!("L1 block subscription stream ended"); @@ -567,7 +549,7 @@ impl L1Subscriber { } /// Extract portal and policy events from pre-fetched receipts (no RPC) and mutated accounts. - fn extract_events( + pub(crate) fn extract_events( &mut self, block_number: u64, receipts: &[tempo_alloy::rpc::TempoTransactionReceipt], @@ -603,7 +585,7 @@ impl L1Subscriber { } else if self.tracked_tokens.contains(&addr) && log.topics().first() == Some(&TransferPolicyUpdate::SIGNATURE_HASH) { - // TODO: remove once tempo migrated policy ids to the 403 registry. + // TODO: Remove once Tempo has migrated policy IDs to the TIP-403 registry. mutated_accounts.insert(addr); if let Some(event) = PolicyEvent::decode_tip20(log) { policy_events.push(event); @@ -710,33 +692,32 @@ impl L1Subscriber { /// against the current anchor and clears the cache when they diverge. pub(crate) fn update_l1_state_anchor( &self, - number: u64, - hash: B256, - parent_hash: B256, + header: &SealedHeader, chain_id: u64, - timestamp: u64, mutated_accounts: &HashSet
, ) { let mut guard = self.config.l1_state_cache.write(); let anchor = guard.anchor(); - if anchor.hash != B256::ZERO && parent_hash != anchor.hash { + if anchor.hash != B256::ZERO && header.parent_hash() != anchor.hash { self.subscriber_metrics.reorgs_detected.increment(1); warn!( old_anchor = %anchor.hash, - new_parent = %parent_hash, - block_number = number, + new_parent = %header.parent_hash(), + block_number = header.number(), "Reorg detected, clearing L1 state cache" ); guard.clear(); self.config.policy_cache.write().clear(); } for &address in mutated_accounts { - guard.invalidate(address, number); + guard.invalidate(address, header.number()); } - if let Some(hardfork) = TempoHardfork::from_chain_and_timestamp(chain_id, timestamp) { - guard.observe_hardfork(number, hardfork); + if let Some(hardfork) = + TempoHardfork::from_chain_and_timestamp(chain_id, header.timestamp()) + { + guard.observe_hardfork(header.number(), hardfork); } - guard.update_anchor(NumHash::new(number, hash)); + guard.update_anchor(header.num_hash()); } } diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index f0b7140de..79893b918 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::abi::{DepositType, PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT}; use alloy_consensus::{Header, ReceiptWithBloom}; -use alloy_primitives::{Bloom, FixedBytes, address}; +use alloy_primitives::{Bloom, Bytes, FixedBytes, LogData, address}; use alloy_rpc_types_eth::TransactionReceipt; use alloy_sol_types::SolEvent; use alloy_transport::mock::Asserter; @@ -11,6 +11,7 @@ use std::{ time::Duration, }; use tempo_alloy::rpc::TempoTransactionReceipt; +use tempo_chainspec::hardfork::TempoHardfork; use tempo_primitives::{TempoReceipt, TempoTxType}; #[derive(Deserialize)] @@ -226,6 +227,12 @@ fn make_test_receipt( } } +fn make_test_receipt_with_logs(logs: Vec) -> TempoTransactionReceipt { + let mut receipt = make_test_receipt(1, B256::ZERO, B256::ZERO, 0, 21_000, Bloom::ZERO); + receipt.inner.inner.receipt.logs = logs; + receipt +} + fn calculate_test_receipts_root(receipts: &[TempoTransactionReceipt]) -> B256 { let receipts = receipts .iter() @@ -399,7 +406,7 @@ fn assert_tempo_header_rejected(input: &[u8]) { } #[test] -fn update_l1_state_anchor_reorg_clears_stale_policy_state() { +fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { use crate::state::tip403::AuthRole; use tempo_contracts::precompiles::ITIP403Registry::PolicyType; @@ -410,22 +417,18 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_state() { let token = address!("0x0000000000000000000000000000000000000011"); let user = address!("0x0000000000000000000000000000000000000022"); - let old_header = make_test_header(10); - let old_hash = header_hash(&old_header); - subscriber.update_l1_state_anchor( - 10, - old_hash, - old_header.inner.parent_hash, - 4217, - old_header.timestamp(), - &HashSet::new(), - ); - subscriber.config.l1_state_cache.write().set( - token, - B256::with_last_byte(1), - 10, - B256::with_last_byte(0xaa), - ); + let old_header = seal(make_test_header(10)); + subscriber.update_l1_state_anchor(&old_header, 4217, &HashSet::new()); + { + let mut cache = subscriber.config.l1_state_cache.write(); + cache.set( + token, + B256::with_last_byte(1), + 10, + B256::with_last_byte(0xaa), + ); + cache.extend_hardfork_schedule(10, [(0, TempoHardfork::T0)]); + } { let mut cache = subscriber.config.policy_cache.write(); cache.set_token_policy(token, 10, 2); @@ -435,15 +438,8 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_state() { } let replacement_parent = B256::with_last_byte(0x44); - let replacement_header = make_chained_header(11, replacement_parent); - subscriber.update_l1_state_anchor( - 11, - header_hash(&replacement_header), - replacement_parent, - 4217, - replacement_header.timestamp(), - &HashSet::new(), - ); + let replacement_header = seal(make_chained_header(11, replacement_parent)); + subscriber.update_l1_state_anchor(&replacement_header, 4217, &HashSet::new()); assert_eq!( subscriber .config @@ -453,6 +449,11 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_state() { None, "reorg must clear raw L1 state" ); + assert_eq!( + subscriber.config.l1_state_cache.read().hardfork_at(10), + None, + "reorg must clear hardfork metadata" + ); subscriber.apply_policy_events( 11, &[ @@ -492,12 +493,9 @@ fn confirm_shared(queue: &DepositQueue) -> L1BlockDeposits { queue.confirm(num_hash).expect("confirm mismatch") } -fn make_portal_log(portal_address: Address, event: E) -> Log { +fn make_log(address: Address, data: LogData) -> Log { Log { - inner: alloy_primitives::Log { - address: portal_address, - data: event.encode_log_data(), - }, + inner: alloy_primitives::Log { address, data }, block_hash: None, block_number: None, block_timestamp: None, @@ -508,6 +506,49 @@ fn make_portal_log(portal_address: Address, event: E) -> Log { } } +fn make_portal_log(portal_address: Address, event: E) -> Log { + make_log(portal_address, event.encode_log_data()) +} + +#[test] +fn extract_events_conservatively_tracks_policy_mutations() { + use tempo_contracts::precompiles::{ITIP20::TransferPolicyUpdate, TIP403_REGISTRY_ADDRESS}; + + let tracked_token = Address::with_last_byte(0x11); + let untracked_token = Address::with_last_byte(0x22); + let mut subscriber = test_subscriber( + Arc::new(SequenceLocalTempoCheckpointReader::new([0])), + Some(0), + ); + subscriber.tracked_tokens = vec![tracked_token]; + + let unknown_registry_log = make_log( + TIP403_REGISTRY_ADDRESS, + LogData::new_unchecked(vec![B256::repeat_byte(0xff)], Bytes::new()), + ); + let policy_update = TransferPolicyUpdate { + updater: Address::ZERO, + newPolicyId: 7, + }; + let tracked_update = make_log(tracked_token, policy_update.encode_log_data()); + let untracked_update = make_log(untracked_token, policy_update.encode_log_data()); + let receipt = + make_test_receipt_with_logs(vec![unknown_registry_log, tracked_update, untracked_update]); + + let (_, policy_events, mutated_accounts) = subscriber.extract_events(1, &[receipt]); + + assert!(mutated_accounts.contains(&TIP403_REGISTRY_ADDRESS)); + assert!(mutated_accounts.contains(&tracked_token)); + assert!(!mutated_accounts.contains(&untracked_token)); + assert!(matches!( + policy_events.as_slice(), + [PolicyEvent::TokenPolicyChanged { + token, + policy_id: 7, + }] if *token == tracked_token + )); +} + #[tokio::test] async fn test_resolve_start_block_reads_live_local_state_each_time() { let subscriber = test_subscriber( From 16e88dd02be8c0bf0d87154eb541f2a4c00f5eca Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Jul 2026 22:33:02 +0200 Subject: [PATCH 03/22] fix(node): prune `L1StateCache` --- crates/node/src/engine.rs | 11 ++++++++++- crates/node/src/node.rs | 6 ++++++ crates/node/tests/it/utils.rs | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/node/src/engine.rs b/crates/node/src/engine.rs index 7c268929c..d6a4b80fb 100644 --- a/crates/node/src/engine.rs +++ b/crates/node/src/engine.rs @@ -51,7 +51,7 @@ use tempo_chainspec::spec::TempoChainSpec; use tempo_primitives::TempoHeader; use tracing::{error, warn}; -use zone_l1::{DepositQueue, L1BlockDeposits, PolicyProvider, PreparedL1Block}; +use zone_l1::{DepositQueue, L1BlockDeposits, L1StateCache, PolicyProvider, PreparedL1Block}; use zone_payload::{ZonePayloadAttributes, ZonePayloadTypes}; /// Engine that drives L2 block production from L1 events. @@ -87,6 +87,8 @@ pub struct ZoneEngine { /// Cache-first, RPC-fallback TIP-403 policy provider for authorization checks /// on encrypted deposit recipients during preparation. policy_provider: PolicyProvider, + /// Block-versioned L1 storage cache shared with the subscriber and precompiles. + l1_state_cache: L1StateCache, } impl ZoneEngine { @@ -100,6 +102,7 @@ impl ZoneEngine { sequencer_key: k256::SecretKey, portal_address: Address, policy_provider: PolicyProvider, + l1_state_cache: L1StateCache, ) -> Self { Self { chain_spec, @@ -111,6 +114,7 @@ impl ZoneEngine { sequencer_key, portal_address, policy_provider, + l1_state_cache, } } @@ -277,6 +281,11 @@ impl ZoneEngine { // could return wrong results. self.policy_provider.cache().advance(l1_num_hash.number); + // The engine has committed this L1 height, so no subsequent zone block can query an + // earlier height while building. Preserve only the baseline needed at this boundary and + // discard older slot, invalidation, and hardfork history accumulated by the subscriber. + self.l1_state_cache.write().prune_before(l1_num_hash.number); + self.last_header = header; // Canonicalize the new head — FCU-with-attrs above only set the diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index eb6b3346b..3f6d44e70 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -364,6 +364,8 @@ where l1_config: L1SubscriberConfig, /// TIP-403 policy cache policy_cache: PolicyCache, + /// Block-versioned L1 storage cache shared with the subscriber and precompiles. + l1_state_cache: L1StateCache, /// ZonePortal address on L1. portal_address: Address, /// Pre-configured list of initial tokens. @@ -393,6 +395,7 @@ where deposit_queue: DepositQueue, l1_config: L1SubscriberConfig, policy_cache: PolicyCache, + l1_state_cache: L1StateCache, portal_address: Address, initial_tokens: Option>, private_rpc_config: ZonePrivateRpcConfig, @@ -410,6 +413,7 @@ where deposit_queue, l1_config, policy_cache, + l1_state_cache, portal_address, initial_tokens, private_rpc_config, @@ -623,6 +627,7 @@ where sequencer_key, self.portal_address, policy_provider, + self.l1_state_cache.clone(), ); ctx.node .task_executor() @@ -815,6 +820,7 @@ where self.deposit_queue.clone(), self.l1_config.clone(), self.policy_cache.clone(), + self.l1_state_cache.clone(), self.portal_address, self.initial_tokens.clone(), self.private_rpc_config.clone(), diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index f73d54b54..8291b1990 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -693,6 +693,7 @@ impl ZoneTestNode { SecretKey::from(sequencer_signer.credential()), portal_address, policy_provider, + l1_state_cache.clone(), ); node_handle .node From 5ba81038733861aa7b6fd9ccaba97e120df2ceb8 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 08:02:56 +0200 Subject: [PATCH 04/22] refactor(l1): use composed chainspec for hardforks --- Cargo.lock | 2 - crates/l1/Cargo.toml | 2 - crates/l1/src/state/cache.rs | 141 +------------------------------- crates/l1/src/state/provider.rs | 136 +----------------------------- crates/l1/src/subscriber.rs | 20 ++--- crates/l1/src/tests.rs | 11 +-- 6 files changed, 11 insertions(+), 301 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11976602a..aa722d697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13290,7 +13290,6 @@ dependencies = [ "k256", "metrics", "parking_lot", - "reth-chainspec", "reth-metrics", "reth-primitives-traits", "reth-provider", @@ -13301,7 +13300,6 @@ dependencies = [ "serde", "serde_json", "tempo-alloy", - "tempo-chainspec", "tempo-contracts", "tempo-precompiles", "tempo-primitives", diff --git a/crates/l1/Cargo.toml b/crates/l1/Cargo.toml index cc73ad3bf..069557690 100644 --- a/crates/l1/Cargo.toml +++ b/crates/l1/Cargo.toml @@ -19,7 +19,6 @@ zone-primitives = { workspace = true, features = ["std", "serde"] } # tempo tempo-alloy.workspace = true tempo-contracts.workspace = true -tempo-chainspec = { workspace = true, features = ["reth"] } tempo-precompiles.workspace = true tempo-primitives.workspace = true tempo-revm.workspace = true @@ -29,7 +28,6 @@ tempo-transaction-pool.workspace = true reth-metrics.workspace = true reth-primitives-traits.workspace = true reth-provider.workspace = true -reth-chainspec.workspace = true reth-storage-api.workspace = true reth-tasks.workspace = true reth-transaction-pool.workspace = true diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index 444c71550..f30a93ffe 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -31,7 +31,6 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, sync::Arc, }; -use tempo_chainspec::hardfork::TempoHardfork; /// Thread-safe L1 state cache backed by an `Arc>`. #[derive(Debug, Clone, Deref, Default)] @@ -56,11 +55,9 @@ impl L1StateCache { /// i.e. the value that was current at that height. This allows the zone to read L1 state at /// the `tempoBlockNumber` it committed to, even if the L1 chain has since advanced. /// -/// To enable delegation to upstream Tempo precompiles, the cache also tracks contract mutation -/// barriers and hardfork activations. Barriers prevent values from crossing blocks where logs -/// signal a possible storage change without requiring slot-level decoding, while hardfork metadata -/// selects the Tempo rules active at the same anchor. Reorgs clear all three atomically: slot -/// values, mutation history, and protocol-version metadata. +/// Contract mutation barriers prevent values from crossing blocks where logs signal a possible +/// storage change without requiring slot-level decoding. Reorgs clear slot values and mutation +/// history atomically. /// /// The anchor tracks the latest L1 block the cache has received data for, used by the /// [`L1Subscriber`](crate::l1::L1Subscriber) for reorg detection. @@ -74,10 +71,6 @@ pub struct L1StateCacheInner { /// A slot value cached at block V may serve block N only when no barrier exists in `(V, N]`. /// The subscriber records barriers for contracts whose logs imply possible storage changes. invalidations: HashMap>, - /// First canonical L1 block where each observed Tempo hardfork is active. - hardfork_schedule: BTreeSet<(u64, TempoHardfork)>, - /// Highest block for which the activation schedule is known to be complete. - hardfork_schedule_head: Option, /// Latest L1 block the cache has received data for, used for reorg detection. anchor: NumHash, } @@ -130,64 +123,6 @@ impl L1StateCacheInner { .insert(block_number, value); } - /// Returns the active Tempo hardfork when the cached schedule covers `block_number`. - pub fn hardfork_at(&self, block_number: u64) -> Option { - if self.hardfork_schedule_head? < block_number { - return None; - } - self.latest_hardfork_activation_at(block_number) - .map(|(_, hardfork)| hardfork) - } - - fn latest_hardfork_activation_at(&self, block_number: u64) -> Option<(u64, TempoHardfork)> { - let latest = TempoHardfork::VARIANTS.last().copied()?; - self.hardfork_schedule - .range(..=(block_number, latest)) - .next_back() - .copied() - } - - /// Extends the known Tempo activation schedule through `block_number`. - pub fn extend_hardfork_schedule( - &mut self, - block_number: u64, - activations: impl IntoIterator, - ) { - self.hardfork_schedule.extend(activations); - self.hardfork_schedule_head = Some( - self.hardfork_schedule_head - .unwrap_or_default() - .max(block_number), - ); - } - - /// Extends an initialized schedule with the next confirmed L1 block. - /// - /// An observed active hardfork does not reveal its historical activation block, so it cannot - /// initialize the schedule. After provider initialization, a contiguous observation either - /// advances coverage or records a newly activated fork; gaps and downgrades are ignored. - pub fn observe_hardfork(&mut self, block_number: u64, hardfork: TempoHardfork) { - let Some(head) = self.hardfork_schedule_head else { - return; - }; - if block_number != head.saturating_add(1) { - return; - } - - let previous = self - .latest_hardfork_activation_at(head) - .map(|(_, hardfork)| hardfork); - let Some(previous) = previous else { - return; - }; - if hardfork > previous { - self.hardfork_schedule.insert((block_number, hardfork)); - } else if hardfork < previous { - return; // Tempo hardforks never downgrade. - } - self.hardfork_schedule_head = Some(block_number); - } - /// Updates the anchor block that this cache has received data up to. pub fn update_anchor(&mut self, anchor: NumHash) { self.anchor = anchor; @@ -207,8 +142,6 @@ impl L1StateCacheInner { pub fn clear(&mut self) { self.slots.clear(); self.invalidations.clear(); - self.hardfork_schedule.clear(); - self.hardfork_schedule_head = None; self.anchor = NumHash::default(); } @@ -236,13 +169,6 @@ impl L1StateCacheInner { blocks.retain(|block| *block >= min_block || Some(*block) == baseline); } self.invalidations.retain(|_, blocks| !blocks.is_empty()); - - // Keep the latest activation before the pruning boundary as the baseline, plus all newer - // activations. This preserves hardfork lookup for every retained block. - let baseline = self.latest_hardfork_activation_at(min_block); - self.hardfork_schedule.retain(|(block, hardfork)| { - *block >= min_block || Some((*block, *hardfork)) == baseline - }); } } @@ -310,7 +236,6 @@ mod tests { cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1)); cache.invalidate(PORTAL, 101); - cache.extend_hardfork_schedule(101, [(0, TempoHardfork::T0)]); cache.update_anchor(NumHash { number: 100, hash: B256::with_last_byte(0xab), @@ -320,8 +245,6 @@ mod tests { assert_eq!(cache.get(PORTAL, B256::ZERO, 100), None); assert!(cache.invalidations.is_empty()); - assert_eq!(cache.hardfork_at(100), None); - assert_eq!(cache.hardfork_schedule_head, None); assert_eq!(cache.anchor(), NumHash::default()); } @@ -358,64 +281,6 @@ mod tests { assert_eq!(cache.anchor(), NumHash::default()); } - #[test] - fn hardfork_lookup_uses_bounded_activation_schedule() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - cache.extend_hardfork_schedule(20, [(10, TempoHardfork::T2), (20, TempoHardfork::T8)]); - - assert_eq!(cache.hardfork_at(9), None); - assert_eq!(cache.hardfork_at(10), Some(TempoHardfork::T2)); - assert_eq!(cache.hardfork_at(19), Some(TempoHardfork::T2)); - assert_eq!(cache.hardfork_at(20), Some(TempoHardfork::T8)); - assert_eq!(cache.hardfork_at(21), None); - } - - #[test] - fn confirmed_headers_advance_initialized_hardfork_schedule() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - cache.extend_hardfork_schedule(10, [(0, TempoHardfork::T0)]); - - cache.observe_hardfork(11, TempoHardfork::T0); - cache.observe_hardfork(12, TempoHardfork::T2); - - assert_eq!(cache.hardfork_at(11), Some(TempoHardfork::T0)); - assert_eq!(cache.hardfork_at(12), Some(TempoHardfork::T2)); - assert_eq!(cache.hardfork_at(13), None); - } - - #[test] - fn hardfork_observation_does_not_initialize_or_cross_a_gap() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - cache.observe_hardfork(10, TempoHardfork::T2); - assert_eq!(cache.hardfork_at(10), None); - - cache.extend_hardfork_schedule(10, [(0, TempoHardfork::T0)]); - cache.observe_hardfork(12, TempoHardfork::T2); - assert_eq!(cache.hardfork_at(11), None); - assert_eq!(cache.hardfork_at(12), None); - - cache.observe_hardfork(11, TempoHardfork::Genesis); - assert_eq!(cache.hardfork_at(11), None); - } - - #[test] - fn prune_keeps_hardfork_activation_baseline() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - cache.extend_hardfork_schedule( - 30, - [ - (0, TempoHardfork::T0), - (10, TempoHardfork::T2), - (20, TempoHardfork::T8), - ], - ); - - cache.prune_before(15); - - assert_eq!(cache.hardfork_at(15), Some(TempoHardfork::T2)); - assert_eq!(cache.hardfork_at(20), Some(TempoHardfork::T8)); - } - #[test] fn update_anchor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index 78e93ecbd..cb5a3fd4d 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -10,19 +10,13 @@ //! intended for use inside EVM precompiles where async is unavailable — it retries the RPC //! call indefinitely with exponential backoff to avoid bricking the chain on transient outages. -use alloy_consensus::BlockHeader; use alloy_primitives::{Address, B256, U256}; use alloy_provider::{DynProvider, Provider, ProviderBuilder}; use alloy_rpc_client::RpcClient; -use alloy_rpc_types_eth::{BlockId, BlockNumberOrTag}; +use alloy_rpc_types_eth::BlockId; use alloy_transport::layers::RetryBackoffLayer; use eyre::Result; -use reth_chainspec::ForkCondition; use tempo_alloy::TempoNetwork; -use tempo_chainspec::{ - hardfork::TempoHardfork, - spec::{TempoHardforks, chainspec_from_chain_id}, -}; use tracing::{debug, info, warn}; use zone_precompiles::{L1StorageReader, SequencerExt}; @@ -264,84 +258,11 @@ impl L1StateProvider { Ok(value) } - /// Resolve the Tempo hardfork active at an exact L1 block, using cached metadata first. - pub fn get_hardfork(&self, block_number: u64) -> Result { - if let Some(hardfork) = self.cache.read().hardfork_at(block_number) { - return Ok(hardfork); - } - - let activations = tokio::task::block_in_place(|| { - self.runtime_handle - .block_on(self.fetch_hardfork_schedule(block_number)) - })?; - let mut cache = self.cache.write(); - cache.extend_hardfork_schedule(block_number, activations); - cache - .hardfork_at(block_number) - .ok_or_else(|| eyre::eyre!("no Tempo hardfork active at L1 block {block_number}")) - } - /// Expose the shared cache handle for external use (e.g. the engine). pub fn cache(&self) -> &L1StateCache { &self.cache } - async fn fetch_hardfork_schedule( - &self, - block_number: u64, - ) -> Result> { - let (chain_id, block) = tokio::try_join!( - self.provider.get_chain_id(), - self.provider - .get_block_by_number(BlockNumberOrTag::Number(block_number)), - )?; - let block = block.ok_or_else(|| eyre::eyre!("L1 block {block_number} not found"))?; - let chain_spec = chainspec_from_chain_id(chain_id) - .ok_or_else(|| eyre::eyre!("unsupported Tempo L1 chain ID {chain_id}"))?; - let block_ts = block.header.timestamp(); - let mut activations = Vec::new(); - - for &hardfork in TempoHardfork::VARIANTS { - let ForkCondition::Timestamp(fork_ts) = chain_spec.tempo_fork_activation(hardfork) - else { - continue; - }; - if fork_ts > block_ts { - continue; - } - let known_block = match chain_id { - 4217 => hardfork.mainnet_activation_block(), - 42431 => hardfork.moderato_activation_block(), - _ => None, - }; - let activation_block = match known_block { - Some(block) => block, - None => self.first_block_at_or_after(fork_ts, block_number).await?, - }; - activations.push((activation_block, hardfork)); - } - - Ok(activations) - } - - async fn first_block_at_or_after(&self, timestamp: u64, mut high: u64) -> Result { - let mut low = 0u64; - while low < high { - let mid = low + (high - low) / 2; - let block = self - .provider - .get_block_by_number(BlockNumberOrTag::Number(mid)) - .await? - .ok_or_else(|| eyre::eyre!("L1 block {mid} not found"))?; - if block.header.timestamp() < timestamp { - low = mid + 1; - } else { - high = mid; - } - } - Ok(low) - } - /// Fetch a single storage slot from L1 at a specific block via the shared HTTP provider. async fn fetch_slot(&self, address: Address, slot: B256, block_number: u64) -> Result { let key = U256::from_be_bytes(slot.0); @@ -377,58 +298,3 @@ impl SequencerExt for L1StateProvider { self.get_latest_sequencer().ok() } } - -#[cfg(test)] -mod tests { - use super::*; - use alloy_transport::mock::Asserter; - - #[tokio::test(flavor = "multi_thread")] - async fn get_hardfork_fetches_exact_block_writes_back_and_hits_cache() { - let asserter = Asserter::new(); - asserter.push_success(&4217u64); - let consensus = tempo_primitives::TempoHeader { - inner: alloy_consensus::Header { - number: 0, - timestamp: 0, - ..Default::default() - }, - ..Default::default() - }; - let header = tempo_alloy::rpc::TempoHeaderResponse { - inner: alloy_rpc_types_eth::Header::new(consensus), - timestamp_millis: 0, - }; - let block: ::BlockResponse = - alloy_rpc_types_eth::Block::empty(header); - asserter.push_success(&Some(block)); - - let cache = L1StateCache::default(); - let provider = ProviderBuilder::new_with_network::() - .connect_mocked_client(asserter) - .erased(); - let provider = L1StateProvider::new_raw( - L1StateProviderConfig::default(), - cache.clone(), - provider, - tokio::runtime::Handle::current(), - ); - let fetched = tokio::task::spawn_blocking({ - let provider = provider.clone(); - move || provider.get_hardfork(0) - }) - .await - .unwrap() - .unwrap(); - - assert_eq!(fetched, TempoHardfork::T0); - assert_eq!(cache.read().hardfork_at(0), Some(TempoHardfork::T0)); - - // No further mock response is configured, so this can only succeed from the cache. - let cached = tokio::task::spawn_blocking(move || provider.get_hardfork(0)) - .await - .unwrap() - .unwrap(); - assert_eq!(cached, TempoHardfork::T0); - } -} diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index 864d34d2d..826e044aa 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -1,6 +1,5 @@ use super::*; use std::collections::HashSet; -use tempo_chainspec::hardfork::TempoHardfork; /// Poll interval for the HTTP block filter fallback (500ms, matching L1 block time). const HTTP_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); @@ -285,7 +284,6 @@ impl L1Subscriber { async fn sync_to_l1_tip( &mut self, l1_provider: &impl Provider, - chain_id: u64, ) -> eyre::Result<()> { let Some(mut from) = self.resolve_start_block(l1_provider).await? else { self.subscriber_metrics.current_l1_lag_blocks.set(0.0); @@ -321,7 +319,7 @@ impl L1Subscriber { "Backfilling deposit events" ); let start = std::time::Instant::now(); - let result = self.backfill(l1_provider, chain_id, from, tip).await; + let result = self.backfill(l1_provider, from, tip).await; self.subscriber_metrics .backfill_duration_seconds .record(start.elapsed().as_secs_f64()); @@ -341,7 +339,6 @@ impl L1Subscriber { async fn backfill( &mut self, l1_provider: &impl Provider, - chain_id: u64, from: u64, to: u64, ) -> eyre::Result<()> { @@ -407,7 +404,7 @@ impl L1Subscriber { self.record_seen_block(block_number, to.saturating_sub(block_number)); let sealed = SealedHeader::seal_slow(header); - self.update_l1_state_anchor(&sealed, chain_id, &mutated_accounts); + self.update_l1_state_anchor(&sealed, &mutated_accounts); self.apply_policy_events(block_number, &policy_events); self.apply_portal_state_events(block_number, &portal_events); self.deposit_queue @@ -459,11 +456,10 @@ impl L1Subscriber { self.tracked_tokens = self.config.policy_cache.read().tracked_tokens(); let provider = self.connect().await?; - let chain_id = provider.get_chain_id().await?; // Backfill to the current tip before subscribing. // Backfilled blocks are historical and considered confirmed. - self.sync_to_l1_tip(&provider, chain_id).await?; + self.sync_to_l1_tip(&provider).await?; info!(portal = %self.config.portal_address, "Listening for L1 blocks"); let mut stream = self.l1_block_stream(&provider).await?; @@ -500,7 +496,7 @@ impl L1Subscriber { // Confirmed — update the L1 state anchor, apply events, and // flush to the queue. let tip_number = tip_header.number(); - self.update_l1_state_anchor(&tip_header, chain_id, &mutated_accounts); + self.update_l1_state_anchor(&tip_header, &mutated_accounts); self.apply_policy_events(tip_number, &policy_events); self.apply_portal_state_events(tip_number, &portal_events); match self @@ -521,7 +517,7 @@ impl L1Subscriber { tip = tip_number, "Backfilling gap before confirmed tip" ); - self.backfill(&provider, chain_id, from, tip_number).await?; + self.backfill(&provider, from, tip_number).await?; } } } else { @@ -693,7 +689,6 @@ impl L1Subscriber { pub(crate) fn update_l1_state_anchor( &self, header: &SealedHeader, - chain_id: u64, mutated_accounts: &HashSet
, ) { let mut guard = self.config.l1_state_cache.write(); @@ -712,11 +707,6 @@ impl L1Subscriber { for &address in mutated_accounts { guard.invalidate(address, header.number()); } - if let Some(hardfork) = - TempoHardfork::from_chain_and_timestamp(chain_id, header.timestamp()) - { - guard.observe_hardfork(header.number(), hardfork); - } guard.update_anchor(header.num_hash()); } } diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index 79893b918..a89f772b9 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -11,7 +11,6 @@ use std::{ time::Duration, }; use tempo_alloy::rpc::TempoTransactionReceipt; -use tempo_chainspec::hardfork::TempoHardfork; use tempo_primitives::{TempoReceipt, TempoTxType}; #[derive(Deserialize)] @@ -418,7 +417,7 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { let user = address!("0x0000000000000000000000000000000000000022"); let old_header = seal(make_test_header(10)); - subscriber.update_l1_state_anchor(&old_header, 4217, &HashSet::new()); + subscriber.update_l1_state_anchor(&old_header, &HashSet::new()); { let mut cache = subscriber.config.l1_state_cache.write(); cache.set( @@ -427,7 +426,6 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { 10, B256::with_last_byte(0xaa), ); - cache.extend_hardfork_schedule(10, [(0, TempoHardfork::T0)]); } { let mut cache = subscriber.config.policy_cache.write(); @@ -439,7 +437,7 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { let replacement_parent = B256::with_last_byte(0x44); let replacement_header = seal(make_chained_header(11, replacement_parent)); - subscriber.update_l1_state_anchor(&replacement_header, 4217, &HashSet::new()); + subscriber.update_l1_state_anchor(&replacement_header, &HashSet::new()); assert_eq!( subscriber .config @@ -449,11 +447,6 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { None, "reorg must clear raw L1 state" ); - assert_eq!( - subscriber.config.l1_state_cache.read().hardfork_at(10), - None, - "reorg must clear hardfork metadata" - ); subscriber.apply_policy_events( 11, &[ From b35a82acb5408c96e38860412205ecae1a1dc634 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 08:14:02 +0200 Subject: [PATCH 05/22] style: alias complex type --- crates/l1/src/subscriber.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index 826e044aa..0d204f50a 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -47,6 +47,9 @@ where } } +/// Events extracted from an L1 block: portal events, policy events, and tracked tokens. +type SubscriberEventsOutput = (L1PortalEvents, Vec, HashSet
); + /// L1 chain subscriber that listens for new blocks and extracts deposit events. #[derive(Clone)] pub struct L1Subscriber { @@ -468,11 +471,7 @@ impl L1Subscriber { // A block is only flushed to the deposit queue once the NEXT block // arrives with a matching parent hash, proving the buffered block // is on the canonical chain. - #[allow(clippy::type_complexity)] - let mut unconfirmed_tip: Option<( - SealedHeader, - (L1PortalEvents, Vec, HashSet
), - )> = None; + let mut unconfirmed_tip: Option<(SealedHeader, SubscriberEventsOutput)> = None; loop { let stream_wait_start = std::time::Instant::now(); @@ -549,7 +548,7 @@ impl L1Subscriber { &mut self, block_number: u64, receipts: &[tempo_alloy::rpc::TempoTransactionReceipt], - ) -> (L1PortalEvents, Vec, HashSet
) { + ) -> SubscriberEventsOutput { use tempo_contracts::precompiles::{ITIP20::TransferPolicyUpdate, TIP403_REGISTRY_ADDRESS}; let portal_address = self.config.portal_address; From 8836842b2698c5249e8ee08a3b6b8b7a5711bae5 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 19:07:03 +0200 Subject: [PATCH 06/22] fix(subscriber): track portal --- crates/l1/src/subscriber.rs | 1 + crates/l1/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index 0d204f50a..80c09e357 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -561,6 +561,7 @@ impl L1Subscriber { let addr = log.address(); if addr == portal_address { + mutated_accounts.insert(addr); let prev_len = portal_events.enabled_tokens.len(); if let Err(e) = portal_events.push_log(log, block_number) { warn!(block_number, %e, "Failed to decode portal event from receipt"); diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index d58f624fe..718f8bd5a 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -503,6 +503,24 @@ fn make_portal_log(portal_address: Address, event: E) -> Log { make_log(portal_address, event.encode_log_data()) } +#[test] +fn extract_events_tracks_portal_mutations() { + let mut subscriber = test_subscriber( + Arc::new(SequenceLocalTempoCheckpointReader::new([0])), + Some(0), + ); + let portal = subscriber.config.portal_address; + let log = make_log( + portal, + LogData::new_unchecked(vec![B256::repeat_byte(0xff)], Bytes::new()), + ); + + let (_, _, mutated_accounts) = + subscriber.extract_events(1, &[make_test_receipt_with_logs(vec![log])]); + + assert_eq!(mutated_accounts, HashSet::from([portal])); +} + #[test] fn extract_events_conservatively_tracks_policy_mutations() { use tempo_contracts::precompiles::{ITIP20::TransferPolicyUpdate, TIP403_REGISTRY_ADDRESS}; From de4fe49d7658bb6941ba8a874325a19dce5ff6e4 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 15 Jul 2026 21:40:17 +0200 Subject: [PATCH 07/22] fix(l1): lazily prune raw state cache histories --- crates/l1/src/state/cache.rs | 138 ++++++++++++++++++++++---------- crates/l1/src/state/provider.rs | 4 +- crates/node/src/engine.rs | 10 ++- 3 files changed, 102 insertions(+), 50 deletions(-) diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index f30a93ffe..a733015de 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -15,8 +15,9 @@ //! //! - The [`L1Subscriber`](crate::l1::L1Subscriber) writes storage diffs for tracked contracts //! as they arrive, tagged with the L1 tip block number. -//! - The [`L1StateProvider`](super::provider::L1StateProvider) writes RPC-fetched values on -//! cache miss, tagged with the block number that was requested. +//! - The [`L1StateProvider`](super::provider::L1StateProvider) writes eligible forward RPC +//! misses, tagged with the requested block number. Misses below the engine's consumed-block +//! floor are returned without being inserted. //! //! ## Reorg handling //! @@ -59,8 +60,10 @@ impl L1StateCache { /// storage change without requiring slot-level decoding. Reorgs clear slot values and mutation /// history atomically. /// -/// The anchor tracks the latest L1 block the cache has received data for, used by the -/// [`L1Subscriber`](crate::l1::L1Subscriber) for reorg detection. +/// The subscriber anchor and engine floor track independent progress. The anchor is the latest +/// confirmed L1 block observed by the subscriber and may run ahead while blocks are queued. The +/// floor is the latest L1 height consumed by the engine. It advances monotonically and drives +/// lazy history compaction without scanning the cache on the block-production path. #[derive(Debug, Default)] pub struct L1StateCacheInner { tracked_contracts: HashSet
, @@ -71,7 +74,15 @@ pub struct L1StateCacheInner { /// A slot value cached at block V may serve block N only when no barrier exists in `(V, N]`. /// The subscriber records barriers for contracts whose logs imply possible storage changes. invalidations: HashMap>, - /// Latest L1 block the cache has received data for, used for reorg detection. + /// Latest L1 block height successfully consumed by the Zone engine. + /// + /// New fallback values below this floor are not admitted. Histories are compacted lazily + /// against it when their slot/address is next mutated, so older entries may remain physically + /// present until touched. This floor may lag the subscriber [`anchor`](Self::anchor). + block_floor: u64, + /// Latest confirmed L1 block observed by the subscriber, used for reorg detection. + /// + /// The anchor may run ahead of [`block_floor`](Self::block_floor) while L1 blocks are queued. anchor: NumHash, } @@ -109,26 +120,35 @@ impl L1StateCacheInner { /// /// Values subsequently inserted at the same block are post-block state and remain valid. pub fn invalidate(&mut self, address: Address, block_number: u64) { - self.invalidations - .entry(address) - .or_default() - .insert(block_number); + let blocks = self.invalidations.entry(address).or_default(); + blocks.insert(block_number); + prune_invalidation_history(blocks, self.block_floor); } - /// Sets a storage slot value in the cache at the given block number. + /// Sets a storage slot value in the forward cache at the given block number. + /// + /// Fallback results below the engine's consumed-block floor are deliberately not cached. pub fn set(&mut self, address: Address, slot: B256, block_number: u64, value: B256) { - self.slots - .entry((address, slot)) - .or_default() - .insert(block_number, value); + if block_number < self.block_floor { + return; + } + + let history = self.slots.entry((address, slot)).or_default(); + history.insert(block_number, value); + prune_slot_history(history, self.block_floor); + } + + /// Advances the engine's consumed-block floor monotonically in O(1). + pub fn advance_floor(&mut self, block_number: u64) { + self.block_floor = self.block_floor.max(block_number); } - /// Updates the anchor block that this cache has received data up to. + /// Updates the latest confirmed L1 block observed by the subscriber. pub fn update_anchor(&mut self, anchor: NumHash) { self.anchor = anchor; } - /// Returns the current anchor block. + /// Returns the latest confirmed L1 block observed by the subscriber. pub fn anchor(&self) -> NumHash { self.anchor } @@ -138,38 +158,38 @@ impl L1StateCacheInner { self.tracked_contracts.contains(address) } - /// Clears all cached slot values but retains the tracked-contract set. + /// Clears subscriber-derived chain data while retaining tracked contracts and the engine floor. pub fn clear(&mut self) { self.slots.clear(); self.invalidations.clear(); self.anchor = NumHash::default(); } +} + +/// Retains the latest pre-floor entry as a baseline and every newer entry. +fn prune_slot_history(history: &mut BTreeMap, min_block: u64) { + if history.range(..min_block).nth(1).is_none() { + return; + } - /// Remove all entries with block numbers strictly less than `min_block`. - /// - /// Retains at most one entry per slot below the threshold — the latest one — so that - /// lookups at `min_block` still have a baseline value. - pub fn prune_before(&mut self, min_block: u64) { - for history in self.slots.values_mut() { - let keep_from = history.range(..min_block).next_back().map(|(k, _)| *k); - - if let Some(keep) = keep_from { - let to_remove: Vec = history.range(..keep).map(|(k, _)| *k).collect(); - for k in to_remove { - history.remove(&k); - } - } - } + let mut retained = history.split_off(&min_block); + if let Some(baseline) = history.pop_last() { + retained.insert(baseline.0, baseline.1); + } + *history = retained; +} - self.slots.retain(|_, history| !history.is_empty()); +/// Retains the latest pre-floor barrier and every newer barrier. +fn prune_invalidation_history(history: &mut BTreeSet, min_block: u64) { + if history.range(..min_block).nth(1).is_none() { + return; + } - // Retain the latest pre-boundary invalidation so a pruned stale value cannot become valid. - for blocks in self.invalidations.values_mut() { - let baseline = blocks.range(..=min_block).next_back().copied(); - blocks.retain(|block| *block >= min_block || Some(*block) == baseline); - } - self.invalidations.retain(|_, blocks| !blocks.is_empty()); + let mut retained = history.split_off(&min_block); + if let Some(baseline) = history.pop_last() { + retained.insert(baseline); } + *history = retained; } #[cfg(test)] @@ -231,11 +251,12 @@ mod tests { } #[test] - fn clear_removes_slots_invalidations_and_resets_anchor() { + fn clear_removes_chain_data_but_preserves_engine_floor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1)); cache.invalidate(PORTAL, 101); + cache.advance_floor(100); cache.update_anchor(NumHash { number: 100, hash: B256::with_last_byte(0xab), @@ -245,6 +266,7 @@ mod tests { assert_eq!(cache.get(PORTAL, B256::ZERO, 100), None); assert!(cache.invalidations.is_empty()); + assert_eq!(cache.block_floor, 100); assert_eq!(cache.anchor(), NumHash::default()); } @@ -321,19 +343,27 @@ mod tests { } #[test] - fn prune_keeps_baseline_entry_and_invalidation() { + fn advance_floor_is_lazy_and_touched_histories_keep_their_baselines() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05)); cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a)); cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14)); + cache.invalidate(PORTAL, 5); cache.invalidate(PORTAL, 12); cache.invalidate(PORTAL, 18); - cache.prune_before(15); + cache.advance_floor(15); - assert_eq!(cache.get(PORTAL, slot, 5), None); + // Advancing only moves the floor. + assert_eq!( + cache.slots[&(PORTAL, slot)] + .keys() + .copied() + .collect::>(), + vec![5, 10, 20] + ); assert_eq!( cache.get(PORTAL, slot, 10), Some(B256::with_last_byte(0x0a)) @@ -344,12 +374,32 @@ mod tests { cache.get(PORTAL, slot, 20), Some(B256::with_last_byte(0x14)) ); + + // A historical fallback cannot repopulate the forward cache. + cache.set(PORTAL, slot, 14, B256::with_last_byte(0xee)); + assert!(!cache.slots[&(PORTAL, slot)].contains_key(&14)); + + // Touching one slot/address compacts only that history and preserves its baseline. + cache.set(PORTAL, slot, 15, B256::with_last_byte(0x0f)); + cache.invalidate(PORTAL, 21); + assert_eq!(cache.get(PORTAL, slot, 5), None); + assert_eq!( + cache.slots[&(PORTAL, slot)] + .keys() + .copied() + .collect::>(), + vec![10, 15, 20] + ); assert_eq!( cache.invalidations[&PORTAL] .iter() .copied() .collect::>(), - vec![12, 18] + vec![12, 18, 21] + ); + assert_eq!( + cache.get(PORTAL, slot, 15), + Some(B256::with_last_byte(0x0f)) ); } } diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index cb5a3fd4d..a01d0b092 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -2,8 +2,8 @@ //! //! [`L1StateProvider`] wraps a [`L1StateCache`] and a [`DynProvider`] backed by an //! HTTP transport. Reads are served from the in-memory cache when possible. On cache miss the -//! provider falls back to `eth_getStorageAt` via the shared HTTP provider and writes the result -//! back into the cache. +//! provider falls back to `eth_getStorageAt` via the shared HTTP provider, and forward reads are +//! written back. Misses below the engine's consumed-block floor are returned without caching. //! //! Both a synchronous ([`L1StateProvider::get_storage`]) and an asynchronous //! ([`L1StateProvider::get_storage_async`]) entry point are provided. The synchronous variant is diff --git a/crates/node/src/engine.rs b/crates/node/src/engine.rs index d6a4b80fb..18b5886b9 100644 --- a/crates/node/src/engine.rs +++ b/crates/node/src/engine.rs @@ -281,10 +281,12 @@ impl ZoneEngine { // could return wrong results. self.policy_provider.cache().advance(l1_num_hash.number); - // The engine has committed this L1 height, so no subsequent zone block can query an - // earlier height while building. Preserve only the baseline needed at this boundary and - // discard older slot, invalidation, and hardfork history accumulated by the subscriber. - self.l1_state_cache.write().prune_before(l1_num_hash.number); + // The engine has committed this L1 height, so advance the forward-cache floor. + // Individual slot and mutation histories are compacted lazily when next touched, so + // RPC-created slots cannot amplify engine work. + self.l1_state_cache + .write() + .advance_floor(l1_num_hash.number); self.last_header = header; From 01d536da0ccba5c2616d0d1b7a92517b7854611c Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 07:39:06 +0200 Subject: [PATCH 08/22] fix(l1): bypass cache below block floor --- crates/l1/src/state/cache.rs | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index a733015de..15a5270c1 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -97,9 +97,14 @@ impl L1StateCacheInner { /// Returns the cached value for a storage slot at the given block number. /// - /// Returns the most recent value at or before `block_number`, or `None` if no - /// value has been cached for this slot at or before the requested block. + /// Returns `None` below the engine floor because pruned mutation history cannot safely serve + /// historical inheritance. Otherwise returns the most recent valid value at or before + /// `block_number`. pub fn get(&self, address: Address, slot: B256, block_number: u64) -> Option { + if block_number < self.block_floor { + return None; + } + let (value_block, value) = self .slots .get(&(address, slot))? @@ -364,10 +369,7 @@ mod tests { .collect::>(), vec![5, 10, 20] ); - assert_eq!( - cache.get(PORTAL, slot, 10), - Some(B256::with_last_byte(0x0a)) - ); + assert_eq!(cache.get(PORTAL, slot, 10), None); assert_eq!(cache.get(PORTAL, slot, 15), None); assert_eq!(cache.get(PORTAL, slot, 19), None); assert_eq!( @@ -402,4 +404,25 @@ mod tests { Some(B256::with_last_byte(0x0f)) ); } + + #[test] + fn below_floor_lookup_misses_after_invalidation_pruning() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + let slot = B256::with_last_byte(1); + + cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05)); + cache.invalidate(PORTAL, 10); + cache.invalidate(PORTAL, 20); + cache.advance_floor(30); + cache.invalidate(PORTAL, 40); + + assert_eq!( + cache.invalidations[&PORTAL] + .iter() + .copied() + .collect::>(), + vec![20, 40] + ); + assert_eq!(cache.get(PORTAL, slot, 15), None); + } } From 757a00547ac38fc6e55da3387d5c531d76b5f8a3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <90208954+0xrusowsky@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:05:04 +0200 Subject: [PATCH 09/22] feat(precompiles): l1-backed storage provider (#628) --- Cargo.lock | 1 + crates/evm/src/lib.rs | 96 +-- 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 | 219 ++++- crates/precompiles/Cargo.toml | 1 + crates/precompiles/src/aes_gcm/mod.rs | 102 +-- crates/precompiles/src/chaum_pedersen/mod.rs | 39 - crates/precompiles/src/execution.rs | 508 ++++++++++++ crates/precompiles/src/lib.rs | 190 ++++- crates/precompiles/src/policy.rs | 45 - crates/precompiles/src/storage.rs | 544 ++++++++++++ crates/precompiles/src/tempo_state.rs | 203 ++--- crates/precompiles/src/test_utils.rs | 278 +++++++ crates/precompiles/src/tip20_factory/mod.rs | 43 - .../precompiles/src/tip403_proxy/dispatch.rs | 213 ----- crates/precompiles/src/tip403_proxy/mod.rs | 376 +++++++-- crates/precompiles/src/ztip20/dispatch.rs | 229 ----- crates/precompiles/src/ztip20/mod.rs | 785 ++++++++---------- docs/ZONES.md | 4 +- specs/spec.md | 6 +- 23 files changed, 2563 insertions(+), 1674 deletions(-) create mode 100644 crates/precompiles/src/execution.rs delete mode 100644 crates/precompiles/src/policy.rs create mode 100644 crates/precompiles/src/storage.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 39d30dbdf..09e5f8f43 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,18 +43,13 @@ 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}; +use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; type TempoCtx = ::Context; @@ -67,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>>( @@ -91,67 +72,16 @@ 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(), + 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 } } @@ -295,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..78cc451c9 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,8 @@ 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); } let node_handle = NodeBuilder::new(node_config) @@ -3138,6 +3270,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 +3287,7 @@ impl L1Fixture { next_block_number: 1, next_timestamp: 1_000_000, last_hash: genesis_hash, + caches: Mutex::new(Vec::new()), } } @@ -3164,16 +3299,27 @@ 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); 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]; @@ -3200,10 +3346,51 @@ impl L1Fixture { ); } + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); 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); + } + } + + 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. @@ -3249,6 +3436,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![]); @@ -3261,6 +3449,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![]); } @@ -3289,6 +3484,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, @@ -3313,6 +3509,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/aes_gcm/mod.rs b/crates/precompiles/src/aes_gcm/mod.rs index 1960972b7..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). @@ -117,33 +82,11 @@ pub fn decrypt_aes_gcm( #[cfg(test)] mod tests { use super::*; - use alloy_evm::{ - EvmInternals, - precompiles::{Precompile, PrecompileInput}, - }; - use alloy_primitives::{Bytes, U256}; + use crate::test_utils::{test_context, test_storage_provider}; + use alloy_primitives::Bytes; use alloy_sol_types::SolCall; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - 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 revm::precompile::PrecompileOutput; + use tempo_precompiles::{charge_input_cost, storage::StorageCtx}; fn encrypt(plaintext: &[u8], aad: &[u8]) -> decryptCall { let key = [0x42u8; 32]; @@ -173,34 +116,23 @@ 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 { 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/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..1d199e124 --- /dev/null +++ b/crates/precompiles/src/execution.rs @@ -0,0 +1,508 @@ +//! 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` and +//! overlays selected policy storage from that exact L1 block. Execution uses the active Tempo +//! hardfork already selected by the composed Zone EVM configuration. +//! +//! # Call ordering +//! +//! 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 and install the 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 +//! 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, PrecompileInput}; +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::{StorageCtx, actions::StorageActions, evm::EvmPrecompileStorageProvider}, + storage_credits::NonCreditableSlots, +}; + +use crate::storage::{L1StorageReader, ZonePrecompileStorageProvider}; + +/// 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 is resolved from the local `TempoState` anchor during execution, while the active +/// hardfork comes from the configuration already resolved by the composed Zone EVM. +#[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, + } + } +} + +/// Call metadata, independent of EVM internals, for [`CallRules`] running in a [`StorageCtx`]. +/// Provider-free precompiles can inspect `PrecompileInput` directly. +/// +/// **MOTIVATION:** Execution helpers move `PrecompileInput::internals` into the +/// [`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)] +#[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], + /// 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. + /// + /// 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-, 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 + } + + /// 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 + } + + /// 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 + } +} + +/// 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 check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + if call.is_direct { + CallCheck::Continue + } else { + CallCheck::Return(Ok(StorageCtx::default() + .revert_output(SolError::abi_encode(&DelegateCallNotAllowed {}).into()))) + } + } +} + +/// 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: 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 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, + input.reservoir, + )); + } + + 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(), + ); + + if let Some(check_result) = + StorageCtx::enter(&mut storage, || match rules.check_with_local_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) + }) +} + +/// 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. The active hardfork +/// comes from the composed Zone EVM configuration rather than the anchor. Any anchor 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: 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; + 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| { + let call = ZoneCall::new(&input); + if !call.is_direct { + return Ok(PrecompileOutput::revert( + 0, + SolError::abi_encode(&DelegateCallNotAllowed {}).into(), + input.reservoir, + )); + } + + let fixed_gas = rules.fixed_gas(call.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()); + + 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 mut storage = match ZonePrecompileStorageProvider::try_new(inner, l1_reader.clone()) { + Ok(storage) => storage, + Err(err) => return err.into_precompile_result(), + }; + + 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) + }) +} + +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 { + let mut storage = StorageCtx::default(); + let gas_before = storage.gas_used(); + if let Some(err) = charge_input_cost(&mut storage, calldata) { + return err; + } + 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); + } + 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, + }; + use tempo_precompiles::storage::PrecompileStorageProvider; + + 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_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + *self.0.borrow_mut() = Some(( + Bytes::copy_from_slice(call.data), + call.selector(), + call.caller, + )); + CallCheck::Continue + } + } + + 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_admission_precedes_anchored_provider() { + let anchor = 42; + 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 env = L1BackedPrecompileEnv::new( + &cfg, + reader, + 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()); + + let precompile = + create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { + execute_spec.set(Some(StorageCtx::default().spec())); + Ok(StorageCtx::default().success_output(Bytes::new())) + }); + 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!(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_with_local_state(&self, _call: ZoneCall<'_>) -> CallCheck { + self.0.set(true); + CallCheck::Return(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 32427d908..28d2b323a 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -1,4 +1,12 @@ -//! 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. 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. @@ -14,17 +22,14 @@ //! ## 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)] 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}; @@ -40,7 +45,8 @@ pub mod dispatch { }; } -pub mod policy; +mod execution; +pub mod storage; pub mod tempo_state; pub mod tip20_factory; pub mod tip403_proxy; @@ -48,12 +54,174 @@ 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}; +pub use tip403_proxy::ZONE_TIP403_PROXY_ADDRESS; +pub use ztip20::SequencerExt; + +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::{TIP20Token, is_tip20_prefix}, + tip403_registry::TIP403Registry, +}; +use zone_primitives::constants::TEMPO_STATE_ADDRESS; + +/// Register zone-native and currently supported Tempo 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, + sequencer: Arc, + actions: StorageActions, + non_creditable_slots: Rc>, +) { + 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)) + }); + + 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. 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(create_tip20_precompile( + *address, + &l1_env, + 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 + } + }); +} -use revm::precompile::PrecompileError; +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), + ) + } +} + +/// 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]"; 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/storage.rs b/crates/precompiles/src/storage.rs new file mode 100644 index 000000000..84e8b28dd --- /dev/null +++ b/crates/precompiles/src/storage.rs @@ -0,0 +1,544 @@ +//! 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, PrecompileResult}, + state::{AccountInfo, Bytecode}, +}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_contracts::precompiles::{TIP403_REGISTRY_ADDRESS, TIP403RegistryError}; +use tempo_precompiles::{ + error::{Result, TempoPrecompileError}, + storage::evm::EvmPrecompileStorageProvider, + tip20::{TIP20Error, 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; +} + +/// 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: 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> { + /// 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, + l1_block_number, + } + } +} + +/// Read the finalized Tempo/L1 block number once before constructing the zone provider. +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 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) + } + + 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.inner.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 { + if is_tip20_policy_id_slot(address, key) { + TIP20Error::invalid_transfer_policy_id().into() + } else { + TIP403RegistryError::unauthorized().into() + } +} + +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 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, TestContext, test_context, test_storage_provider}; + use tempo_precompiles::{ + PATH_USD_ADDRESS, + storage::{StorageAction, actions::StorageActions}, + }; + + fn with_zone_provider( + ctx: &mut TestContext, + 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 mut provider = + ZonePrecompileStorageProvider::try_new(inner, l1).expect("anchor read succeeds"); + f(&mut provider) + } + + #[test] + fn provider_uses_composed_evm_hardfork() { + let mut ctx = test_context(); + ctx.cfg.spec = TempoHardfork::T8; + let provider = ZonePrecompileStorageProvider::new_at_block( + test_storage_provider(&mut ctx, u64::MAX, false), + MockL1Reader::default(), + 77, + ); + + assert_eq!(provider.spec(), TempoHardfork::T8); + } + + #[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 = match ZonePrecompileStorageProvider::try_new(inner, MockL1Reader::default()) { + Ok(_) => panic!("oversized anchor must be rejected"), + Err(err) => err, + }; + assert!( + 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] + 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 crate::is_zone_rpc_error(&msg) + && msg.contains(&TIP403_REGISTRY_ADDRESS.to_string()) + && msg.contains("block=123") + )); + }, + ); + } + + #[test] + fn sstore_sinc_sdec_reject_l1_slots_with_precompile_reverts() { + 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 { + 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()); + } + } + + 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); + // 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)) + .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(), 3); + } + + #[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..772c1917e 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -3,19 +3,16 @@ //! 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::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; @@ -29,17 +26,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, @@ -188,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], @@ -255,42 +208,14 @@ impl TempoState { mod tests { use super::*; - 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 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) - } - } + use tempo_precompiles::storage::StorageCtx; fn encode_header(header: &TempoHeader) -> Bytes { let mut encoded = Vec::new(); @@ -298,23 +223,8 @@ mod tests { 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 TestContext, header: &[u8]) -> eyre::Result<()> { + let mut storage = test_storage_provider(ctx, u64::MAX, false); StorageCtx::enter(&mut storage, || TempoState::new().initialize(header))?; Ok(()) @@ -327,37 +237,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, ) } @@ -402,7 +290,7 @@ mod tests { precompile: &DynPrecompile, expected_hash: B256, expected_number: u64, - ) -> TestResult { + ) -> eyre::Result<()> { let block_hash = call( ctx, precompile, @@ -430,20 +318,20 @@ 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(); 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(()) } #[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); @@ -453,7 +341,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, @@ -469,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); @@ -477,7 +365,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, @@ -493,28 +381,35 @@ 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)?; - let precompile = TempoState::create(MockL1Reader { value: B256::ZERO }, &ctx.cfg.clone()); - let output = call_with_bytecode_address( + let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + 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, + tempo_precompiles::input_cost(calldata.len()) + ); Ok(()) } #[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); @@ -522,7 +417,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, @@ -538,14 +433,14 @@ 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); 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, @@ -561,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); @@ -571,7 +466,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, @@ -587,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); @@ -595,7 +490,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, @@ -611,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); @@ -619,7 +514,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, @@ -635,13 +530,13 @@ 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)?; 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, @@ -668,13 +563,13 @@ 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)?; 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..ecb545cee 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -1,18 +1,296 @@ //! Shared test utilities for precompile tests. +use std::{ + cell::RefCell, + collections::HashMap, + rc::Rc, + sync::{Arc, Mutex}, +}; + +use alloy_evm::{ + EvmInternals, + precompiles::{DynPrecompile, Precompile as _, PrecompileInput}, +}; 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, PrecompileResult}, +}; +use tempo_chainspec::hardfork::TempoHardfork; +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}; +/// EVM context used by precompile tests. +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() -> 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 TestContext, + 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, + ) +} + +/// 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, + 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), + }) +} + +// 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)] +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, + ..Default::default() + } + } + + pub(crate) fn failing_storage() -> Self { + Self { + fail_storage: 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 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 { + 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(crate::zone_rpc_error("RPC unavailable")); + } + if let Some(value) = self + .slots + .lock() + .unwrap() + .get(&(account, slot, block_number)) + .copied() + { + 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())) + } + } +} + /// 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/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)) - }, - ) - } } 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..091ea6e5a 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -1,92 +1,332 @@ -//! 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_with_local_state(&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_chainspec::hardfork::TempoHardfork; + 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, } - /// Resolve the `transferPolicyId` for a token. - pub fn resolve_transfer_policy_id(&self, token: Address) -> Result { - self.provider.resolve_transfer_policy_id(token) + 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, + 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, + ) + } } - /// 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) + 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 } - /// 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); + 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); } - self.is_authorized(policy_id, to, AuthRole::Recipient) + + 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(()) + } + + #[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.storage_requests().is_empty()); + Ok(()) + } + + #[test] + 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 { + policyId: 5, + user: ALICE, + } + .abi_encode(); + let output = harness.call(&call, u64::MAX)?; + assert!(output.is_success()); + + let requests = reader.storage_requests(); + assert!(!requests.is_empty()); + assert!(requests.iter().all(|(_, _, block)| *block == ANCHOR)); + Ok(()) + } + + #[test] + fn anchored_storage_failures_fail_closed() { + let call = ITIP403Registry::isAuthorizedCall { + policyId: 5, + user: ALICE, + } + .abi_encode(); + + 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!( + 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 b64901a27..2cc740425 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -1,232 +1,165 @@ -//! 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; -const 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>; + +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_with_local_state(&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(()) } } @@ -234,101 +167,21 @@ impl ZoneTip20Token

{ mod tests { use super::*; 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 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, - storage::evm::EvmPrecompileStorageProvider, - tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, TIP20Token}, + storage::StorageCtx, + tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, RolesAuthError, TIP20Token}, }; + use tempo_zone_contracts::Unauthorized; - type TestResult = Result>; - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - - #[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 { @@ -348,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_without_registry() -> TestResult { - Self::new_with_registry(None) + fn new(l1_reader: MockL1Reader) -> eyre::Result { + Self::new_with_l1(l1_reader) } - 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"); @@ -369,10 +217,16 @@ 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()); - - Self::with_storage(&mut ctx, u64::MAX, |storage| { - StorageCtx::enter(storage, || -> TestResult { + let mut ctx = test_context(); + + { + 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( @@ -409,13 +263,15 @@ mod tests { }, )?; Ok(()) - }) - })?; + })?; + } + + l1_reader.seed_transfer_policy_id(token, 7); - let precompile = ZoneTip20Token::create( + 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), }), @@ -428,32 +284,10 @@ mod tests { bob, spender, sequencer, - issuer, precompile, }) } - fn with_storage( - ctx: &mut TestContext, - 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) - } - fn call( &mut self, caller: Address, @@ -461,44 +295,38 @@ 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 { - 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 })?) - }) + 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"); + 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 })?) - }) + 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"); + Ok(token.allowance(ITIP20::allowanceCall { owner, spender })?) }) } } #[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, } @@ -525,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, @@ -560,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, @@ -570,7 +398,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, true, )?; assert!(private_balance.is_revert()); @@ -587,29 +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: TestContext = - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); - let precompile = ZoneTip20Token::create( - token, - &ctx.cfg, - Some(ZoneTip403ProxyRegistry::new(MockPolicyProvider::failing())), - Arc::new(MockSequencer { address: None }), - ); + let mut ctx = test_context(); + 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), @@ -617,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()); @@ -642,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, @@ -705,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, @@ -745,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( @@ -759,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( @@ -773,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( @@ -787,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( @@ -802,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( @@ -817,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( @@ -833,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 { @@ -883,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)); @@ -893,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, @@ -904,7 +725,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(approve.is_success()); @@ -921,7 +742,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(transfer.is_success()); @@ -931,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, @@ -1001,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, @@ -1011,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 bd7cf429a9b208b67a89b4bf8faa4a8be346f828 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 17:00:23 +0200 Subject: [PATCH 10/22] fix(tests): seed L1 policy state before cache reads --- crates/l1/src/lib.rs | 3 +- crates/l1/src/state/cache.rs | 57 ++++++++++----- crates/l1/src/state/provider.rs | 54 +++++++++++++- crates/l1/src/subscriber.rs | 48 +++++------- crates/l1/src/tests.rs | 14 ++-- crates/node/src/node.rs | 15 ++++ crates/node/tests/it/tip403_policy.rs | 87 +++++++++++----------- crates/node/tests/it/tip403_transfers.rs | 10 ++- crates/node/tests/it/utils.rs | 93 +++++++++++++++--------- 9 files changed, 241 insertions(+), 140 deletions(-) diff --git a/crates/l1/src/lib.rs b/crates/l1/src/lib.rs index ad8033386..c9f07b8b6 100644 --- a/crates/l1/src/lib.rs +++ b/crates/l1/src/lib.rs @@ -102,6 +102,5 @@ pub(crate) use event::EnqueueOutcome; pub(crate) use queue::PendingDeposits; #[cfg(test)] pub(crate) use subscriber::{ - LocalTempoCheckpointReader, address_to_storage_value, apply_sequencer_events_to_cache, - verify_receipts, + LocalTempoCheckpointReader, apply_sequencer_events_to_cache, verify_receipts, }; diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index 15a5270c1..ef191e7ce 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -132,15 +132,19 @@ impl L1StateCacheInner { /// Sets a storage slot value in the forward cache at the given block number. /// - /// Fallback results below the engine's consumed-block floor are deliberately not cached. - pub fn set(&mut self, address: Address, slot: B256, block_number: u64, value: B256) { + /// Returns `false` without inserting when `block_number` is below the engine's consumed-block + /// floor. Callers that materialize synthetic/test state should check the result so a rejected + /// seed cannot turn into an unexpected RPC fallback. + #[must_use = "check whether the cache write was admitted above the block floor"] + pub fn set(&mut self, address: Address, slot: B256, block_number: u64, value: B256) -> bool { if block_number < self.block_floor { - return; + return false; } let history = self.slots.entry((address, slot)).or_default(); history.insert(block_number, value); prune_slot_history(history, self.block_floor); + true } /// Advances the engine's consumed-block floor monotonically in O(1). @@ -148,6 +152,11 @@ impl L1StateCacheInner { self.block_floor = self.block_floor.max(block_number); } + /// Returns the latest L1 height consumed by canonical Zone execution. + pub fn block_floor(&self) -> u64 { + self.block_floor + } + /// Updates the latest confirmed L1 block observed by the subscriber. pub fn update_anchor(&mut self, anchor: NumHash) { self.anchor = anchor; @@ -216,17 +225,27 @@ mod tests { let slot = B256::with_last_byte(1); let value = B256::with_last_byte(0xff); - cache.set(PORTAL, slot, 10, value); + assert!(cache.set(PORTAL, slot, 10, value)); assert_eq!(cache.get(PORTAL, slot, 10), Some(value)); } + #[test] + fn set_reports_rejection_below_floor() { + let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); + let slot = B256::with_last_byte(1); + cache.advance_floor(10); + + assert!(!cache.set(PORTAL, slot, 9, B256::with_last_byte(0xff))); + assert_eq!(cache.get(PORTAL, slot, 10), None); + } + #[test] fn get_returns_latest_value_at_or_before_requested_block() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a)); - cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14)); + assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a))); + assert!(cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14))); assert_eq!( cache.get(PORTAL, slot, 10), @@ -251,7 +270,7 @@ mod tests { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - cache.set(PORTAL, slot, 10, B256::with_last_byte(0xff)); + assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0xff))); assert_eq!(cache.get(PORTAL, slot, 9), None); } @@ -259,7 +278,7 @@ mod tests { fn clear_removes_chain_data_but_preserves_engine_floor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1)); + assert!(cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1))); cache.invalidate(PORTAL, 101); cache.advance_floor(100); cache.update_anchor(NumHash { @@ -283,8 +302,8 @@ mod tests { let new = B256::with_last_byte(0x14); let other = Address::with_last_byte(0x43); - cache.set(PORTAL, slot, 10, old); - cache.set(other, slot, 10, old); + assert!(cache.set(PORTAL, slot, 10, old)); + assert!(cache.set(other, slot, 10, old)); cache.invalidate(PORTAL, 20); cache.invalidate(PORTAL, 20); // Multiple logs in one block deduplicate. @@ -294,7 +313,7 @@ mod tests { assert_eq!(cache.get(other, slot, 30), Some(old)); assert_eq!(cache.invalidations[&PORTAL].len(), 1); - cache.set(PORTAL, slot, 20, new); + assert!(cache.set(PORTAL, slot, 20, new)); assert_eq!(cache.get(PORTAL, slot, 20), Some(new)); assert_eq!(cache.get(PORTAL, slot, 30), Some(new)); @@ -334,8 +353,8 @@ mod tests { let addr_b = address!("0x0000000000000000000000000000000000004343"); let slot = B256::with_last_byte(1); - cache.set(PORTAL, slot, 10, B256::with_last_byte(0xaa)); - cache.set(addr_b, slot, 10, B256::with_last_byte(0xbb)); + assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0xaa))); + assert!(cache.set(addr_b, slot, 10, B256::with_last_byte(0xbb))); assert_eq!( cache.get(PORTAL, slot, 10), @@ -352,9 +371,9 @@ mod tests { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05)); - cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a)); - cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14)); + assert!(cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05))); + assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a))); + assert!(cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14))); cache.invalidate(PORTAL, 5); cache.invalidate(PORTAL, 12); cache.invalidate(PORTAL, 18); @@ -378,11 +397,11 @@ mod tests { ); // A historical fallback cannot repopulate the forward cache. - cache.set(PORTAL, slot, 14, B256::with_last_byte(0xee)); + assert!(!cache.set(PORTAL, slot, 14, B256::with_last_byte(0xee))); assert!(!cache.slots[&(PORTAL, slot)].contains_key(&14)); // Touching one slot/address compacts only that history and preserves its baseline. - cache.set(PORTAL, slot, 15, B256::with_last_byte(0x0f)); + assert!(cache.set(PORTAL, slot, 15, B256::with_last_byte(0x0f))); cache.invalidate(PORTAL, 21); assert_eq!(cache.get(PORTAL, slot, 5), None); assert_eq!( @@ -410,7 +429,7 @@ mod tests { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05)); + assert!(cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05))); cache.invalidate(PORTAL, 10); cache.invalidate(PORTAL, 20); cache.advance_floor(30); diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index a01d0b092..d58bdbcf9 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -41,6 +41,8 @@ pub struct L1StateProviderConfig { /// Interval between WebSocket reconnection attempts. /// Defaults to 100ms. pub retry_connection_interval: std::time::Duration, + /// Maximum number of synchronous RPC attempts per cache miss. `None` retries indefinitely. + pub max_sync_attempts: Option, } impl Default for L1StateProviderConfig { @@ -52,6 +54,7 @@ impl Default for L1StateProviderConfig { max_retries: 10, initial_backoff_ms: 20, retry_connection_interval: std::time::Duration::from_millis(100), + max_sync_attempts: None, } } } @@ -86,6 +89,8 @@ pub struct L1StateProvider { /// Handle to the tokio runtime, used by [`get_storage`](Self::get_storage) to /// dispatch async RPC calls from a blocking (non-async) context. runtime_handle: tokio::runtime::Handle, + /// Optional finite attempt limit for synchronous cache-miss fallback. + max_sync_attempts: Option, } impl L1StateProvider { @@ -134,6 +139,7 @@ impl L1StateProvider { portal_address: config.portal_address, provider, runtime_handle, + max_sync_attempts: config.max_sync_attempts, }) } @@ -153,6 +159,7 @@ impl L1StateProvider { portal_address: config.portal_address, provider, runtime_handle, + max_sync_attempts: config.max_sync_attempts, } } @@ -191,7 +198,8 @@ impl L1StateProvider { match result { Ok(value) => { - self.cache.write().set(address, slot, block_number, value); + // Historical reads below the canonical floor are deliberately not readmitted. + let _ = self.cache.write().set(address, slot, block_number, value); if attempt > 1 { info!(%address, %slot, block_number, %value, ?elapsed, attempt, "L1 storage RPC fetch succeeded after retries"); } else { @@ -200,6 +208,14 @@ impl L1StateProvider { return Ok(value); } Err(rpc_err) => { + if self + .max_sync_attempts + .is_some_and(|max_attempts| attempt >= max_attempts) + { + return Err(eyre::eyre!( + "L1 storage RPC fetch failed after {attempt} attempts for address={address} slot={slot} block={block_number}: {rpc_err}" + )); + } warn!(%address, %slot, block_number, %rpc_err, ?elapsed, attempt, "L1 storage RPC fetch failed, retrying"); } } @@ -254,7 +270,8 @@ impl L1StateProvider { warn!(%address, %slot, block_number, "L1 storage cache miss, fetching from RPC"); let value = self.fetch_slot(address, slot, block_number).await?; - self.cache.write().set(address, slot, block_number, value); + // Historical reads below the canonical floor are deliberately not readmitted. + let _ = self.cache.write().set(address, slot, block_number, value); Ok(value) } @@ -298,3 +315,36 @@ impl SequencerExt for L1StateProvider { self.get_latest_sequencer().ok() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn finite_sync_attempt_limit_returns_diagnostic_error() { + let config = L1StateProviderConfig { + max_sync_attempts: Some(1), + ..Default::default() + }; + let provider = ProviderBuilder::new_with_network::() + .connect("http://127.0.0.1:1") + .await + .expect("HTTP transport construction is lazy") + .erased(); + let reader = L1StateProvider::new_raw( + config, + L1StateCache::default(), + provider, + tokio::runtime::Handle::current(), + ); + + let err = + tokio::task::spawn_blocking(move || reader.get_storage(Address::ZERO, B256::ZERO, 7)) + .await + .expect("storage task must not panic") + .expect_err("dead endpoint must fail after one attempt"); + let message = err.to_string(); + assert!(message.contains("after 1 attempts"), "{message}"); + assert!(message.contains("block=7"), "{message}"); + } +} diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index 80c09e357..cbedda12f 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -775,48 +775,36 @@ pub(crate) fn apply_sequencer_events_to_cache( block_number: u64, sequencer_events: &[L1SequencerEvent], ) { + let mut set_cache_slot = |slot, value| { + if !cache.set(portal_address, slot, block_number, value) { + let floor = cache.block_floor(); + debug!( + %portal_address, + %slot, + block_number, + floor, + "discarding sequencer cache update below canonical floor" + ); + } + }; + for event in sequencer_events { + // Reorg/backfill may replay events below the monotonic floor; those writes stay rejected. match *event { L1SequencerEvent::TransferStarted { current_sequencer, pending_sequencer, } => { - cache.set( - portal_address, - PORTAL_SEQUENCER_SLOT, - block_number, - address_to_storage_value(current_sequencer), - ); - cache.set( - portal_address, - PORTAL_PENDING_SEQUENCER_SLOT, - block_number, - address_to_storage_value(pending_sequencer), - ); + set_cache_slot(PORTAL_SEQUENCER_SLOT, current_sequencer.into_word()); + set_cache_slot(PORTAL_PENDING_SEQUENCER_SLOT, pending_sequencer.into_word()); } L1SequencerEvent::Transferred { previous_sequencer: _, new_sequencer, } => { - cache.set( - portal_address, - PORTAL_SEQUENCER_SLOT, - block_number, - address_to_storage_value(new_sequencer), - ); - cache.set( - portal_address, - PORTAL_PENDING_SEQUENCER_SLOT, - block_number, - B256::ZERO, - ); + set_cache_slot(PORTAL_SEQUENCER_SLOT, new_sequencer.into_word()); + set_cache_slot(PORTAL_PENDING_SEQUENCER_SLOT, B256::ZERO); } } } } - -pub(crate) fn address_to_storage_value(address: Address) -> B256 { - let mut bytes = [0u8; 32]; - bytes[12..].copy_from_slice(address.as_slice()); - B256::new(bytes) -} diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index 718f8bd5a..76ebb8ba7 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -420,12 +420,12 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { subscriber.update_l1_state_anchor(&old_header, &HashSet::new()); { let mut cache = subscriber.config.l1_state_cache.write(); - cache.set( + assert!(cache.set( token, B256::with_last_byte(1), 10, B256::with_last_byte(0xaa), - ); + )); } { let mut cache = subscriber.config.policy_cache.write(); @@ -717,11 +717,11 @@ fn test_apply_sequencer_events_to_cache_sets_pending_sequencer() { assert_eq!( cache.get(portal_address, PORTAL_SEQUENCER_SLOT, 42), - Some(address_to_storage_value(current_sequencer)) + Some(current_sequencer.into_word()) ); assert_eq!( cache.get(portal_address, PORTAL_PENDING_SEQUENCER_SLOT, 42), - Some(address_to_storage_value(pending_sequencer)) + Some(pending_sequencer.into_word()) ); } @@ -744,7 +744,7 @@ fn test_apply_sequencer_events_to_cache_accept_clears_pending_sequencer() { assert_eq!( cache.get(portal_address, PORTAL_SEQUENCER_SLOT, 43), - Some(address_to_storage_value(new_sequencer)) + Some(new_sequencer.into_word()) ); assert_eq!( cache.get(portal_address, PORTAL_PENDING_SEQUENCER_SLOT, 43), @@ -782,11 +782,11 @@ fn test_apply_sequencer_events_to_cache_preserves_in_block_event_order() { assert_eq!( cache.get(portal_address, PORTAL_SEQUENCER_SLOT, 44), - Some(address_to_storage_value(sequencer_b)) + Some(sequencer_b.into_word()) ); assert_eq!( cache.get(portal_address, PORTAL_PENDING_SEQUENCER_SLOT, 44), - Some(address_to_storage_value(sequencer_c)) + Some(sequencer_c.into_word()) ); } diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 9860b84a8..8a3300a57 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -286,6 +286,21 @@ impl ZoneNode { self } + /// Bound L1 state-provider retries for callers that must fail finitely on cache misses. + pub fn with_l1_state_provider_retry_limits( + mut self, + transport_retries: u32, + sync_attempts: u32, + ) -> Self { + assert!( + sync_attempts > 0, + "at least one synchronous attempt is required" + ); + self.l1_state_provider_config.max_retries = transport_retries; + self.l1_state_provider_config.max_sync_attempts = Some(sync_attempts); + self + } + /// Set the number of zone blocks between empty withdrawal batch /// finalization. pub fn with_withdrawal_batch_interval_blocks(mut self, interval_blocks: u64) -> Self { diff --git a/crates/node/tests/it/tip403_policy.rs b/crates/node/tests/it/tip403_policy.rs index 0aaa37536..b2c54ade6 100644 --- a/crates/node/tests/it/tip403_policy.rs +++ b/crates/node/tests/it/tip403_policy.rs @@ -56,6 +56,10 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { .wallet(alice_signer) .connect_http(zone.http_url().clone()); + // T6+ transfers also consult the recipient's address-level receive policy on L1. + // Seed the anchor before pool validation; the next execution block inherits this baseline. + fixture.seed_no_receive_policy(bob)?; + let tip20 = ITIP20::new(PATH_USD_ADDRESS, &alice_provider); let pending = tip20 .transfer(bob, U256::from(transfer_amount)) @@ -64,9 +68,6 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { .send() .await?; - // 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()); @@ -102,14 +103,10 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - // Inject a few empty L1 blocks so the zone is running - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate raw L1 state: policy 5 = WHITELIST, Alice is in the set, Bob is not. + // Populate raw L1 state before block 1 advances the cache floor. seed_raw_tip403_policy( zone.l1_state_cache(), 1, @@ -119,6 +116,9 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { ], )?; + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); // Alice is whitelisted → authorized @@ -154,13 +154,10 @@ async fn test_policy_proxy_blacklist_authorization() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate raw L1 state: policy 5 = BLACKLIST, Alice is blacklisted, Bob is not. + // Populate raw L1 state before block 1 advances the cache floor. seed_raw_tip403_policy( zone.l1_state_cache(), 1, @@ -170,6 +167,9 @@ async fn test_policy_proxy_blacklist_authorization() -> eyre::Result<()> { ], )?; + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); // Alice is in blacklist → NOT authorized @@ -193,12 +193,10 @@ async fn test_policy_proxy_compound_policy() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); + // Seed the compound policy graph before block 1 advances the cache floor. // Policy 5 = sender whitelist, policy 6 = recipient blacklist; compound policy 10 // references them. seed_raw_tip403_policy( @@ -211,6 +209,9 @@ async fn test_policy_proxy_compound_policy() -> eyre::Result<()> { ], )?; + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); // Alice is in sender whitelist → authorized as sender @@ -305,25 +306,32 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); let carol = address!("0x000000000000000000000000000000000000CA01"); - // Policy 5 = sender whitelist, policy 6 = recipient blacklist; compound policy 10 - // references them. + // Seed the complete policy membership before block 1 advances the cache floor. 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::simple( + 5, + PolicyType::WHITELIST, + &[(alice, true), (bob, false), (carol, true)], + ), + PolicySeed::simple( + 6, + PolicyType::BLACKLIST, + &[(alice, false), (bob, true), (carol, true)], + ), PolicySeed::compound(10, 5, 6, 1), ], )?; + fixture.inject_empty_blocks(zone.deposit_queue(), 3); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); // Alice: whitelisted as sender + NOT in recipient blacklist → true @@ -340,17 +348,7 @@ 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. - 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 + // Carol is whitelisted as sender but blacklisted as recipient, so transfer auth fails. let carol_auth = registry.isAuthorized(10, carol).call().await?; assert!( !carol_auth, @@ -367,13 +365,10 @@ async fn test_policy_proxy_uses_block_versioned_raw_state() -> eyre::Result<()> let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - fixture.inject_empty_blocks(zone.deposit_queue(), 3); - zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - let alice = address!("0x000000000000000000000000000000000000A11C"); let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, zone.provider()); - // Step 1: Create policy 5 as WHITELIST and add Alice at block 1. + // Step 1: materialize block-1 state before accepting block 1, then query at anchor 1. seed_raw_tip403_policy( zone.l1_state_cache(), 1, @@ -383,12 +378,13 @@ async fn test_policy_proxy_uses_block_versioned_raw_state() -> eyre::Result<()> &[(alice, true)], )], )?; + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(1, DEFAULT_TIMEOUT).await?; - // Alice should be authorized (whitelisted) let authorized = registry.isAuthorized(5, alice).call().await?; - assert!(authorized, "alice should be authorized (whitelisted)"); + assert!(authorized, "alice should be authorized at block 1"); - // Step 2: Remove Alice at block 2. + // Step 2: materialize block-2 state before accepting block 2, then query at anchor 2. seed_raw_tip403_policy( zone.l1_state_cache(), 2, @@ -398,19 +394,22 @@ async fn test_policy_proxy_uses_block_versioned_raw_state() -> eyre::Result<()> &[(alice, false)], )], )?; + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(2, DEFAULT_TIMEOUT).await?; - // Alice should no longer be authorized let authorized = registry.isAuthorized(5, alice).call().await?; - assert!(!authorized, "alice should NOT be authorized after removal"); + assert!(!authorized, "alice should NOT be authorized at block 2"); - // Step 3: Create compound policy 10 at block 3. + // Step 3: materialize the compound policy before accepting block 3. seed_raw_tip403_policy( zone.l1_state_cache(), 3, &[PolicySeed::compound(10, 5, 1, 1)], )?; + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - // Compound data should be queryable + // Compound data should be queryable at anchor 3. let compound = registry.compoundPolicyData(10).call().await?; assert_eq!(compound.senderPolicyId, 5); assert_eq!(compound.recipientPolicyId, 1); diff --git a/crates/node/tests/it/tip403_transfers.rs b/crates/node/tests/it/tip403_transfers.rs index 4f226014b..2080aa2de 100644 --- a/crates/node/tests/it/tip403_transfers.rs +++ b/crates/node/tests/it/tip403_transfers.rs @@ -53,6 +53,8 @@ async fn test_deposit_then_transfer() -> eyre::Result<()> { // Dev transfers 400,000 to Bob. // Submit the tx then inject an L1 block so the zone produces a block including it. let transfer_amount: u128 = 400_000; + // Seed the current anchor before estimation/pool validation. The next empty block inherits it. + fixture.seed_no_receive_policy(bob)?; let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); let native_balance = provider.get_balance(dev_address).await?; @@ -237,6 +239,7 @@ async fn test_sequential_transfers() -> eyre::Result<()> { let alice_provider = ProviderBuilder::new() .wallet(alice_signer) .connect_http(zone.http_url().clone()); + fixture.seed_no_receive_policy(bob)?; let tip20_alice = ITIP20::new(PATH_USD_ADDRESS, &alice_provider); let pending = tip20_alice @@ -265,6 +268,7 @@ async fn test_sequential_transfers() -> eyre::Result<()> { let bob_provider = ProviderBuilder::new() .wallet(bob_signer) .connect_http(zone.http_url().clone()); + fixture.seed_no_receive_policy(charlie)?; let tip20_bob = ITIP20::new(PATH_USD_ADDRESS, &bob_provider); let pending = tip20_bob @@ -347,7 +351,8 @@ async fn test_transfer_emits_events() -> eyre::Result<()> { ) .await?; - // Transfer to Bob + // Transfer to Bob. Seed its receive-policy baseline before pool validation. + fixture.seed_no_receive_policy(bob)?; let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); let pending = tip20 @@ -413,7 +418,8 @@ async fn test_transfer_with_memo() -> eyre::Result<()> { ) .await?; - // Transfer with memo + // Transfer with memo. Seed its receive-policy baseline before pool validation. + fixture.seed_no_receive_policy(bob)?; let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); let pending = tip20 diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 78cc451c9..1e2cf0ff6 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -324,19 +324,20 @@ fn pack_transfer_policy_id(policy_id: u64) -> U256 { } /// Seed a TIP-20 transfer policy ID in the canonical packed L1 storage slot. +#[must_use = "check whether the policy seed was admitted above the cache floor"] pub(crate) fn seed_raw_tip20_policy_id( cache: &mut zone_l1::state::L1StateCacheInner, block_number: u64, token: Address, policy_id: u64, -) { +) -> bool { 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`]. @@ -427,13 +428,17 @@ pub(crate) fn seed_raw_tip403_policy( })?; let mut cache = cache.write(); + let block_floor = cache.block_floor(); for slot in slots { let value = storage.sload(TIP403_REGISTRY_ADDRESS, slot)?; - cache.set( - TIP403_REGISTRY_ADDRESS, - slot.into(), - block_number, - value.into(), + eyre::ensure!( + cache.set( + TIP403_REGISTRY_ADDRESS, + slot.into(), + block_number, + value.into(), + ), + "TIP-403 seed rejected below cache floor: block={block_number} floor={block_floor} slot={slot}" ); } Ok(()) @@ -875,7 +880,9 @@ impl ZoneTestNode { ) .with_withdrawal_batch_interval_blocks(withdrawal_batch_interval_blocks); if is_local_dummy_l1 { - zone_node = zone_node.with_l1_chain_id(1337); + zone_node = zone_node + .with_l1_chain_id(1337) + .with_l1_state_provider_retry_limits(0, 1); } if let Some(initial_tokens) = initial_tokens { zone_node = zone_node.with_initial_tokens(initial_tokens); @@ -903,7 +910,10 @@ impl ZoneTestNode { 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); + assert!( + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID), + "pathUSD policy seed must be admitted before node startup" + ); } let node_handle = NodeBuilder::new(node_config) @@ -3314,39 +3324,42 @@ impl L1Fixture { // 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( + assert!(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]; sequencer_bytes[12..].copy_from_slice(sequencer.as_slice()); - cache.set( + assert!(cache.set( portal_address, B256::ZERO, block, B256::new(sequencer_bytes), - ); + )); // Deposit queue hash slot (5) — read by ZoneInbox after finalizeTempo. // The initial value is B256::ZERO (empty queue). - cache.set(portal_address, deposit_queue_hash_slot, block, B256::ZERO); - cache.set(portal_address, refunds_slot, block, B256::ZERO); + assert!(cache.set(portal_address, deposit_queue_hash_slot, block, B256::ZERO)); + assert!(cache.set(portal_address, refunds_slot, block, B256::ZERO)); // Local fixtures treat pathUSD as the default enabled bridge token. // ZoneConfig reads the L1 ZonePortal TokenConfig mapping directly, so // seed the packed { enabled, depositsActive } value to avoid a dummy // RPC fallback on self-contained tests. - cache.set( + assert!(cache.set( portal_address, path_usd_config_slot, block, enabled_token_config, - ); + )); } - seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); + assert!( + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID), + "pathUSD policy baseline must be admitted before the cache floor advances" + ); cache.update_anchor(NumHash { number: num_blocks, hash: B256::ZERO, @@ -3355,27 +3368,35 @@ impl L1Fixture { 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); + /// Seed the absence of an address-level TIP-403 receive policy at the current Zone anchor. + pub(crate) fn seed_no_receive_policy(&self, recipient: Address) -> eyre::Result<()> { + let current_anchor = self.next_block_number.saturating_sub(1); + self.seed_no_receive_policy_at(current_anchor, recipient) } - fn seed_no_receive_policy_at(&self, block_number: u64, recipient: Address) { + fn seed_no_receive_policy_at(&self, block_number: u64, recipient: Address) -> eyre::Result<()> { // 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, + let mut cache = cache.write(); + let block_floor = cache.block_floor(); + eyre::ensure!( + cache.set( + TIP403_REGISTRY_ADDRESS, + B256::from(receive_policy_slot.to_be_bytes()), + block_number, + B256::ZERO, + ), + "receive-policy seed rejected below cache floor: recipient={recipient} block={block_number} floor={block_floor} slot={receive_policy_slot}" ); } + Ok(()) } 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); + self.seed_no_receive_policy_at(block_number, deposit.to) + .expect("deposit receive-policy fixture seed must be admitted"); } } @@ -3383,11 +3404,14 @@ impl L1Fixture { 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, + assert!( + seed_raw_tip20_policy_id( + &mut cache, + block_number, + token.token, + ALLOW_ALL_POLICY_ID, + ), + "enabled-token policy fixture seed must be admitted" ); } } @@ -3453,7 +3477,8 @@ impl L1Fixture { 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); + self.seed_no_receive_policy_at(block_number, deposit.to) + .expect("event receive-policy fixture seed must be admitted"); } } queue.enqueue(block.header.clone(), events, vec![]); From c8b557d860ba23465ae70085616c27737a3346d0 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 17:14:39 +0200 Subject: [PATCH 11/22] fix(evm): bound L1 retries for maintenance execution --- crates/evm/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 09e5f8f43..9b160e0e5 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -220,7 +220,10 @@ impl ZoneEvmConfig { .connect_http("http://127.0.0.1:1".parse().expect("valid fallback URL")) .erased(); let runtime_handle = tokio::runtime::Handle::current(); - let config = L1StateProviderConfig::default(); + let config = L1StateProviderConfig { + max_sync_attempts: Some(1), + ..Default::default() + }; let l1_provider = L1StateProvider::new_raw(config, cache, provider, runtime_handle); Self::from_chain_spec(chain_spec, l1_provider) } From 2ec6aab0fd066248fac9d6a0048c15e574ddc502 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 17:52:55 +0200 Subject: [PATCH 12/22] fix(node): advance L1 cache floor using canonical task --- crates/l1/src/state/cache.rs | 20 ++++----- crates/l1/src/state/provider.rs | 2 +- crates/node/src/engine.rs | 13 +----- crates/node/src/node.rs | 77 +++++++++++++++++++++++++++++++-- crates/node/tests/it/e2e.rs | 10 +++++ crates/node/tests/it/utils.rs | 1 - 6 files changed, 95 insertions(+), 28 deletions(-) diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index ef191e7ce..3594f9d19 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -16,7 +16,7 @@ //! - The [`L1Subscriber`](crate::l1::L1Subscriber) writes storage diffs for tracked contracts //! as they arrive, tagged with the L1 tip block number. //! - The [`L1StateProvider`](super::provider::L1StateProvider) writes eligible forward RPC -//! misses, tagged with the requested block number. Misses below the engine's consumed-block +//! misses, tagged with the requested block number. Misses below the canonical Zone anchor //! floor are returned without being inserted. //! //! ## Reorg handling @@ -60,10 +60,10 @@ impl L1StateCache { /// storage change without requiring slot-level decoding. Reorgs clear slot values and mutation /// history atomically. /// -/// The subscriber anchor and engine floor track independent progress. The anchor is the latest +/// The subscriber anchor and canonical floor track independent progress. The anchor is the latest /// confirmed L1 block observed by the subscriber and may run ahead while blocks are queued. The -/// floor is the latest L1 height consumed by the engine. It advances monotonically and drives -/// lazy history compaction without scanning the cache on the block-production path. +/// floor is the latest L1 height committed by canonical Zone execution. It advances monotonically +/// and drives lazy history compaction without scanning the cache on the import path. #[derive(Debug, Default)] pub struct L1StateCacheInner { tracked_contracts: HashSet

, @@ -74,7 +74,7 @@ pub struct L1StateCacheInner { /// A slot value cached at block V may serve block N only when no barrier exists in `(V, N]`. /// The subscriber records barriers for contracts whose logs imply possible storage changes. invalidations: HashMap>, - /// Latest L1 block height successfully consumed by the Zone engine. + /// Latest L1 block height committed by canonical Zone execution. /// /// New fallback values below this floor are not admitted. Histories are compacted lazily /// against it when their slot/address is next mutated, so older entries may remain physically @@ -97,7 +97,7 @@ impl L1StateCacheInner { /// Returns the cached value for a storage slot at the given block number. /// - /// Returns `None` below the engine floor because pruned mutation history cannot safely serve + /// Returns `None` below the canonical floor because pruned mutation history cannot safely serve /// historical inheritance. Otherwise returns the most recent valid value at or before /// `block_number`. pub fn get(&self, address: Address, slot: B256, block_number: u64) -> Option { @@ -132,7 +132,7 @@ impl L1StateCacheInner { /// Sets a storage slot value in the forward cache at the given block number. /// - /// Returns `false` without inserting when `block_number` is below the engine's consumed-block + /// Returns `false` without inserting when `block_number` is below the canonical Zone anchor /// floor. Callers that materialize synthetic/test state should check the result so a rejected /// seed cannot turn into an unexpected RPC fallback. #[must_use = "check whether the cache write was admitted above the block floor"] @@ -147,7 +147,7 @@ impl L1StateCacheInner { true } - /// Advances the engine's consumed-block floor monotonically in O(1). + /// Advances the canonical Zone anchor floor monotonically in O(1). pub fn advance_floor(&mut self, block_number: u64) { self.block_floor = self.block_floor.max(block_number); } @@ -172,7 +172,7 @@ impl L1StateCacheInner { self.tracked_contracts.contains(address) } - /// Clears subscriber-derived chain data while retaining tracked contracts and the engine floor. + /// Clears subscriber-derived chain data while retaining tracked contracts and the canonical floor. pub fn clear(&mut self) { self.slots.clear(); self.invalidations.clear(); @@ -275,7 +275,7 @@ mod tests { } #[test] - fn clear_removes_chain_data_but_preserves_engine_floor() { + fn clear_removes_chain_data_but_preserves_canonical_floor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); assert!(cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1))); diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index d58bdbcf9..48fd2a3ac 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -3,7 +3,7 @@ //! [`L1StateProvider`] wraps a [`L1StateCache`] and a [`DynProvider`] backed by an //! HTTP transport. Reads are served from the in-memory cache when possible. On cache miss the //! provider falls back to `eth_getStorageAt` via the shared HTTP provider, and forward reads are -//! written back. Misses below the engine's consumed-block floor are returned without caching. +//! written back. Misses below the canonical Zone anchor floor are returned without caching. //! //! Both a synchronous ([`L1StateProvider::get_storage`]) and an asynchronous //! ([`L1StateProvider::get_storage_async`]) entry point are provided. The synchronous variant is diff --git a/crates/node/src/engine.rs b/crates/node/src/engine.rs index 5711fc5d4..f029a2b08 100644 --- a/crates/node/src/engine.rs +++ b/crates/node/src/engine.rs @@ -51,7 +51,7 @@ use tempo_primitives::TempoHeader; use tracing::{error, warn}; use zone_chainspec::ZoneChainSpec; -use zone_l1::{DepositQueue, L1BlockDeposits, L1StateCache, PolicyProvider, PreparedL1Block}; +use zone_l1::{DepositQueue, L1BlockDeposits, PolicyProvider, PreparedL1Block}; use zone_payload::{ZonePayloadAttributes, ZonePayloadTypes}; /// Engine that drives L2 block production from L1 events. @@ -87,8 +87,6 @@ pub struct ZoneEngine { /// Cache-first, RPC-fallback TIP-403 policy provider for authorization checks /// on encrypted deposit recipients during preparation. policy_provider: PolicyProvider, - /// Block-versioned L1 storage cache shared with the subscriber and precompiles. - l1_state_cache: L1StateCache, } impl ZoneEngine { @@ -102,7 +100,6 @@ impl ZoneEngine { sequencer_key: k256::SecretKey, portal_address: Address, policy_provider: PolicyProvider, - l1_state_cache: L1StateCache, ) -> Self { Self { chain_spec, @@ -114,7 +111,6 @@ impl ZoneEngine { sequencer_key, portal_address, policy_provider, - l1_state_cache, } } @@ -281,13 +277,6 @@ impl ZoneEngine { // could return wrong results. self.policy_provider.cache().advance(l1_num_hash.number); - // The engine has committed this L1 height, so advance the forward-cache floor. - // Individual slot and mutation histories are compacted lazily when next touched, so - // RPC-created slots cannot amplify engine work. - self.l1_state_cache - .write() - .advance_floor(l1_num_hash.number); - self.last_header = header; // Canonicalize the new head — FCU-with-attrs above only set the diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 8a3300a57..161d993ee 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -29,7 +29,7 @@ use reth_node_builder::{ }, }; use reth_primitives_traits::SealedHeader; -use reth_provider::ChainSpecProvider; +use reth_provider::{CanonStateSubscriptions, ChainSpecProvider}; use reth_rpc_builder::Identity; use reth_rpc_eth_api::EthApiTypes; use reth_storage_api::{BlockNumReader, EmptyBodyStorage, HeaderProvider, StateProviderFactory}; @@ -61,7 +61,7 @@ use tracing::{debug, info, warn}; use zone_chainspec::ZoneChainSpec; use zone_evm::ZoneEvmConfig; use zone_l1::{ - DepositQueue, L1Subscriber, L1SubscriberConfig, PolicyCache, TempoStateExt, + ChainTempoStateExt, DepositQueue, L1Subscriber, L1SubscriberConfig, PolicyCache, TempoStateExt, state::{ L1StateCache, L1StateProvider, L1StateProviderConfig, PolicyProvider, spawn_policy_resolution_task, spawn_pool_prefetch_task, @@ -474,7 +474,11 @@ where let sp = ctx.node.provider().latest()?; let tempo_block_number = sp.tempo_block_number()?; self.policy_cache.set_last_l1_block(tempo_block_number); - info!(target: "reth::cli", tempo_block_number, "Read local tempoBlockNumber for L1 subscriber"); + self.l1_state_cache + .write() + .advance_floor(tempo_block_number); + self.spawn_l1_state_cache_floor_task(&ctx); + info!(target: "reth::cli", tempo_block_number, "Initialized L1 cache progress from local TempoState"); let l1_provider = alloy_provider::ProviderBuilder::new_with_network::() .connect_with_config( @@ -647,6 +651,72 @@ where Ok(()) } + /// Track the canonical Zone anchor and advance the raw L1 cache floor for every node role. + fn spawn_l1_state_cache_floor_task(&self, ctx: &AddOnsContext<'_, N>) { + let provider = ctx.node.provider().clone(); + let mut notifications = provider.subscribe_to_canonical_state(); + let cache = self.l1_state_cache.clone(); + + ctx.node.task_executor().spawn_critical_task( + "l1-state-cache-floor", + async move { + loop { + let block_number = match notifications.recv().await { + Ok(notification) => { + let committed = notification.committed(); + if committed.is_empty() { + // A pure revert has no new execution outcome. The floor is + // monotonic, but read the new canonical head for completeness. + match provider + .latest() + .and_then(|state| state.tempo_block_number()) + { + Ok(block_number) => block_number, + Err(err) => { + warn!(target: "zone::l1_cache", %err, "Failed to resync L1 cache floor after canonical revert"); + continue; + } + } + } else { + committed.tempo_block_number() + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + // A later notification normally catches the floor up, but resync now + // so an idle follower cannot remain behind after notification loss. + warn!(target: "zone::l1_cache", skipped, "Canonical notifications lagged; resyncing L1 cache floor"); + match provider + .latest() + .and_then(|state| state.tempo_block_number()) + { + Ok(block_number) => block_number, + Err(err) => { + warn!(target: "zone::l1_cache", %err, "Failed to resync lagged L1 cache floor"); + continue; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("canonical state notifications closed unexpectedly") + } + }; + + let mut cache = cache.write(); + let previous_floor = cache.block_floor(); + cache.advance_floor(block_number); + if block_number > previous_floor { + debug!( + target: "zone::l1_cache", + previous_floor, + block_number, + "Advanced L1 cache floor from canonical Zone state" + ); + } + } + }, + ); + } + /// Spawn the L1 subscriber. Listens for new blocks and deposit events. fn spawn_l1_subscriber(&mut self, ctx: &AddOnsContext<'_, N>) { L1Subscriber::spawn( @@ -706,7 +776,6 @@ where sequencer_key, self.portal_address, policy_provider, - self.l1_state_cache.clone(), ); ctx.node .task_executor() diff --git a/crates/node/tests/it/e2e.rs b/crates/node/tests/it/e2e.rs index 9d796347a..3b1ecca40 100644 --- a/crates/node/tests/it/e2e.rs +++ b/crates/node/tests/it/e2e.rs @@ -1037,6 +1037,16 @@ async fn test_chain_tempo_state_ext_from_canon_notification() -> eyre::Result<() // Wait for tempoBlockNumber to reach 3 via RPC (ensures blocks are mined). zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; + // The engine does not own the raw cache floor. Reaching 3 here proves the role-independent + // canonical-state task observed the imports and advanced the shared cache. + let cache = zone.l1_state_cache().clone(); + let floor = poll_until(DEFAULT_TIMEOUT, DEFAULT_POLL, "L1 cache floor >= 3", || { + let floor = cache.read().block_floor(); + async move { Ok((floor >= 3).then_some(floor)) } + }) + .await?; + assert_eq!(floor, 3, "raw L1 cache floor should match the Zone anchor"); + // Drain canon notifications and collect the L1 NumHash from each committed chain. let mut num_hashes: Vec = Vec::new(); while let Ok(notification) = canon_rx.try_recv() { diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 1e2cf0ff6..b47b2ab58 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -945,7 +945,6 @@ impl ZoneTestNode { SecretKey::from(sequencer_signer.credential()), portal_address, policy_provider, - l1_state_cache.clone(), ); node_handle .node From 1e130d4acd7c27a2eb31ea7106dd91ac2adf4074 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 19:13:42 +0200 Subject: [PATCH 13/22] fix(evm): fee manager backed by zone storage provider --- Cargo.lock | 88 ++++++++++++++++---------- Cargo.toml | 24 ++++---- crates/evm/src/fee_manager.rs | 89 +++++++++++++++++++++++++++ crates/evm/src/lib.rs | 5 +- crates/node/tests/it/tip403_policy.rs | 89 +++++++++++++++++++++++++-- crates/precompiles/src/storage.rs | 5 ++ 6 files changed, 250 insertions(+), 50 deletions(-) create mode 100644 crates/evm/src/fee_manager.rs diff --git a/Cargo.lock b/Cargo.lock index c7983ebfc..e01b09239 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,9 +375,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +checksum = "6cee30dd4c2f4b23f434fdf675e7bf9681b86768141277266c6f548ef25cba0a" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -441,9 +441,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +checksum = "f007e257069855bdf21d27762fd3f3705a613f805c9a08309bf353503f081d71" dependencies = [ "alloy-rlp", "arbitrary", @@ -451,6 +451,7 @@ dependencies = [ "cfg-if", "const-hex", "derive_more", + "fixed-cache", "foldhash", "getrandom 0.4.3", "hashbrown 0.17.1", @@ -806,13 +807,13 @@ dependencies = [ [[package]] name = "alloy-sol-macro" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +checksum = "b5655c38d5f84955bf727b2eeb62fddd91ebb98fd1d7ae6eb77f73ea88f9b9cf" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "syn 2.0.119", @@ -820,16 +821,16 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +checksum = "6277c780e07b76951e09a59788dde230d1582612324177d11a43a61e21a6bb83" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", "indexmap 2.14.0", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "sha3 0.11.0", @@ -839,9 +840,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-input" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +checksum = "9762b2ad3e5a0c09886de54fe549ab0056681df843cb082e2df7e1c0eb270d30" dependencies = [ "alloy-json-abi", "const-hex", @@ -857,9 +858,9 @@ dependencies = [ [[package]] name = "alloy-sol-type-parser" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +checksum = "da4c7130f0f01f4719678bda3db3bc7267fc2f7f9d0565e3bd964cd2bb45050d" dependencies = [ "serde", "winnow 1.0.4", @@ -867,9 +868,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +checksum = "d96e74d6213180f78dbdccddce8af02a639c160c94b0a543fa35c77c58b8a7fc" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -6551,6 +6552,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -6560,6 +6571,17 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", "syn 2.0.119", ] @@ -11018,9 +11040,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +checksum = "083be3061e64d362cbe6ef12cfe1307ba3884326d8856448fe8a120fa2c44ebf" dependencies = [ "paste", "proc-macro2", @@ -11136,7 +11158,7 @@ dependencies = [ [[package]] name = "tempo-alloy" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-contract", @@ -11175,7 +11197,7 @@ dependencies = [ [[package]] name = "tempo-chainspec" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-eips", "alloy-evm", @@ -11199,7 +11221,7 @@ dependencies = [ [[package]] name = "tempo-contracts" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-contract", "alloy-primitives", @@ -11211,7 +11233,7 @@ dependencies = [ [[package]] name = "tempo-dkg-onchain-artifacts" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "bytes", "commonware-codec", @@ -11223,7 +11245,7 @@ dependencies = [ [[package]] name = "tempo-evm" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11258,7 +11280,7 @@ dependencies = [ [[package]] name = "tempo-hardfork" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-eips", "alloy-evm", @@ -11270,7 +11292,7 @@ dependencies = [ [[package]] name = "tempo-node" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy", "alloy-eips", @@ -11286,6 +11308,7 @@ dependencies = [ "futures", "jiff", "jsonrpsee", + "metrics", "reqwest 0.13.4", "reth-chainspec", "reth-errors", @@ -11309,6 +11332,7 @@ dependencies = [ "reth-transaction-pool", "serde", "serde_json", + "sysinfo 0.38.4", "tempo-alloy", "tempo-chainspec", "tempo-evm", @@ -11328,7 +11352,7 @@ dependencies = [ [[package]] name = "tempo-payload-builder" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -11366,7 +11390,7 @@ dependencies = [ [[package]] name = "tempo-payload-types" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-eips", "alloy-primitives", @@ -11386,7 +11410,7 @@ dependencies = [ [[package]] name = "tempo-precompiles" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy", "alloy-evm", @@ -11409,7 +11433,7 @@ dependencies = [ [[package]] name = "tempo-precompiles-macros" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy", "proc-macro2", @@ -11420,7 +11444,7 @@ dependencies = [ [[package]] name = "tempo-primitives" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-eips", @@ -11452,7 +11476,7 @@ dependencies = [ [[package]] name = "tempo-revm" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11475,7 +11499,7 @@ dependencies = [ [[package]] name = "tempo-transaction-pool" version = "1.10.1" -source = "git+https://github.com/tempoxyz/tempo?rev=436f6cf069a0c8aafa9a3a197ffa17488b5f1e81#436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" +source = "git+https://github.com/tempoxyz/tempo?rev=d1507111c1437fe352f6ad74b6d2035f878a5714#d1507111c1437fe352f6ad74b6d2035f878a5714" dependencies = [ "alloy-consensus", "alloy-eips", diff --git a/Cargo.toml b/Cargo.toml index 6854d960f..7a83f12bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,25 +94,25 @@ incremental = false [workspace.dependencies] # tempo (from upstream) -tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" } -tempo-chainspec = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-consensus = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false, features = [ +tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714" } +tempo-chainspec = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-consensus = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false, features = [ "serde", ] } -tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-node = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" } -tempo-payload-types = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-precompiles-macros = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81" } -tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false, features = [ +tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-node = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714" } +tempo-payload-types = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-precompiles-macros = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714" } +tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false, features = [ "reth", "serde", "serde-bincode-compat", "reth-codec", ] } -tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } -tempo-transaction-pool = { git = "https://github.com/tempoxyz/tempo", rev = "436f6cf069a0c8aafa9a3a197ffa17488b5f1e81", default-features = false } +tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } +tempo-transaction-pool = { git = "https://github.com/tempoxyz/tempo", rev = "d1507111c1437fe352f6ad74b6d2035f878a5714", default-features = false } # zones tempo-zone-contracts = { path = "crates/contracts", default-features = false } diff --git a/crates/evm/src/fee_manager.rs b/crates/evm/src/fee_manager.rs new file mode 100644 index 000000000..348a3e6e5 --- /dev/null +++ b/crates/evm/src/fee_manager.rs @@ -0,0 +1,89 @@ +//! L1-aware implementation of Tempo's internal protocol fee hooks. + +use alloy_evm::Database; +use alloy_primitives::{Address, U256}; +use reth_evm::EvmInternals; +use tempo_evm::{ProtocolFeeContext, ProtocolFeeManager}; +use tempo_precompiles::{ + error::Result, + storage::{PrecompileStorageProvider, StorageCtx, evm::EvmPrecompileStorageProvider}, + tip_fee_manager::TipFeeManager, +}; +use zone_l1::state::L1StateProvider; +use zone_precompiles::storage::ZonePrecompileStorageProvider; + +/// Tempo protocol fee hooks executed against the Zone's finalized L1 policy overlay. +#[derive(Debug, Clone)] +pub(crate) struct ZoneFeeManager { + l1_provider: L1StateProvider, +} + +impl ZoneFeeManager { + pub(crate) const fn new(l1_provider: L1StateProvider) -> Self { + Self { l1_provider } + } + + fn enter( + &self, + ctx: ProtocolFeeContext<'_, DB>, + f: impl FnOnce() -> Result, + ) -> Result { + let ProtocolFeeContext { + journal, + block_env, + cfg, + tx_env, + actions, + } = ctx; + let internals = EvmInternals::new(journal, block_env, cfg, tx_env); + let mut inner = + EvmPrecompileStorageProvider::new_max_gas(internals, cfg).with_actions(actions); + inner.set_tip1060_storage_credits(false); + + let mut storage = ZonePrecompileStorageProvider::try_new(inner, self.l1_provider.clone()) + .map_err(|error| error.into_error())?; + StorageCtx::enter(&mut storage, f) + } +} + +impl ProtocolFeeManager for ZoneFeeManager { + fn collect_fee_pre_tx( + &self, + ctx: ProtocolFeeContext<'_, DB>, + fee_payer: Address, + user_token: Address, + max_amount: U256, + beneficiary: Address, + skip_liquidity_check: bool, + ) -> Result
{ + self.enter(ctx, || { + TipFeeManager::new().collect_fee_pre_tx( + fee_payer, + user_token, + max_amount, + beneficiary, + skip_liquidity_check, + ) + }) + } + + fn collect_fee_post_tx( + &self, + ctx: ProtocolFeeContext<'_, DB>, + fee_payer: Address, + actual_spending: U256, + refund_amount: U256, + fee_token: Address, + beneficiary: Address, + ) -> Result { + self.enter(ctx, || { + TipFeeManager::new().collect_fee_post_tx( + fee_payer, + actual_spending, + refund_amount, + fee_token, + beneficiary, + ) + }) + } +} diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 9b160e0e5..88c1a378b 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -8,6 +8,7 @@ #![allow(unnameable_types)] mod executor; +mod fee_manager; pub mod precompiles; mod tx_context; mod zone_evm; @@ -16,6 +17,7 @@ pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; use crate::{ executor::ZoneBlockExecutor, + fee_manager::ZoneFeeManager, precompiles::{SequencerExt, extend_zone_precompiles}, tx_context::ZoneTxContext, }; @@ -68,9 +70,10 @@ impl ZoneEvmFactory { fn register_precompiles>>( &self, - mut evm: TempoEvm, + evm: TempoEvm, ) -> TempoEvm { let cfg = evm.ctx().cfg.clone(); + let mut evm = evm.with_fee_manager(ZoneFeeManager::new(self.l1_provider.clone())); let (_, _, precompiles) = evm.components_mut(); let sequencer: Arc = Arc::new(self.l1_provider.clone()); extend_zone_precompiles( diff --git a/crates/node/tests/it/tip403_policy.rs b/crates/node/tests/it/tip403_policy.rs index b2c54ade6..0d92a7de6 100644 --- a/crates/node/tests/it/tip403_policy.rs +++ b/crates/node/tests/it/tip403_policy.rs @@ -4,20 +4,21 @@ //! 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::primitives::{TxKind, U256, address}; +use alloy_provider::{Provider, ProviderBuilder}; +use alloy_rpc_types_eth::TransactionRequest; use alloy_signer_local::{MnemonicBuilder, coins_bip39::English}; use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; use tempo_contracts::precompiles::{ ITIP20, ITIP403Registry::{self, PolicyType}, }; -use tempo_precompiles::{PATH_USD_ADDRESS, TIP403_REGISTRY_ADDRESS}; +use tempo_precompiles::{PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP403_REGISTRY_ADDRESS}; use zone_l1::state::tip403::PolicyEvent; use crate::utils::{ - DEFAULT_TIMEOUT, PolicySeed, TEST_MNEMONIC, TIP20_TX_GAS, seed_raw_tip403_policy, - start_local_zone_with_fixture, + DEFAULT_TIMEOUT, PolicySeed, TEST_MNEMONIC, TIP20_TX_GAS, seed_raw_tip20_policy_id, + seed_raw_tip403_policy, start_local_zone_with_fixture, }; /// Deposit pathUSD to Alice, then transfer a portion to Bob on the zone. @@ -96,6 +97,84 @@ async fn test_tip20_transfer_on_zone() -> eyre::Result<()> { Ok(()) } +/// Protocol fee collection must use the finalized L1 policy even when the tx does not call TIP-20. +#[tokio::test(flavor = "multi_thread")] +async fn test_l1_blacklisted_sender_cannot_pay_for_empty_transaction() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; + let alice_signer = MnemonicBuilder::::default() + .phrase(TEST_MNEMONIC) + .build()?; + let alice = alice_signer.address(); + + let deposit_amount = 1_000_000u128; + let deposit = fixture.make_deposit(PATH_USD_ADDRESS, alice, alice, deposit_amount); + fixture.inject_deposits(zone.deposit_queue(), vec![deposit]); + zone.wait_for_balance( + PATH_USD_ADDRESS, + alice, + U256::from(deposit_amount), + DEFAULT_TIMEOUT, + ) + .await?; + let anchor = zone.wait_for_tempo_block_number(1, DEFAULT_TIMEOUT).await?; + + const BLACKLIST_POLICY_ID: u64 = 42; + { + let mut cache = zone.l1_state_cache().write(); + eyre::ensure!( + seed_raw_tip20_policy_id(&mut cache, anchor, PATH_USD_ADDRESS, BLACKLIST_POLICY_ID,), + "fee-token policy seed must be admitted at the current anchor" + ); + } + seed_raw_tip403_policy( + zone.l1_state_cache(), + anchor, + &[PolicySeed::simple( + BLACKLIST_POLICY_ID, + PolicyType::BLACKLIST, + &[(alice, true), (TIP_FEE_MANAGER_ADDRESS, false)], + )], + )?; + + let alice_provider = ProviderBuilder::new() + .wallet(alice_signer) + .connect_http(zone.http_url().clone()); + let mut request = TransactionRequest::default(); + request.to = Some(TxKind::Call(alice)); + request.gas = Some(TIP20_TX_GAS); + request.gas_price = Some(TEMPO_T0_BASE_FEE as u128); + + let nonce_before = alice_provider.get_transaction_count(alice).await?; + let pending = alice_provider.send_transaction(request).await?; + let tx_hash = *pending.tx_hash(); + + fixture.inject_empty_block(zone.deposit_queue()); + zone.wait_for_tempo_block_number(anchor + 1, DEFAULT_TIMEOUT) + .await?; + + assert!( + alice_provider + .get_transaction_receipt(tx_hash) + .await? + .is_none(), + "L1-blacklisted fee payer transaction must not be included" + ); + assert_eq!( + alice_provider.get_transaction_count(alice).await?, + nonce_before, + "rejected fee payment must not consume the sender nonce" + ); + assert_eq!( + zone.balance_of(PATH_USD_ADDRESS, alice).await?, + U256::from(deposit_amount), + "rejected fee payment must leave the sender balance unchanged" + ); + + Ok(()) +} + /// Whitelist policy: set entries are authorized, non-set entries are not (fail-closed). #[tokio::test(flavor = "multi_thread")] async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 84e8b28dd..099a458e9 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -69,6 +69,11 @@ pub struct ZonePrecompileStorageProviderInitError { } impl ZonePrecompileStorageProviderInitError { + /// Return the underlying Tempo error for protocol execution outside a metered precompile call. + pub fn into_error(self) -> TempoPrecompileError { + self.error + } + /// Convert the initialization failure using the gas accounting of the anchor read. pub fn into_precompile_result(self) -> PrecompileResult { self.error From d4db039ac9e148f3d239ca19a48757623b886287 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 16 Jul 2026 22:30:17 +0200 Subject: [PATCH 14/22] style: docs --- crates/evm/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 09f4fe730..6f5a25877 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -55,8 +55,7 @@ use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; type TempoCtx = ::Context; -/// Zone EVM factory — wraps [`TempoEvmFactory`] and registers the -/// zone-native precompiles. +/// Zone EVM factory — wraps [`TempoEvmFactory`] and registers the zone-native precompiles. #[derive(Debug, Clone)] pub struct ZoneEvmFactory { l1_reader: L1, From 7ed347dbb5ae7a6f12a4840769432ab3fe6dc7df Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 11:31:49 +0200 Subject: [PATCH 15/22] feat(evm): l1-aware database --- Cargo.lock | 5 +- Cargo.toml | 1 + crates/evm/Cargo.toml | 2 + crates/evm/src/database.rs | 347 +++++++++ crates/evm/src/executor.rs | 36 +- crates/evm/src/fee_manager.rs | 96 --- crates/evm/src/lib.rs | 57 +- crates/evm/src/test_utils.rs | 3 + crates/evm/src/validation.rs | 154 ++++ crates/evm/src/zone_evm/contract_creation.rs | 33 +- crates/evm/src/zone_evm/mod.rs | 103 ++- crates/payload/Cargo.toml | 1 + crates/payload/src/builder.rs | 3 + crates/precompiles/src/execution.rs | 100 +-- crates/precompiles/src/lib.rs | 72 +- crates/precompiles/src/storage.rs | 658 ++++++------------ crates/precompiles/src/tempo_state.rs | 200 +++++- crates/precompiles/src/test_utils.rs | 401 ----------- .../precompiles/src/test_utils/l1_reader.rs | 206 ++++++ crates/precompiles/src/test_utils/local.rs | 197 ++++++ crates/precompiles/src/test_utils/mod.rs | 9 + crates/precompiles/src/tip403_proxy/mod.rs | 148 +--- crates/precompiles/src/ztip20/mod.rs | 171 +---- 23 files changed, 1568 insertions(+), 1435 deletions(-) create mode 100644 crates/evm/src/database.rs delete mode 100644 crates/evm/src/fee_manager.rs create mode 100644 crates/evm/src/test_utils.rs create mode 100644 crates/evm/src/validation.rs delete mode 100644 crates/precompiles/src/test_utils.rs create mode 100644 crates/precompiles/src/test_utils/l1_reader.rs create mode 100644 crates/precompiles/src/test_utils/local.rs create mode 100644 crates/precompiles/src/test_utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index ab7f2f348..2e707f3cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,8 +327,7 @@ dependencies = [ [[package]] name = "alloy-evm" version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acde665074b478c97047dc2a461b4916cac77922d690e5b7415d1941ae7ff7bf" +source = "git+https://github.com/alloy-rs/evm?rev=ce6b773ab958548e37fe195b21412a2a1638706e#ce6b773ab958548e37fe195b21412a2a1638706e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -13435,6 +13434,7 @@ dependencies = [ "tempo-primitives", "tempo-revm", "tempo-zone-contracts", + "thiserror 2.0.18", "tokio", "tracing", "zone-chainspec", @@ -13625,6 +13625,7 @@ dependencies = [ "tempo-zone-contracts", "tracing", "zone-chainspec", + "zone-evm", "zone-l1", "zone-precompiles", ] diff --git a/Cargo.toml b/Cargo.toml index e14590d1b..747da348e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -231,6 +231,7 @@ tracing = "0.1.41" # Keep Commonware aligned with the revision used by upstream Tempo. Cargo does # not inherit a git dependency's `[patch]` section into this workspace. [patch.crates-io] +alloy-evm = { git = "https://github.com/alloy-rs/evm", rev = "ce6b773ab958548e37fe195b21412a2a1638706e" } commonware-broadcast = { git = "https://github.com/commonwarexyz/monorepo", rev = "2a7dd423f0a241276a5a38db8cc3d05f11de0c03" } commonware-codec = { git = "https://github.com/commonwarexyz/monorepo", rev = "2a7dd423f0a241276a5a38db8cc3d05f11de0c03" } commonware-consensus = { git = "https://github.com/commonwarexyz/monorepo", rev = "2a7dd423f0a241276a5a38db8cc3d05f11de0c03" } diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index c9b524a77..5b63b8e23 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -44,9 +44,11 @@ alloy-sol-types.workspace = true revm.workspace = true # misc +thiserror.workspace = true tokio.workspace = true tracing.workspace = true [dev-dependencies] eyre.workspace = true +zone-precompiles = { workspace = true, features = ["std", "test-utils"] } tempo-precompiles = { workspace = true, features = ["test-utils"] } diff --git a/crates/evm/src/database.rs b/crates/evm/src/database.rs new file mode 100644 index 000000000..035da07fd --- /dev/null +++ b/crates/evm/src/database.rs @@ -0,0 +1,347 @@ +//! Execution-local database adapter for Tempo-owned state mirrored into a Zone. + +use std::{collections::HashMap, fmt}; + +use alloy_evm::Database; +use alloy_primitives::{Address, B256, U256}; +use revm::{ + context::{ + DBErrorMarker, + result::{AnyError, EVMError}, + }, + database_interface::Database as RevmDatabase, + precompile::PrecompileError, + primitives::{AddressMap, StorageKey, StorageValue}, + state::{Account, AccountInfo, Bytecode}, +}; +use thiserror::Error; +use zone_precompiles::{ + TIP403_REGISTRY_ADDRESS, + storage::{self, L1AnchorController, L1AnchorError, L1StorageReader}, + tempo_state::TEMPO_BLOCK_NUMBER_SLOT, +}; +use zone_primitives::constants::TEMPO_STATE_ADDRESS; + +/// Database error produced by [`AnchoredZoneDb`]. +#[derive(Debug, Error)] +pub enum AnchoredZoneDbError { + /// Error from the caller-provided database. + #[error("inner database error: {0}")] + Inner(#[source] E), + /// The selected Zone state contains an invalid Tempo anchor. + #[error("invalid Tempo anchor (does not fit in u64): {0}")] + AnchorOverflow(U256), + /// The execution-local anchor transition is inconsistent. + #[error(transparent)] + AnchorTransition(#[from] L1AnchorError), + /// Exact anchored L1 storage was unavailable. + #[error("Tempo L1 storage unavailable address={address} slot={slot} block={anchor}: {source}")] + L1Read { + address: Address, + slot: B256, + anchor: u64, + #[source] + source: PrecompileError, + }, + /// A transaction attempted to persist mirrored Tempo-owned state. + #[error("write to mirrored Tempo storage address={address} slot={slot}")] + L1Write { address: Address, slot: U256 }, +} + +impl DBErrorMarker for AnchoredZoneDbError {} + +impl AnchoredZoneDbError { + pub(crate) fn into_evm_error(self) -> EVMError { + match self { + Self::Inner(error) => EVMError::Database(error), + error => EVMError::CustomAny(AnyError::new(error)), + } + } +} + +// TODO(rusowsky): remove once TIP-1092 is implemented +#[derive(Debug, Clone, Copy)] +struct PackedPolicySlot { + local: U256, + l1: U256, +} + +/// Database adapter that resolves Tempo-owned policy storage at the Zone's finalized L1 anchor. +pub struct AnchoredZoneDb { + inner: DB, + l1: L1, + controller: L1AnchorController, + // TODO(rusowsky): remove once TIP-1092 is implemented + packed_policy_slots: HashMap<(Address, U256), PackedPolicySlot>, +} + +impl fmt::Debug for AnchoredZoneDb { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnchoredZoneDb") + .field("inner", &self.inner) + .field("controller", &self.controller) + .finish_non_exhaustive() + } +} + +impl AnchoredZoneDb { + /// Creates an adapter around the caller-provided database. + pub fn new(inner: DB, l1: L1, controller: L1AnchorController) -> Self { + Self { + inner, + l1, + controller, + packed_policy_slots: HashMap::new(), + } + } + + /// Returns the original caller-provided database. + pub const fn inner(&self) -> &DB { + &self.inner + } + + /// Returns the original caller-provided database mutably. + pub const fn inner_mut(&mut self) -> &mut DB { + &mut self.inner + } + + /// Recovers the original caller-provided database. + pub fn into_inner(self) -> DB { + self.inner + } + + /// Returns the execution-local anchor controller. + pub const fn controller(&self) -> &L1AnchorController { + &self.controller + } +} + +impl AnchoredZoneDb { + fn anchor(&mut self) -> Result> { + if let Some(anchor) = self.controller.current() { + return Ok(anchor); + } + + let value = self + .inner + .storage(TEMPO_STATE_ADDRESS, TEMPO_BLOCK_NUMBER_SLOT) + .map_err(AnchoredZoneDbError::Inner)?; + let anchor = + u64::try_from(value).map_err(|_| AnchoredZoneDbError::AnchorOverflow(value))?; + self.controller + .initialize(anchor) + .map_err(AnchoredZoneDbError::AnchorTransition) + } + + fn l1_storage( + &self, + address: Address, + slot: U256, + anchor: u64, + ) -> Result> { + self.l1 + .read_l1_storage(address, B256::from(slot), anchor) + .map(|value| value.into()) + .map_err(|source| AnchoredZoneDbError::L1Read { + address, + slot: slot.into(), + anchor, + source, + }) + } + + /// Rejects registry writes and removes the mirrored field from packed TIP-20 transitions. + pub fn sanitize_state( + &self, + state: &mut AddressMap, + ) -> Result<(), AnchoredZoneDbError> { + let l1_write = |address, slot| Err(AnchoredZoneDbError::L1Write { address, slot }); + + if let Some(account) = state.get(&TIP403_REGISTRY_ADDRESS) { + if account.info != account.original_info() { + return l1_write(TIP403_REGISTRY_ADDRESS, U256::ZERO); + } + for (slot, value) in &account.storage { + if value.is_changed() { + return l1_write(TIP403_REGISTRY_ADDRESS, *slot); + } + } + } + + // TODO(rusowsky): remove once TIP-1092 is implemented + for ((address, slot), observed) in &self.packed_policy_slots { + let Some(value) = state + .get_mut(address) + .and_then(|account| account.storage.get_mut(slot)) + else { + continue; + }; + if storage::merge_transfer_policy_id(U256::ZERO, value.present_value) + != storage::merge_transfer_policy_id(U256::ZERO, observed.l1) + { + return l1_write(*address, *slot); + } + + value.original_value = + storage::merge_transfer_policy_id(value.original_value, observed.local); + value.present_value = + storage::merge_transfer_policy_id(value.present_value, observed.local); + } + Ok(()) + } +} + +impl RevmDatabase for AnchoredZoneDb { + type Error = AnchoredZoneDbError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.inner + .basic(address) + .map_err(AnchoredZoneDbError::Inner) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.inner + .code_by_hash(code_hash) + .map_err(AnchoredZoneDbError::Inner) + } + + fn storage(&mut self, address: Address, slot: StorageKey) -> Result { + let local = self + .inner + .storage(address, slot) + .map_err(AnchoredZoneDbError::Inner)?; + if address != TIP403_REGISTRY_ADDRESS && !storage::is_tip20_policy_id_slot(address, slot) { + return Ok(local); + } + + let anchor = self.anchor()?; + let l1 = self.l1_storage(address, slot, anchor)?; + self.controller + .observe_read(anchor) + .map_err(AnchoredZoneDbError::AnchorTransition)?; + + if storage::is_tip20_policy_id_slot(address, slot) { + self.packed_policy_slots + .insert((address, slot), PackedPolicySlot { local, l1 }); + Ok(storage::merge_transfer_policy_id(local, l1)) + } else { + Ok(l1) + } + } + + fn block_hash(&mut self, number: u64) -> Result { + self.inner + .block_hash(number) + .map_err(AnchoredZoneDbError::Inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::TestL1; + use revm::{ + database::{CacheDB, EmptyDB}, + state::EvmStorageSlot, + }; + fn test_db(anchor: u64) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_storage( + TEMPO_STATE_ADDRESS, + TEMPO_BLOCK_NUMBER_SLOT, + U256::from(anchor), + ) + .unwrap(); + db + } + + #[test] + fn overlays_registry_at_selected_state_anchor() { + let anchor = 42; + let slot = U256::from(7); + let expected = U256::from(99); + let l1 = TestL1::default(); + l1.insert(TIP403_REGISTRY_ADDRESS, slot, anchor, expected); + let mut db = AnchoredZoneDb::new(test_db(anchor), l1, L1AnchorController::default()); + + assert_eq!(db.storage(TIP403_REGISTRY_ADDRESS, slot).unwrap(), expected); + assert_eq!(db.controller().current(), Some(anchor)); + } + + #[test] + fn l1_failures_and_read_before_advance_fail_closed() { + let anchor = 42; + let slot = U256::from(7); + let mut failing = AnchoredZoneDb::new( + test_db(anchor), + TestL1::failing_storage(), + L1AnchorController::default(), + ); + assert!(matches!( + failing.storage(TIP403_REGISTRY_ADDRESS, slot), + Err(AnchoredZoneDbError::L1Read { anchor: 42, .. }) + )); + + let reader = TestL1::default(); + reader.insert(TIP403_REGISTRY_ADDRESS, slot, anchor, U256::ONE); + let controller = L1AnchorController::default(); + let mut db = AnchoredZoneDb::new(test_db(anchor), reader.clone(), controller.clone()); + assert_eq!( + db.storage(TIP403_REGISTRY_ADDRESS, slot).unwrap(), + U256::ONE + ); + assert!(controller.begin_advance(anchor, anchor + 1).is_err()); + assert_eq!(reader.storage_requests().len(), 1); + } + + #[test] + fn packed_policy_field_is_removed_from_canonical_transition() { + let anchor = 42; + let token = tempo_precompiles::PATH_USD_ADDRESS; + let slot = tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID; + let offset = tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8; + let local = storage::merge_transfer_policy_id(U256::from(10), U256::from(2) << offset); + let l1_value = U256::from(99) << offset; + let l1 = TestL1::default(); + l1.insert(token, slot, anchor, l1_value); + let mut inner = test_db(anchor); + inner.insert_account_storage(token, slot, local).unwrap(); + let mut db = AnchoredZoneDb::new(inner, l1, L1AnchorController::default()); + let observed = db.storage(token, slot).unwrap(); + + let mut state = AddressMap::default(); + let mut account = Account::default(); + account.storage.insert( + slot, + EvmStorageSlot { + original_value: observed, + present_value: observed + U256::ONE, + ..Default::default() + }, + ); + state.insert(token, account); + db.sanitize_state(&mut state).unwrap(); + + let sanitized = state[&token].storage[&slot].present_value; + assert_eq!( + storage::merge_transfer_policy_id(U256::ZERO, sanitized), + storage::merge_transfer_policy_id(U256::ZERO, local) + ); + assert_eq!(sanitized & U256::from(u64::MAX), U256::from(11)); + } + + #[test] + fn ordinary_storage_is_local_and_inner_database_is_recoverable() { + let address = Address::repeat_byte(0x11); + let slot = U256::from(3); + let value = U256::from(5); + let mut inner = test_db(1); + inner.insert_account_storage(address, slot, value).unwrap(); + let mut db = AnchoredZoneDb::new(inner, TestL1::default(), L1AnchorController::default()); + + assert_eq!(db.storage(address, slot).unwrap(), value); + let mut inner: CacheDB = db.into_inner(); + assert_eq!(inner.storage(address, slot).unwrap(), value); + } +} diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index c5304540a..03b1a0230 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -7,7 +7,10 @@ use alloy_consensus::transaction::TxHashRef; use alloy_evm::{ Database, Evm, RecoveredTx, - block::{BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, GasOutput}, + block::{ + BlockExecutionError, BlockExecutionResult, BlockExecutor, CommitChanges, ExecutableTx, + GasOutput, + }, eth::{EthBlockExecutor, EthTxResult}, }; use reth_evm::block::StateDB; @@ -20,8 +23,9 @@ use tempo_precompiles::{ use tempo_primitives::{TempoReceipt, TempoTxEnvelope, TempoTxType}; use tempo_revm::{TempoStateAccess, evm::TempoContext}; use zone_chainspec::ZoneChainSpec; +use zone_l1::state::L1StateProvider; -use crate::{ZoneEvm, tx_context}; +use crate::{AnchoredZoneDb, ZoneEvm, tx_context}; /// Simplified block executor for zone nodes. /// @@ -34,7 +38,7 @@ pub struct ZoneBlockExecutor<'a, DB: Database, I> { impl<'a, DB, I> ZoneBlockExecutor<'a, DB, I> where DB: StateDB, - I: Inspector>, + I: Inspector>>, { /// Create a zone block executor for `evm` and the current block context. pub fn new( @@ -82,7 +86,7 @@ where impl<'a, DB, I> BlockExecutor for ZoneBlockExecutor<'a, DB, I> where DB: StateDB, - I: Inspector>, + I: Inspector>>, { type Transaction = TempoTxEnvelope; type Receipt = TempoReceipt; @@ -99,15 +103,33 @@ where ) -> Result { let (tx_env, recovered) = tx.into_parts(); - // Override the validator's fee token preference to match this - // transaction's resolved fee token, so the handler skips FeeAMM. - self.override_validator_token(); + // System txs do not pay protocol fees. Resolving their validator token here could read L1 + // policy state at N before `advanceTempo` moves the controller to N+1, causing the anchor + // advancement to fail. Thus, FeeAMM override is restricted to regular fee-paying txs. + if !tx_env.is_system_tx { + self.override_validator_token(); + } let _tx_hash_guard = tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); self.inner .execute_transaction_without_commit((tx_env, recovered)) } + /// Restores execution-local L1 anchor state when a simulated transaction is not committed. + fn execute_transaction_with_commit_condition( + &mut self, + tx: impl ExecutableTx, + f: impl FnOnce(&Self::Result) -> CommitChanges, + ) -> Result, BlockExecutionError> { + let snapshot = self.evm().anchor_controller().phase(); + let output = self.execute_transaction_without_commit(tx)?; + if !f(&output).should_commit() { + self.evm().anchor_controller().restore(snapshot); + return Ok(None); + } + Ok(Some(self.commit_transaction(output))) + } + fn commit_transaction(&mut self, output: Self::Result) -> GasOutput { self.inner.commit_transaction(output) } diff --git a/crates/evm/src/fee_manager.rs b/crates/evm/src/fee_manager.rs deleted file mode 100644 index a291532c7..000000000 --- a/crates/evm/src/fee_manager.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! L1-aware implementation of Tempo's internal protocol fee hooks. - -use alloy_evm::Database; -use alloy_primitives::{Address, U256}; -use core::fmt; -use reth_evm::EvmInternals; -use tempo_evm::{ProtocolFeeContext, ProtocolFeeManager}; -use tempo_precompiles::{ - error::Result, - storage::{PrecompileStorageProvider, StorageCtx, evm::EvmPrecompileStorageProvider}, - tip_fee_manager::TipFeeManager, -}; -use zone_l1::state::L1StateProvider; -use zone_precompiles::{L1StorageReader, storage::ZonePrecompileStorageProvider}; - -/// Tempo protocol fee hooks executed against the Zone's finalized L1 policy overlay. -#[derive(Clone)] -pub(crate) struct ZoneFeeManager { - l1_reader: L1, -} - -impl fmt::Debug for ZoneFeeManager { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ZoneFeeManager").finish_non_exhaustive() - } -} - -impl ZoneFeeManager { - pub(crate) const fn new(l1_reader: L1) -> Self { - Self { l1_reader } - } - - fn enter( - &self, - ctx: ProtocolFeeContext<'_, DB>, - f: impl FnOnce() -> Result, - ) -> Result { - let ProtocolFeeContext { - journal, - block_env, - cfg, - tx_env, - actions, - } = ctx; - let internals = EvmInternals::new(journal, block_env, cfg, tx_env); - let mut inner = - EvmPrecompileStorageProvider::new_max_gas(internals, cfg).with_actions(actions); - inner.set_tip1060_storage_credits(false); - - let mut storage = ZonePrecompileStorageProvider::try_new(inner, self.l1_reader.clone()) - .map_err(|error| error.into_error())?; - StorageCtx::enter(&mut storage, f) - } -} - -impl ProtocolFeeManager for ZoneFeeManager { - fn collect_fee_pre_tx( - &self, - ctx: ProtocolFeeContext<'_, DB>, - fee_payer: Address, - user_token: Address, - max_amount: U256, - beneficiary: Address, - skip_liquidity_check: bool, - ) -> Result
{ - self.enter(ctx, || { - TipFeeManager::new().collect_fee_pre_tx( - fee_payer, - user_token, - max_amount, - beneficiary, - skip_liquidity_check, - ) - }) - } - - fn collect_fee_post_tx( - &self, - ctx: ProtocolFeeContext<'_, DB>, - fee_payer: Address, - actual_spending: U256, - refund_amount: U256, - fee_token: Address, - beneficiary: Address, - ) -> Result { - self.enter(ctx, || { - TipFeeManager::new().collect_fee_post_tx( - fee_payer, - actual_spending, - refund_amount, - fee_token, - beneficiary, - ) - }) - } -} diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 6f5a25877..3c9d0b9bf 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -7,18 +7,22 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![allow(unnameable_types)] +mod database; mod executor; -mod fee_manager; pub mod precompiles; +#[cfg(test)] +mod test_utils; mod tx_context; +mod validation; mod zone_evm; +pub use database::{AnchoredZoneDb, AnchoredZoneDbError}; pub use executor::ZoneBlockExecutor; +pub use validation::{AdvanceTempoValidationError, validate_advance_tempo_transactions}; pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; use crate::{ - fee_manager::ZoneFeeManager, - precompiles::{L1StorageReader, SequencerExt, extend_zone_precompiles}, + precompiles::{L1AnchorController, L1StorageReader, SequencerExt, extend_zone_precompiles}, tx_context::ZoneTxContext, }; use alloy_evm::{ @@ -70,12 +74,12 @@ where Self { l1_reader } } - fn register_precompiles>>( + fn register_precompiles>>>( &self, - evm: TempoEvm, - ) -> TempoEvm { + mut evm: TempoEvm, I>, + controller: L1AnchorController, + ) -> TempoEvm, I> { let cfg = evm.ctx().cfg.clone(); - let mut evm = evm.with_fee_manager(ZoneFeeManager::new(self.l1_reader.clone())); let (_, _, precompiles) = evm.components_mut(); let sequencer: Arc = Arc::new(self.l1_reader.clone()); extend_zone_precompiles( @@ -85,6 +89,7 @@ where sequencer, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), + controller, ); precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| Some(ZoneTxContext::create())); evm @@ -95,8 +100,8 @@ impl EvmFactory for ZoneEvmFactory where L1: L1StorageReader + SequencerExt, { - type Evm>> = ZoneEvm; - type Context = TempoCtx; + type Evm>> = ZoneEvm; + type Context = TempoCtx>; type Tx = ::Tx; type Error = ::Error; type HaltReason = TempoHaltReason; @@ -109,8 +114,13 @@ where db: DB, input: EvmEnv, ) -> Self::Evm { + let controller = L1AnchorController::default(); + let db = AnchoredZoneDb::new(db, self.l1_reader.clone(), controller.clone()); let evm = TempoEvm::new(db, input); - ZoneEvm::new(self.register_precompiles(evm)) + ZoneEvm::new( + self.register_precompiles(evm, controller.clone()), + controller, + ) } fn create_evm_with_inspector>>( @@ -119,8 +129,13 @@ where input: EvmEnv, inspector: I, ) -> Self::Evm { + let controller = L1AnchorController::default(); + let db = AnchoredZoneDb::new(db, self.l1_reader.clone(), controller.clone()); let evm = TempoEvm::new(db, input).with_inspector(inspector); - ZoneEvm::new(self.register_precompiles(evm)) + ZoneEvm::new( + self.register_precompiles(evm, controller.clone()), + controller, + ) } } @@ -253,7 +268,8 @@ impl BlockExecutorFactory for ZoneEvmConfig { type Transaction = TempoTxEnvelope; type Receipt = TempoReceipt; type TxExecutionResult = EthTxResult; - type Executor<'a, DB: StateDB, I: Inspector>> = ZoneBlockExecutor<'a, DB, I>; + type Executor<'a, DB: StateDB, I: Inspector>>> = + ZoneBlockExecutor<'a, DB, I>; fn evm_factory(&self) -> &Self::EvmFactory { &self.zone_factory @@ -266,7 +282,7 @@ impl BlockExecutorFactory for ZoneEvmConfig { ) -> Self::Executor<'a, DB, I> where DB: StateDB, - I: Inspector>, + I: Inspector>>, { ZoneBlockExecutor::new(evm, ctx, self.chain_spec()) } @@ -314,6 +330,9 @@ impl ConfigureEvm for ZoneEvmConfig { use alloy_evm::eth::EthBlockExecutionCtx; use std::borrow::Cow; + validate_advance_tempo_transactions(&block.body().transactions) + .map_err(|err| TempoEvmError::InvalidEvmConfig(err.to_string()))?; + Ok(TempoBlockExecutionCtx { inner: EthBlockExecutionCtx { parent_hash: block.header().parent_hash(), @@ -373,12 +392,24 @@ impl ConfigureEngineEvm for ZoneEvmConfig { #[cfg(test)] mod tests { use super::*; + use crate::test_utils::TestL1; + use alloy_evm::revm::database::EmptyDB; use reth_chainspec::{EthChainSpec, ForkCondition}; use tempo_chainspec::{ hardfork::TempoHardfork, spec::{DEV, MODERATO, TempoHardforks}, }; + #[test] + fn factory_context_adapts_and_recovers_original_database() { + let factory = ZoneEvmFactory::new(TestL1::default()); + let mut evm = factory.create_evm(EmptyDB::default(), EvmEnv::default()); + let _: &EmptyDB = evm.db(); + let _: &mut EmptyDB = evm.db_mut(); + let (db, _) = evm.finish(); + let _: EmptyDB = db; + } + #[test] fn composed_chain_spec_uses_zone_identity_and_parent_tempo_forks() { let zone = ZoneChainSpec::from(DEV.clone()); diff --git a/crates/evm/src/test_utils.rs b/crates/evm/src/test_utils.rs new file mode 100644 index 000000000..571c322ae --- /dev/null +++ b/crates/evm/src/test_utils.rs @@ -0,0 +1,3 @@ +//! Shared test utilities for Zone EVM tests. + +pub(crate) use zone_precompiles::test_utils::MockL1Reader as TestL1; diff --git a/crates/evm/src/validation.rs b/crates/evm/src/validation.rs new file mode 100644 index 000000000..81a634c04 --- /dev/null +++ b/crates/evm/src/validation.rs @@ -0,0 +1,154 @@ +//! Zone block transaction-order validation. + +use alloy_consensus::Transaction; +use alloy_primitives::TxKind; +use alloy_sol_types::SolCall; +use tempo_primitives::TempoTxEnvelope; +use tempo_zone_contracts::ZoneInbox; +use thiserror::Error; +use zone_primitives::constants::ZONE_INBOX_ADDRESS; + +/// Invalid required `advanceTempo` transaction sequence. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AdvanceTempoValidationError { + /// The block contains no transactions. + #[error("block is missing the required advanceTempo transaction")] + Missing, + /// Transaction zero is not a canonical Tempo system transaction. + #[error("transaction zero must use the canonical Tempo system transaction envelope")] + InvalidSystemEnvelope, + /// Transaction zero does not call the Zone inbox. + #[error("transaction zero must call the ZoneInbox advanceTempo entrypoint")] + InvalidTarget, + /// Transaction zero does not select `advanceTempo`. + #[error("transaction zero must select ZoneInbox.advanceTempo")] + InvalidSelector, + /// The `advanceTempo` calldata does not ABI-decode. + #[error("invalid advanceTempo calldata: {0}")] + InvalidCalldata(String), + /// A later transaction attempts another advancement. + #[error("duplicate advanceTempo transaction at index {index}")] + Duplicate { + /// Zero-based transaction index. + index: usize, + }, +} + +fn calls_advance_tempo(tx: &TempoTxEnvelope) -> bool { + matches!(tx.kind(), TxKind::Call(to) if to == ZONE_INBOX_ADDRESS) + && tx + .input() + .starts_with(&ZoneInbox::advanceTempoCall::SELECTOR) +} + +/// Validates that the block starts with exactly one canonical `advanceTempo` system transaction. +/// +/// Tempo's reserved system signature uniquely identifies the zero-address system sender, so +/// [`TempoTxEnvelope::is_system_tx`] validates both the envelope signature and recovered sender +/// convention used by block execution. +pub fn validate_advance_tempo_transactions( + transactions: &[TempoTxEnvelope], +) -> Result<(), AdvanceTempoValidationError> { + let first = transactions + .first() + .ok_or(AdvanceTempoValidationError::Missing)?; + if !first.is_system_tx() { + return Err(AdvanceTempoValidationError::InvalidSystemEnvelope); + } + if !matches!(first.kind(), TxKind::Call(to) if to == ZONE_INBOX_ADDRESS) { + return Err(AdvanceTempoValidationError::InvalidTarget); + } + if !first + .input() + .starts_with(&ZoneInbox::advanceTempoCall::SELECTOR) + { + return Err(AdvanceTempoValidationError::InvalidSelector); + } + ZoneInbox::advanceTempoCall::abi_decode(first.input()) + .map_err(|err| AdvanceTempoValidationError::InvalidCalldata(err.to_string()))?; + + if let Some((index, _)) = transactions + .iter() + .enumerate() + .skip(1) + .find(|(_, tx)| calls_advance_tempo(tx)) + { + return Err(AdvanceTempoValidationError::Duplicate { index }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Signed, TxLegacy}; + use alloy_primitives::{Address, Bytes, U256}; + use tempo_primitives::transaction::envelope::TEMPO_SYSTEM_TX_SIGNATURE; + + fn advance_tx() -> TempoTxEnvelope { + let input = ZoneInbox::advanceTempoCall { + header: Bytes::new(), + deposits: Vec::new(), + decryptions: Vec::new(), + enabledTokens: Vec::new(), + } + .abi_encode() + .into(); + TempoTxEnvelope::Legacy(Signed::new_unhashed( + TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 0, + gas_limit: 0, + to: ZONE_INBOX_ADDRESS.into(), + value: U256::ZERO, + input, + }, + TEMPO_SYSTEM_TX_SIGNATURE, + )) + } + + fn regular_tx() -> TempoTxEnvelope { + TempoTxEnvelope::Legacy(Signed::new_unhashed( + TxLegacy { + to: Address::repeat_byte(0x11).into(), + ..Default::default() + }, + alloy_primitives::Signature::test_signature(), + )) + } + + #[test] + fn requires_advance_at_transaction_zero() { + assert_eq!( + validate_advance_tempo_transactions(&[]), + Err(AdvanceTempoValidationError::Missing) + ); + assert_eq!( + validate_advance_tempo_transactions(&[regular_tx()]), + Err(AdvanceTempoValidationError::InvalidSystemEnvelope) + ); + assert!(validate_advance_tempo_transactions(&[advance_tx()]).is_ok()); + } + + #[test] + fn rejects_duplicate_advance() { + assert_eq!( + validate_advance_tempo_transactions(&[advance_tx(), regular_tx(), advance_tx(),]), + Err(AdvanceTempoValidationError::Duplicate { index: 2 }) + ); + } + + #[test] + fn rejects_malformed_advance_calldata() { + let mut tx = advance_tx(); + let TempoTxEnvelope::Legacy(signed) = &mut tx else { + unreachable!() + }; + signed.tx_mut().input = ZoneInbox::advanceTempoCall::SELECTOR.to_vec().into(); + assert!(matches!( + validate_advance_tempo_transactions(&[tx]), + Err(AdvanceTempoValidationError::InvalidCalldata(_)) + )); + } +} diff --git a/crates/evm/src/zone_evm/contract_creation.rs b/crates/evm/src/zone_evm/contract_creation.rs index 67414eae6..c547e5b04 100644 --- a/crates/evm/src/zone_evm/contract_creation.rs +++ b/crates/evm/src/zone_evm/contract_creation.rs @@ -73,7 +73,7 @@ fn contract_creation_deployer(tx: &TempoTxEnv) -> Option
{ #[cfg(test)] mod tests { use super::*; - use crate::ZoneEvm; + use crate::{AnchoredZoneDb, ZoneEvm, test_utils::TestL1}; use alloy_evm::{Evm, EvmEnv}; use alloy_primitives::{Address, Bytes, TxKind, U256, bytes}; use revm::{ @@ -89,18 +89,20 @@ mod tests { use tempo_evm::{TempoBlockEnv, TempoHaltReason}; use tempo_primitives::transaction::Call; use tempo_revm::{TempoBatchCallEnv, TempoTxEnv}; + use zone_precompiles::L1AnchorController; type TestDb = CacheDB; + type TestAdaptedDb = AnchoredZoneDb; const TEST_DEPLOYER: Address = Address::new([0x42; 20]); fn test_create( - context: ZoneInstructionCtx<'_, TestDb>, + context: ZoneInstructionCtx<'_, TestAdaptedDb>, ) -> Result<(), InstructionResult> { - create::(context, &[TEST_DEPLOYER]) + create::(context, &[TEST_DEPLOYER]) } - fn enable_test_deployer(evm: &mut ZoneEvm) { + fn enable_test_deployer(evm: &mut ZoneEvm) { let instructions = &mut evm.inner.inner_mut().inner.instruction; instructions.insert_instruction(CREATE, Instruction::new(test_create::), 0); instructions.insert_instruction(CREATE2, Instruction::new(test_create::), 0); @@ -122,13 +124,18 @@ mod tests { db } - fn evm_with_contract(addr: Address, code: &[u8]) -> ZoneEvm { - let input: EvmEnv = - EvmEnv::default(); - ZoneEvm::new(TempoEvm::new( - test_db([(addr, Bytes::copy_from_slice(code))]), - input, - )) + fn test_evm( + db: TestDb, + input: EvmEnv, + ) -> ZoneEvm { + let controller = L1AnchorController::default(); + let db = AnchoredZoneDb::new(db, TestL1::default(), controller.clone()); + ZoneEvm::new(TempoEvm::new(db, input), controller) + } + + fn evm_with_contract(addr: Address, code: &[u8]) -> ZoneEvm { + let input = EvmEnv::default(); + test_evm(test_db([(addr, Bytes::copy_from_slice(code))]), input) } fn call_tx(caller: Address, contract: Address) -> TempoTxEnv { @@ -247,13 +254,13 @@ mod tests { let input: EvmEnv = EvmEnv::default(); - let mut evm = ZoneEvm::new(TempoEvm::new( + let mut evm = test_evm( test_db([ (proxy, Bytes::from(proxy_code)), (TEST_DEPLOYER, bytes!("0x5f5f5ff000")), ]), input, - )); + ); enable_test_deployer(&mut evm); let result = evm diff --git a/crates/evm/src/zone_evm/mod.rs b/crates/evm/src/zone_evm/mod.rs index 1a5c0641a..cbcd99cef 100644 --- a/crates/evm/src/zone_evm/mod.rs +++ b/crates/evm/src/zone_evm/mod.rs @@ -2,43 +2,65 @@ pub(crate) mod contract_creation; -use crate::TempoCtx; +use crate::{ + TempoCtx, + database::{AnchoredZoneDb, AnchoredZoneDbError}, +}; use alloy_evm::{Database, Evm, EvmEnv, precompiles::PrecompilesMap, revm::Inspector}; use alloy_primitives::{Address, Bytes}; -use revm::context::result::{EVMError, ResultAndState}; +use revm::context::{ + DBErrorMarker, + result::{EVMError, ResultAndState}, +}; use tempo_evm::{TempoBlockEnv, TempoHaltReason, evm::TempoEvm}; use tempo_revm::{TempoInvalidTransaction, TempoTxEnv}; +use zone_l1::state::L1StateProvider; +use zone_precompiles::{L1AnchorController, L1StorageReader}; use zone_primitives::constants::CONTRACT_DEPLOYER_ALLOWLIST; /// Zone runtime EVM. /// -/// Wraps Tempo (L1) EVM to enforce Zone-specific execution rules. -pub struct ZoneEvm { - inner: TempoEvm, +/// Execution uses an anchored database adapter internally while the public [`Evm::DB`] remains the +/// exact database supplied by the caller. +pub struct ZoneEvm { + inner: TempoEvm, I>, + controller: L1AnchorController, } -impl ZoneEvm { +impl ZoneEvm { /// Creates a new `ZoneEvm` with guarded `CREATE` and `CREATE2` opcodes. - pub(super) fn new(mut evm: TempoEvm) -> Self { + pub(super) fn new( + mut evm: TempoEvm, I>, + controller: L1AnchorController, + ) -> Self { contract_creation::configure_runtime(&mut evm); - Self { inner: evm } + Self { + inner: evm, + controller, + } } /// Provides a reference to the EVM context. - pub fn ctx(&self) -> &TempoCtx { + pub fn ctx(&self) -> &TempoCtx> { self.inner.ctx() } /// Provides a mutable reference to the EVM context. - pub fn ctx_mut(&mut self) -> &mut TempoCtx { + pub fn ctx_mut(&mut self) -> &mut TempoCtx> { self.inner.ctx_mut() } + + /// Returns the execution-local anchor controller. + pub const fn anchor_controller(&self) -> &L1AnchorController { + &self.controller + } } -impl Evm for ZoneEvm +impl Evm for ZoneEvm where DB: Database, - I: Inspector>, + L1: L1StorageReader, + I: Inspector>>, { type DB = DB; type Tx = TempoTxEnv; @@ -66,7 +88,23 @@ where tx: Self::Tx, ) -> Result, Self::Error> { contract_creation::validate_transaction(&tx, CONTRACT_DEPLOYER_ALLOWLIST)?; - self.inner.transact_raw(tx) + let snapshot = self.controller.phase(); + let mut result = match self.inner.transact_raw(tx) { + Ok(result) => result, + Err(err) => { + self.controller.restore(snapshot); + return Err(map_adapter_error(err)); + } + }; + if !result.result.is_success() { + self.controller.restore(snapshot); + return Ok(result); + } + if let Err(err) = self.inner.db().sanitize_state(&mut result.state) { + self.controller.restore(snapshot); + return Err(err.into_evm_error()); + } + Ok(result) } fn transact_system_call( @@ -75,11 +113,28 @@ where contract: Address, data: Bytes, ) -> Result, Self::Error> { - self.inner.transact_system_call(caller, contract, data) + let snapshot = self.controller.phase(); + let mut result = match self.inner.transact_system_call(caller, contract, data) { + Ok(result) => result, + Err(err) => { + self.controller.restore(snapshot); + return Err(map_adapter_error(err)); + } + }; + if !result.result.is_success() { + self.controller.restore(snapshot); + return Ok(result); + } + if let Err(err) = self.inner.db().sanitize_state(&mut result.state) { + self.controller.restore(snapshot); + return Err(err.into_evm_error()); + } + Ok(result) } fn finish(self) -> (Self::DB, EvmEnv) { - self.inner.finish() + let (db, env) = self.inner.finish(); + (db.into_inner(), env) } fn set_inspector_enabled(&mut self, enabled: bool) { @@ -87,10 +142,24 @@ where } fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) { - self.inner.components() + let (db, inspector, precompiles) = self.inner.components(); + (db.inner(), inspector, precompiles) } fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) { - self.inner.components_mut() + let (db, inspector, precompiles) = self.inner.components_mut(); + (db.inner_mut(), inspector, precompiles) + } +} + +fn map_adapter_error( + error: EVMError, TempoInvalidTransaction>, +) -> EVMError { + match error { + EVMError::Transaction(error) => EVMError::Transaction(error), + EVMError::Header(error) => EVMError::Header(error), + EVMError::Database(error) => error.into_evm_error(), + EVMError::Custom(error) => EVMError::Custom(error), + EVMError::CustomAny(error) => EVMError::CustomAny(error), } } diff --git a/crates/payload/Cargo.toml b/crates/payload/Cargo.toml index bab592223..03b7495c0 100644 --- a/crates/payload/Cargo.toml +++ b/crates/payload/Cargo.toml @@ -20,6 +20,7 @@ tempo-revm.workspace = true tempo-transaction-pool.workspace = true tempo-zone-contracts = { workspace = true, features = ["std", "serde", "rpc"] } zone-chainspec.workspace = true +zone-evm.workspace = true zone-l1.workspace = true zone-precompiles = { workspace = true, features = ["std"] } diff --git a/crates/payload/src/builder.rs b/crates/payload/src/builder.rs index 34dccf3f3..c5728954c 100644 --- a/crates/payload/src/builder.rs +++ b/crates/payload/src/builder.rs @@ -274,6 +274,9 @@ where block_access_list: _, } = builder.finish(&*state_provider, None)?; + zone_evm::validate_advance_tempo_transactions(&block.sealed_block().body().transactions) + .map_err(PayloadBuilderError::other)?; + let requests = chain_spec .is_prague_active_at_timestamp(attributes.timestamp()) .then_some(execution_result.requests.clone()); diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index 1d199e124..283b0f678 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -1,22 +1,15 @@ -//! Shared execution for zone-native and L1-backed Tempo precompiles. +//! Shared execution for Zone-native and upstream 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` and -//! overlays selected policy storage from that exact L1 block. Execution uses the active Tempo -//! hardfork already selected by the composed Zone EVM configuration. +//! Both helpers install an EVM-backed [`StorageCtx`], apply Zone-specific [`CallRules`], and +//! forward admitted calls without changing their calldata or caller. Protocol storage reaches the +//! execution-local anchored database through the ordinary revm journal. //! //! # Call ordering //! -//! 1. L1-backed execution rejects delegate calls before storage access. +//! 1. Protocol 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 and install the storage overlay, then apply -//! the L1-backed rules phase. +//! 3. Apply the local phase of [`CallRules`]. +//! 4. Apply rules that may inspect anchored state through ordinary EVM storage. //! 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 @@ -37,32 +30,22 @@ use tempo_precompiles::{ storage_credits::NonCreditableSlots, }; -use crate::storage::{L1StorageReader, ZonePrecompileStorageProvider}; - -/// 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 is resolved from the local `TempoState` anchor during execution, while the active -/// hardfork comes from the configuration already resolved by the composed Zone EVM. +/// Shared inputs for protocol precompiles whose storage is provided by the EVM context. #[derive(Clone)] -pub(crate) struct L1BackedPrecompileEnv

{ +pub(crate) struct ProtocolPrecompileEnv { 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. +impl ProtocolPrecompileEnv { 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, } @@ -108,8 +91,7 @@ impl<'a> ZoneCall<'a> { 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. + /// Protocol precompiles observe finalized L1 policy state through the EVM database adapter. Continue, /// Reject the call without invoking the supplied implementation. /// @@ -120,8 +102,8 @@ pub(crate) enum CallCheck { /// 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 +/// The local phase runs before rules that may inspect finalized L1 state. Anchored reads in either +/// phase are resolved by the EVM database adapter. 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 { @@ -138,10 +120,7 @@ pub(crate) trait CallRules: 'static { CallCheck::Continue } - /// 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. + /// Apply rules whose answer must come from finalized Tempo L1-backed storage. fn check_with_l1_backed_state(&self, _call: ZoneCall<'_>) -> CallCheck { CallCheck::Continue } @@ -212,18 +191,13 @@ pub(crate) fn create_local_precompile( }) } -/// Create a direct-call-only precompile backed by the finalized Tempo L1 anchor. +/// Create a direct-call-only protocol precompile using ordinary EVM storage. /// -/// The helper rejects delegate calls before any storage access, reads the `TempoState` anchor once, -/// and constructs [`ZonePrecompileStorageProvider`] with that exact block. The active hardfork -/// comes from the composed Zone EVM configuration rather than the anchor. Any anchor 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( +/// The Zone EVM's database adapter supplies anchored policy values below the revm journal, so this +/// helper does not install a per-precompile storage wrapper. +pub(crate) fn create_protocol_precompile( id: &'static str, - env: L1BackedPrecompileEnv

, + env: ProtocolPrecompileEnv, rules: impl CallRules, execute: impl Fn(&[u8], Address) -> PrecompileResult + 'static, ) -> DynPrecompile { @@ -232,7 +206,6 @@ pub(crate) fn create_l1_backed_precompile( 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| { let call = ZoneCall::new(&input); @@ -252,7 +225,7 @@ pub(crate) fn create_l1_backed_precompile( )); } - let mut inner = EvmPrecompileStorageProvider::new( + let mut storage = EvmPrecompileStorageProvider::new( input.internals, fixed_gas.map_or(input.gas, |_| u64::MAX), input.reservoir, @@ -264,19 +237,14 @@ pub(crate) fn create_l1_backed_precompile( .with_actions(actions.clone()) .with_non_creditable_slots(non_creditable_slots.clone()); - match StorageCtx::enter(&mut inner, || rules.check_with_local_state(call)) { + match StorageCtx::enter(&mut storage, || rules.check_with_local_state(call)) { CallCheck::Continue => {} CallCheck::Return(result) => { - let result = StorageCtx::enter(&mut inner, || add_input_cost(call.data, result)); + let result = StorageCtx::enter(&mut storage, || add_input_cost(call.data, result)); return apply_fixed_gas(result, fixed_gas); } } - let mut storage = match ZonePrecompileStorageProvider::try_new(inner, l1_reader.clone()) { - Ok(storage) => storage, - Err(err) => return err.into_precompile_result(), - }; - if let Some(check_result) = StorageCtx::enter(&mut storage, || { match rules.check_with_l1_backed_state(call) { CallCheck::Continue => None, @@ -314,10 +282,7 @@ fn add_input_cost(calldata: &[u8], mut result: PrecompileResult) -> PrecompileRe #[cfg(test)] mod tests { use super::*; - use crate::{ - tempo_state::slots as tempo_state_slots, - test_utils::{MockL1Reader, test_context, test_storage_provider}, - }; + use crate::test_utils::{test_context, test_storage_provider}; use alloy_evm::{ EvmInternals, precompiles::{Precompile as _, PrecompileInput}, @@ -327,7 +292,6 @@ mod tests { cell::{Cell, RefCell}, rc::Rc, }; - use tempo_precompiles::storage::PrecompileStorageProvider; const FIXED_GAS: u64 = 123; type RuleRecord = Rc, Address)>>>; @@ -407,21 +371,18 @@ mod tests { } #[test] - fn l1_backed_admission_precedes_anchored_provider() { - let anchor = 42; - let reader = MockL1Reader::default(); + fn protocol_precompile_applies_admission_and_evm_spec() { 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 env = L1BackedPrecompileEnv::new( + let env = ProtocolPrecompileEnv::new( &cfg, - reader, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), ); let checked = Rc::new(Cell::new(false)); - let rejected = create_l1_backed_precompile( + let rejected = create_protocol_precompile( "L1AdmissionTest", env.clone(), RejectRules(checked.clone()), @@ -437,17 +398,10 @@ mod tests { assert!(checked.get()); let precompile = - create_l1_backed_precompile("L1BackedTest", env, NoCallRules, move |_, _| { + create_protocol_precompile("ProtocolTest", env, NoCallRules, move |_, _| { execute_spec.set(Some(StorageCtx::default().spec())); Ok(StorageCtx::default().success_output(Bytes::new())) }); - 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)) diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 28d2b323a..ad501c131 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -1,12 +1,9 @@ -//! Zone-native precompiles and shared execution for Tempo precompiles on a zone. +//! 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. TIP-20, TIP-403, and `TipFeeManager` use -//! anchored upstream execution while the zone retains only its admission and gas rules. +//! All implementations use ordinary EVM storage. The Zone EVM installs an anchored database below +//! the revm journal, so upstream TIP-20, TIP-403, and fee-manager code transparently observe +//! finalized Tempo policy state. Zone admission, delegate-call, fixed-gas, and privacy rules remain +//! outside the forwarded business logic. //! //! 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. @@ -54,7 +51,8 @@ pub mod ztip20; pub use aes_gcm::{AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt}; pub use chaum_pedersen::{CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify}; -pub use storage::L1StorageReader; +pub use storage::{L1AnchorController, L1StorageReader}; +pub use tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS; pub use tempo_state::TempoState; pub use tip20_factory::{ZONE_TIP20_FACTORY_ADDRESS, ZoneTokenFactory}; pub use tip403_proxy::ZONE_TIP403_PROXY_ADDRESS; @@ -83,8 +81,8 @@ use zone_primitives::constants::TEMPO_STATE_ADDRESS; /// /// - **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`]. +/// - **Anchored L1 execution:** TIP-20, TIP-403, and `TipFeeManager` use ordinary EVM storage; +/// the Zone EVM context resolves mirrored reads through its anchored database adapter. pub fn extend_zone_precompiles( precompiles: &mut PrecompilesMap, cfg: &CfgEnv, @@ -92,17 +90,18 @@ pub fn extend_zone_precompiles( sequencer: Arc, actions: StorageActions, non_creditable_slots: Rc>, + controller: L1AnchorController, ) { - let l1_env = execution::L1BackedPrecompileEnv::new( - cfg, - l1_reader.clone(), - actions.clone(), - non_creditable_slots.clone(), - ); + let protocol_env = + execution::ProtocolPrecompileEnv::new(cfg, 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)) + Some(TempoState::create( + l1_reader.clone(), + controller.clone(), + cfg, + )) }); precompiles.apply_precompile(&CHAUM_PEDERSEN_VERIFY_ADDRESS, |_| { Some(ChaumPedersenVerify::create(cfg)) @@ -114,7 +113,7 @@ pub fn extend_zone_precompiles( Some(ZoneTokenFactory::create(cfg)) }); - let tip403_env = l1_env.clone(); + let tip403_env = protocol_env.clone(); precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, move |_| { Some(create_tip403_precompile(&tip403_env)) }); @@ -125,13 +124,13 @@ pub fn extend_zone_precompiles( if is_tip20_prefix(*address) { Some(create_tip20_precompile( *address, - &l1_env, + &protocol_env, sequencer.clone(), )) } else if *address == TIP_FEE_MANAGER_ADDRESS { - Some(execution::create_l1_backed_precompile( + Some(execution::create_protocol_precompile( "TipFeeManager", - l1_env.clone(), + protocol_env.clone(), execution::NoCallRules, |data, caller| TipFeeManager::new().call(data, caller), )) @@ -175,12 +174,16 @@ 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 { + pub fn create( + reader: P, + controller: L1AnchorController, + cfg: &CfgEnv, + ) -> DynPrecompile { execution::create_local_precompile( "TempoState", cfg, execution::DirectCallOnly, - move |data, caller| Self::new().call_with_provider(&reader, data, caller), + move |data, caller| Self::new().call_with_provider(&reader, &controller, data, caller), ) } } @@ -198,10 +201,8 @@ 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( +pub(crate) fn create_tip403_precompile(env: &execution::ProtocolPrecompileEnv) -> DynPrecompile { + execution::create_protocol_precompile( "ZoneTip403Registry", env.clone(), tip403_proxy::Tip403Rules, @@ -210,12 +211,12 @@ pub(crate) fn create_tip403_precompile( } /// Create upstream TIP-20 execution with zone rules and finalized L1 policy reads. -pub(crate) fn create_tip20_precompile( +pub(crate) fn create_tip20_precompile( address: alloy_primitives::Address, - env: &execution::L1BackedPrecompileEnv

, + env: &execution::ProtocolPrecompileEnv, sequencer: Arc, ) -> DynPrecompile { - execution::create_l1_backed_precompile( + execution::create_protocol_precompile( "TIP20Token", env.clone(), ztip20::TIP20Rules::new(sequencer), @@ -233,10 +234,11 @@ pub fn zone_rpc_error(msg: impl core::fmt::Display) -> PrecompileError { PrecompileError::Fatal(alloc::format!("{ZONE_RPC_ERROR_PREFIX} {msg}")) } -/// Returns `true` if the error string was produced by [`zone_rpc_error`]. +/// Returns `true` if the error chain contains a failure produced by [`zone_rpc_error`]. pub fn is_zone_rpc_error(err: &str) -> bool { - err.starts_with(ZONE_RPC_ERROR_PREFIX) + err.contains(ZONE_RPC_ERROR_PREFIX) } -#[cfg(test)] -mod test_utils; +#[cfg(any(test, feature = "test-utils"))] +#[doc(hidden)] +pub mod test_utils; diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 099a458e9..f9aae3baa 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -1,45 +1,17 @@ -//! 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; +//! Shared state for the Zone EVM's anchored Tempo database adapter. + +use alloc::rc::Rc; +use core::{cell::RefCell, fmt}; + +use alloy_primitives::{Address, B256, U256}; +use revm::precompile::PrecompileError; +use tempo_precompiles::tip20::tip20_slots; +use tempo_primitives::TempoAddressExt; +use thiserror::Error; 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, PrecompileResult}, - state::{AccountInfo, Bytecode}, -}; -use tempo_chainspec::hardfork::TempoHardfork; -use tempo_contracts::precompiles::{TIP403_REGISTRY_ADDRESS, TIP403RegistryError}; -use tempo_precompiles::{ - error::{Result, TempoPrecompileError}, - storage::evm::EvmPrecompileStorageProvider, - tip20::{TIP20Error, 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. +/// L1 storage access needed by the anchored Zone database and `TempoState` reads. pub trait L1StorageReader: Clone + Send + Sync + 'static { /// Read `account[slot]` at `block_number` on Tempo L1. fn read_l1_storage( @@ -50,261 +22,201 @@ pub trait L1StorageReader: Clone + Send + Sync + 'static { ) -> 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: P, +/// Invalid operation for the current anchor phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("invalid anchor operation {operation:?} in phase {phase:?}")] +pub struct L1AnchorError { + /// Attempted operation. + pub operation: L1AnchorOperation, + /// Phase in which the operation was attempted. + pub phase: L1AnchorPhase, } -/// 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, +/// Operation applied to the execution-local anchor state machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum L1AnchorOperation { + /// Initialize from the checkpoint stored in selected Zone state. + Initialize { anchor: u64 }, + /// Observe external Tempo state at an anchor. + Read { anchor: u64 }, + /// Advance from a parent Tempo block to its direct child. + Advance { from: u64, to: u64 }, } -impl ZonePrecompileStorageProviderInitError { - /// Return the underlying Tempo error for protocol execution outside a metered precompile call. - pub fn into_error(self) -> TempoPrecompileError { - self.error - } - - /// 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) - } +/// Current execution-local Tempo anchor phase. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum L1AnchorPhase { + /// The selected Zone state's checkpoint has not been loaded yet. + #[default] + Uninitialized, + /// Execution is still at the parent anchor. + Parent { + /// Parent Tempo block number. + anchor: u64, + /// Whether external Tempo state was observed at this anchor. + has_read_l1: bool, + }, + /// The required system transaction advanced this execution to the child anchor. + Advanced { + /// Parent Tempo block number. + from: u64, + /// Child Tempo block number. + to: u64, + /// Whether external Tempo state was observed after advancement. + has_read_l1: bool, + }, } -impl<'a, P: L1StorageReader> ZonePrecompileStorageProvider<'a, P> { - /// 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, - l1_block_number, - } - } -} - -/// Read the finalized Tempo/L1 block number once before constructing the zone provider. -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); +impl L1AnchorPhase { + /// Returns the anchor used by reads in this phase, if initialized. + pub const fn current(self) -> Option { + match self { + Self::Uninitialized => None, + Self::Parent { anchor, .. } => Some(anchor), + Self::Advanced { to, .. } => Some(to), } - 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) + /// Returns whether this phase has completed the required advancement. + pub const fn is_advanced(self) -> bool { + matches!(self, Self::Advanced { .. }) } - fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { - 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)); + const fn parent(anchor: u64) -> Self { + Self::Parent { + anchor, + has_read_l1: false, } - 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)); + const fn advanced(from: u64, to: u64) -> Self { + Self::Advanced { + from, + to, + has_read_l1: false, } - 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)); + const fn with_l1_read(self) -> Self { + match self { + Self::Parent { anchor, .. } => Self::Parent { + anchor, + has_read_l1: true, + }, + Self::Advanced { from, to, .. } => Self::Advanced { + from, + to, + has_read_l1: true, + }, + Self::Uninitialized => Self::Uninitialized, } - 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 apply(self, operation: L1AnchorOperation) -> Result { + let invalid = || L1AnchorError { + operation, + phase: self, + }; - fn reservoir(&self) -> u64 { - self.inner.reservoir() + match operation { + L1AnchorOperation::Initialize { anchor: new } => match self { + Self::Uninitialized => Ok(Self::parent(new)), + Self::Parent { anchor: prev, .. } if prev == new => Ok(self), + Self::Advanced { to, .. } if to == new => Ok(self), + _ => Err(invalid()), + }, + L1AnchorOperation::Read { anchor: new } => match self { + Self::Uninitialized => Ok(Self::parent(new).with_l1_read()), + Self::Parent { anchor: prev, .. } if prev == new => Ok(self.with_l1_read()), + Self::Advanced { to, .. } if to == new => Ok(self.with_l1_read()), + _ => Err(invalid()), + }, + L1AnchorOperation::Advance { from, to } => { + if from.checked_add(1) != Some(to) { + return Err(invalid()); + } + match self { + Self::Uninitialized => Ok(Self::advanced(from, to)), + Self::Parent { + anchor, + has_read_l1: false, + } if anchor == from => Ok(Self::advanced(from, to)), + _ => Err(invalid()), + } + } + } } +} - fn spec(&self) -> TempoHardfork { - self.inner.spec() - } +/// The execution-local Tempo anchor used by the database adapter. +#[derive(Clone, Default)] +pub struct L1AnchorController { + state: Rc>, +} - fn storage_actions(&self) -> StorageActions { - self.inner.storage_actions() +impl fmt::Debug for L1AnchorController { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnchorController") + .field("phase", &self.phase()) + .finish() } +} - fn amsterdam_eip8037_enabled(&self) -> bool { - self.inner.amsterdam_eip8037_enabled() +impl L1AnchorController { + fn apply(&self, operation: L1AnchorOperation) -> Result { + let mut phase = self.state.borrow_mut(); + let next = phase.apply(operation)?; + *phase = next; + Ok(next) } - fn is_static(&self) -> bool { - self.inner.is_static() + /// Returns the current phase. + pub fn phase(&self) -> L1AnchorPhase { + *self.state.borrow() } - fn checkpoint(&mut self) -> JournalCheckpoint { - self.inner.checkpoint() + /// Restores a previous controller snapshot. + pub fn restore(&self, snapshot: L1AnchorPhase) { + *self.state.borrow_mut() = snapshot; } - fn checkpoint_commit(&mut self, checkpoint: JournalCheckpoint) { - self.inner.checkpoint_commit(checkpoint) + /// Returns the anchor used by reads in the current phase, if initialized. + pub fn current(&self) -> Option { + self.phase().current() } - fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint) { - self.inner.checkpoint_revert(checkpoint) + /// Initializes the controller from the selected Zone state. + pub fn initialize(&self, anchor: u64) -> Result { + let phase = self.apply(L1AnchorOperation::Initialize { anchor })?; + Ok(phase.current().expect("initialization produces an anchor")) } - fn set_tip1060_storage_credits(&mut self, enabled: bool) { - self.inner.set_tip1060_storage_credits(enabled) + /// Records a successful external Tempo state read at `anchor`. + pub fn observe_read(&self, anchor: u64) -> Result<(), L1AnchorError> { + self.apply(L1AnchorOperation::Read { anchor })?; + Ok(()) } - fn set_tip1060_storage_credit_minting(&mut self, enabled: bool) { - self.inner.set_tip1060_storage_credit_minting(enabled) + /// Advances the execution-local anchor after `finalizeTempo` validates the next header. + pub fn begin_advance(&self, from: u64, to: u64) -> Result<(), L1AnchorError> { + self.apply(L1AnchorOperation::Advance { from, to })?; + Ok(()) } } -// 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) +/// Returns whether a slot is mirrored from Tempo L1. +pub fn is_mirrored_slot(address: Address, key: U256) -> bool { + address == tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS + || is_tip20_policy_id_slot(address, key) } -fn is_tip20_policy_id_slot(address: Address, key: U256) -> bool { +/// Returns whether this is the packed TIP-20 transfer-policy slot. +pub 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 { - if is_tip20_policy_id_slot(address, key) { - TIP20Error::invalid_transfer_policy_id().into() - } else { - TIP403RegistryError::unauthorized().into() - } -} - -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 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 { +/// Replaces the L1-owned transfer-policy field while preserving Zone-local packed fields. +pub 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; @@ -314,236 +226,56 @@ fn merge_transfer_policy_id(local_slot: U256, l1_slot: U256) -> U256 { #[cfg(test)] mod tests { use super::*; - 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 TestContext, - 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 mut provider = - ZonePrecompileStorageProvider::try_new(inner, l1).expect("anchor read succeeds"); - f(&mut provider) - } #[test] - fn provider_uses_composed_evm_hardfork() { - let mut ctx = test_context(); - ctx.cfg.spec = TempoHardfork::T8; - let provider = ZonePrecompileStorageProvider::new_at_block( - test_storage_provider(&mut ctx, u64::MAX, false), - MockL1Reader::default(), - 77, - ); - - assert_eq!(provider.spec(), TempoHardfork::T8); + fn controller_rejects_advance_after_parent_read() { + let controller = L1AnchorController::default(); + controller.observe_read(10).unwrap(); + assert!(controller.begin_advance(10, 11).is_err()); } #[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 = match ZonePrecompileStorageProvider::try_new(inner, MockL1Reader::default()) { - Ok(_) => panic!("oversized anchor must be rejected"), - Err(err) => err, - }; - assert!( - matches!(&err.error, TempoPrecompileError::Fatal(msg) if msg.contains("does not fit in u64") && msg.contains(&oversized.to_string())) + fn controller_accepts_reads_at_advanced_anchor() { + let controller = L1AnchorController::default(); + controller.begin_advance(10, 11).unwrap(); + controller.observe_read(11).unwrap(); + assert_eq!( + controller.phase(), + L1AnchorPhase::Advanced { + from: 10, + to: 11, + has_read_l1: true + } ); - 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] - 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 crate::is_zone_rpc_error(&msg) - && msg.contains(&TIP403_REGISTRY_ADDRESS.to_string()) - && msg.contains("block=123") - )); - }, - ); + fn controller_rejects_reads_at_wrong_anchor() { + let controller = L1AnchorController::default(); + controller.initialize(10).unwrap(); + assert!(controller.observe_read(11).is_err()); } #[test] - fn sstore_sinc_sdec_reject_l1_slots_with_precompile_reverts() { - 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 { - 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()); - } - } - - 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()) - } - }, - ); + fn controller_rejects_duplicate_advance() { + let controller = L1AnchorController::default(); + controller.begin_advance(10, 11).unwrap(); + assert!(controller.begin_advance(11, 12).is_err()); } #[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, + fn controller_snapshot_restores_phase() { + let controller = L1AnchorController::default(); + controller.initialize(10).unwrap(); + let snapshot = controller.phase(); + controller.begin_advance(10, 11).unwrap(); + controller.restore(snapshot); + assert_eq!( + controller.phase(), + L1AnchorPhase::Parent { + anchor: 10, + has_read_l1: false + } ); - 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); - // 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)) - .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(), 3); - } - - #[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 772c1917e..1ca83bfc3 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -3,9 +3,9 @@ //! Replaces the Solidity TempoState predeploy at `0x1c00...0000` while //! preserving the zone-facing checkpoint and Tempo storage read ABI. -use alloc::{format, vec::Vec}; +use alloc::{format, string::ToString, vec::Vec}; -use crate::storage::L1StorageReader; +use crate::storage::{L1AnchorController, L1StorageReader}; use alloy_consensus::BlockHeader; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_rlp::Decodable as _; @@ -32,6 +32,9 @@ pub struct TempoState { tempo_block_number: u64, } +/// Storage slot containing the finalized Tempo block number in Zone state. +pub const TEMPO_BLOCK_NUMBER_SLOT: alloy_primitives::U256 = slots::TEMPO_BLOCK_NUMBER; + impl TempoState { /// Initializes the predeploy account code and checkpoint from the genesis Tempo header. pub fn initialize(&mut self, header_rlp: &[u8]) -> tempo_precompiles::Result<()> { @@ -79,6 +82,7 @@ impl TempoState { fn apply_checkpoint( &mut self, + controller: &L1AnchorController, sender: Address, call: TempoStateAbi::finalizeTempoCall, ) -> PrecompileResult { @@ -114,6 +118,12 @@ impl TempoState { return self.revert_error(TempoStateAbi::InvalidBlockNumber {}); } + if let Err(err) = controller.begin_advance(prev_block_number, header.number()) { + return self + .storage + .error_result(TempoPrecompileError::Fatal(err.to_string())); + } + let tempo_block_hash = match self.write_checkpoint(&call.header, header.number()) { Ok(hash) => hash, Err(err) => return self.storage.error_result(err), @@ -132,6 +142,7 @@ impl TempoState { fn read_tempo_storage_slot( &mut self, provider: &P, + controller: &L1AnchorController, sender: Address, call: TempoStateAbi::readTempoStorageSlotCall, ) -> PrecompileResult { @@ -144,6 +155,11 @@ impl TempoState { Ok(number) => number, Err(err) => return self.storage.error_result(err), }; + if let Err(err) = controller.observe_read(block_number) { + return self + .storage + .error_result(TempoPrecompileError::Fatal(err.to_string())); + } let value = provider.read_l1_storage(call.account, call.slot, block_number)?; Ok(self.storage.success_output( TempoStateAbi::readTempoStorageSlotCall::abi_encode_returns(&value).into(), @@ -153,6 +169,7 @@ impl TempoState { fn read_tempo_storage_slots( &mut self, provider: &P, + controller: &L1AnchorController, sender: Address, call: TempoStateAbi::readTempoStorageSlotsCall, ) -> PrecompileResult { @@ -165,6 +182,11 @@ impl TempoState { Ok(number) => number, Err(err) => return self.storage.error_result(err), }; + if let Err(err) = controller.observe_read(block_number) { + return self + .storage + .error_result(TempoPrecompileError::Fatal(err.to_string())); + } let mut values = Vec::with_capacity(call.slots.len()); for slot in call.slots { values.push(provider.read_l1_storage(call.account, slot, block_number)?); @@ -178,6 +200,7 @@ impl TempoState { pub(crate) fn call_with_provider( &mut self, provider: &P, + controller: &L1AnchorController, calldata: &[u8], msg_sender: Address, ) -> PrecompileResult { @@ -191,12 +214,12 @@ impl TempoState { TempoStateAbi::TempoStateCalls { tempoBlockHash(call) => view(call, |_| self.tempo_block_hash.read()), tempoBlockNumber(call) => view(call, |_| self.tempo_block_number.read()), - finalizeTempo(call) => self.apply_checkpoint(msg_sender, call), + finalizeTempo(call) => self.apply_checkpoint(controller, msg_sender, call), readTempoStorageSlot(call) => { - self.read_tempo_storage_slot(provider, msg_sender, call) + self.read_tempo_storage_slot(provider, controller, msg_sender, call) }, readTempoStorageSlots(call) => { - self.read_tempo_storage_slots(provider, msg_sender, call) + self.read_tempo_storage_slots(provider, controller, msg_sender, call) }, } }, @@ -208,8 +231,11 @@ impl TempoState { mod tests { use super::*; - use crate::test_utils::{ - MockL1Reader, TestContext, call_precompile, test_context, test_storage_provider, + use crate::{ + storage::L1AnchorPhase, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_storage_provider, + }, }; use alloy_evm::precompiles::DynPrecompile; use alloy_primitives::{address, b256}; @@ -317,6 +343,100 @@ mod tests { Ok(()) } + #[test] + fn explicit_read_before_finalize_blocks_advancement() -> eyre::Result<()> { + let genesis = child_header(B256::repeat_byte(0xaa), 10); + let genesis_rlp = encode_header(&genesis); + let genesis_hash = keccak256(&genesis_rlp); + let mut ctx = test_context(); + initialize(&mut ctx, &genesis_rlp)?; + let controller = L1AnchorController::default(); + let precompile = TempoState::create( + MockL1Reader::returning(B256::repeat_byte(0x11)), + controller.clone(), + &ctx.cfg.clone(), + ); + + let read = TempoStateAbi::readTempoStorageSlotCall { + account: Address::repeat_byte(0x44), + slot: B256::ZERO, + }; + call( + &mut ctx, + &precompile, + ZONE_INBOX_ADDRESS, + read.abi_encode().into(), + true, + )?; + assert_eq!( + controller.phase(), + L1AnchorPhase::Parent { + anchor: 10, + has_read_l1: true + } + ); + + let child = encode_header(&child_header(genesis_hash, 11)); + assert!( + call( + &mut ctx, + &precompile, + ZONE_INBOX_ADDRESS, + finalize_calldata(child), + false, + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn explicit_read_after_finalize_uses_advanced_anchor() -> eyre::Result<()> { + let genesis = child_header(B256::repeat_byte(0xaa), 10); + let genesis_rlp = encode_header(&genesis); + let genesis_hash = keccak256(&genesis_rlp); + let mut ctx = test_context(); + initialize(&mut ctx, &genesis_rlp)?; + let controller = L1AnchorController::default(); + let reader = MockL1Reader::returning(B256::repeat_byte(0x11)); + let precompile = TempoState::create(reader.clone(), controller.clone(), &ctx.cfg.clone()); + + let child = encode_header(&child_header(genesis_hash, 11)); + call( + &mut ctx, + &precompile, + ZONE_INBOX_ADDRESS, + finalize_calldata(child), + false, + )?; + let read = TempoStateAbi::readTempoStorageSlotCall { + account: Address::repeat_byte(0x44), + slot: B256::ZERO, + }; + call( + &mut ctx, + &precompile, + ZONE_INBOX_ADDRESS, + read.abi_encode().into(), + true, + )?; + assert_eq!( + controller.phase(), + L1AnchorPhase::Advanced { + from: 10, + to: 11, + has_read_l1: true, + } + ); + assert!( + reader + .storage_requests() + .iter() + .all(|request| request.2 == 11) + ); + Ok(()) + } + #[test] fn initialize_sets_checkpoint() -> eyre::Result<()> { let header = child_header(B256::repeat_byte(0xaa), 42); @@ -324,7 +444,11 @@ mod tests { let mut ctx = test_context(); initialize(&mut ctx, &header_rlp)?; - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); assert_checkpoint(&mut ctx, &precompile, keccak256(&header_rlp), 42)?; Ok(()) @@ -341,7 +465,11 @@ 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::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, @@ -365,7 +493,11 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, &precompile, @@ -386,7 +518,11 @@ mod tests { let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let calldata = TempoStateAbi::tempoBlockHashCall {}.abi_encode(); let output = call_precompile( &mut ctx, @@ -417,7 +553,11 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, &precompile, @@ -440,7 +580,11 @@ mod tests { let mut ctx = test_context(); initialize(&mut ctx, &genesis_rlp)?; - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, &precompile, @@ -466,7 +610,11 @@ 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::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, &precompile, @@ -490,7 +638,11 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(B256::ZERO, 1)); - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, &precompile, @@ -514,7 +666,11 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let child_rlp = encode_header(&child_header(genesis_hash, 2)); - let precompile = TempoState::create(MockL1Reader::default(), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::default(), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let output = call( &mut ctx, &precompile, @@ -536,7 +692,11 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let expected = b256!("0xabababababababababababababababababababababababababababababababab"); - let precompile = TempoState::create(MockL1Reader::returning(expected), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::returning(expected), + L1AnchorController::default(), + &ctx.cfg.clone(), + ); let calldata: Bytes = TempoStateAbi::readTempoStorageSlotCall { account: address!("0x0000000000000000000000000000000000009999"), slot: B256::ZERO, @@ -569,7 +729,11 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let expected = b256!("0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"); - let precompile = TempoState::create(MockL1Reader::returning(expected), &ctx.cfg.clone()); + let precompile = TempoState::create( + MockL1Reader::returning(expected), + L1AnchorController::default(), + &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 deleted file mode 100644 index ecb545cee..000000000 --- a/crates/precompiles/src/test_utils.rs +++ /dev/null @@ -1,401 +0,0 @@ -//! Shared test utilities for precompile tests. - -use std::{ - cell::RefCell, - collections::HashMap, - rc::Rc, - sync::{Arc, Mutex}, -}; - -use alloy_evm::{ - EvmInternals, - precompiles::{DynPrecompile, Precompile as _, PrecompileInput}, -}; -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, PrecompileResult}, -}; -use tempo_chainspec::hardfork::TempoHardfork; -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}; - -/// EVM context used by precompile tests. -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() -> 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 TestContext, - 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, - ) -} - -/// 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, - 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), - }) -} - -// 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)] -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, - ..Default::default() - } - } - - pub(crate) fn failing_storage() -> Self { - Self { - fail_storage: 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 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 { - 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(crate::zone_rpc_error("RPC unavailable")); - } - if let Some(value) = self - .slots - .lock() - .unwrap() - .get(&(account, slot, block_number)) - .copied() - { - 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())) - } - } -} - -/// Assert that the Chaum-Pedersen proof inside a [`DecryptedDeposit`] is valid. -pub(crate) fn assert_cp_proof_valid( - dec: &DecryptedDeposit, - ephemeral_pub: &AffinePoint, - sequencer_pub: &AffinePoint, -) { - let s = >::reduce_bytes(&dec.proof.cp_proof_s.0.into()); - let c = >::reduce_bytes(&dec.proof.cp_proof_c.0.into()); - let shared_pt = - recover_point(&dec.proof.shared_secret.0, dec.proof.shared_secret_y_parity).unwrap(); - - let r1 = ProjectivePoint::GENERATOR * s - ProjectivePoint::from(*sequencer_pub) * c; - let r2 = ProjectivePoint::from(*ephemeral_pub) * s - ProjectivePoint::from(shared_pt) * c; - - let c_prime = challenge_hash( - ephemeral_pub, - sequencer_pub, - &shared_pt, - &r1.to_affine(), - &r2.to_affine(), - ); - assert_eq!(c, c_prime, "Chaum-Pedersen proof must verify"); -} - -/// Pre-computed encrypted deposit for testing. -/// All fields are deterministic (derived from fixed seed keys). -pub(crate) struct EncryptedDepositFixture { - pub seq_key: k256::SecretKey, - pub seq_pub: AffinePoint, - pub eph_pub: AffinePoint, - pub eph_pub_x: B256, - pub eph_pub_y_parity: u8, - pub portal: Address, - pub key_index: U256, - pub to: Address, - pub memo: B256, - pub ciphertext: Vec, - pub nonce: [u8; 12], - pub tag: [u8; 16], -} - -impl EncryptedDepositFixture { - /// Create a fixture with deterministic keys for reproducible tests. - pub(crate) fn new() -> Self { - use sha2::{Digest, Sha256}; - - // Deterministic sequencer key - let seq_bytes: [u8; 32] = Sha256::digest(b"test-sequencer-key").into(); - let seq_key = k256::SecretKey::from_slice(&seq_bytes).expect("valid key"); - let seq_scalar: Scalar = *seq_key.to_nonzero_scalar(); - let seq_pub = AffinePoint::from(ProjectivePoint::GENERATOR * seq_scalar); - - // Deterministic ephemeral key - let eph_bytes: [u8; 32] = Sha256::digest(b"test-ephemeral-key").into(); - let eph_key = k256::SecretKey::from_slice(&eph_bytes).expect("valid key"); - let eph_scalar: Scalar = *eph_key.to_nonzero_scalar(); - let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * eph_scalar); - let (eph_pub_x, eph_pub_y_parity) = compressed_x_and_parity(&eph_pub); - - // ECDH (depositor side) - let shared_proj = ProjectivePoint::from(seq_pub) * eph_scalar; - let shared_affine = AffinePoint::from(shared_proj); - let ss_enc = shared_affine.to_encoded_point(true); - let shared_secret_x: [u8; 32] = ss_enc.x().unwrap().as_slice().try_into().unwrap(); - - let portal = Address::repeat_byte(0xAA); - let key_index = U256::from(42u64); - - // HKDF key derivation - let info = crate::ecies::hkdf_info(&portal, &key_index, &eph_pub_x); - let aes_key = crate::ecies::hkdf_sha256(&shared_secret_x, b"ecies-aes-key", &info); - - // Build and encrypt plaintext - let to = Address::repeat_byte(0xBB); - let memo = B256::repeat_byte(0xCC); - let plaintext = build_plaintext(&to, &memo); - let (ciphertext, nonce, tag) = encrypt_plaintext(&aes_key, &plaintext); - - Self { - seq_key, - seq_pub, - eph_pub, - eph_pub_x, - eph_pub_y_parity, - portal, - key_index, - to, - memo, - ciphertext, - nonce, - tag, - } - } - - /// Decrypt using the fixture's sequencer key. - pub(crate) fn decrypt(&self) -> Option { - crate::ecies::decrypt_deposit( - &self.seq_key, - &self.eph_pub_x, - self.eph_pub_y_parity, - &self.ciphertext, - &self.nonce, - &self.tag, - self.portal, - self.key_index, - ) - } -} diff --git a/crates/precompiles/src/test_utils/l1_reader.rs b/crates/precompiles/src/test_utils/l1_reader.rs new file mode 100644 index 000000000..42360e68f --- /dev/null +++ b/crates/precompiles/src/test_utils/l1_reader.rs @@ -0,0 +1,206 @@ +//! Shared L1 reader fixtures for precompile and EVM integration tests. +use crate::{L1StorageReader, SequencerExt}; +use alloy_primitives::{Address, B256, U256}; +use revm::precompile::PrecompileError; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; +use tempo_precompiles::{ + storage::{Handler, PrecompileStorageProvider, StorageCtx, hashmap::HashMapStorageProvider}, + tip403_registry::{CompoundPolicyData, PolicyData, TIP403Registry}, +}; + +pub type L1Slot = (Address, B256, u64); +type Shared = Arc>; + +/// In-memory exact-block L1 reader shared by EVM and precompile tests. +#[derive(Clone)] +pub 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 fn insert(&self, address: Address, slot: U256, anchor: u64, value: U256) { + self.set_u256(address, slot, anchor, value); + } + + pub fn allow_all() -> Self { + Self::with_policy_id(1) + } + + pub fn failing() -> Self { + Self { + fail_storage: true, + ..Self::allow_all() + } + } + + pub fn with_policy_id(policy_id: u64) -> Self { + Self { + policy_id, + ..Default::default() + } + } + + pub fn returning(value: B256) -> Self { + Self { + fallback: value, + ..Default::default() + } + } + + pub fn failing_storage() -> Self { + Self { + fail_storage: true, + ..Default::default() + } + } + + pub 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 fn storage_requests(&self) -> Vec { + self.storage_requests.lock().unwrap().clone() + } + + pub fn seed_transfer_policy_id(&self, token: Address, block_number: u64) { + let packed = U256::from(self.policy_id) + << (tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8); + self.set_u256( + token, + tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID, + block_number, + packed, + ); + } + + pub 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 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 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 SequencerExt for MockL1Reader { + fn latest_sequencer(&self) -> Option

{ + None + } +} + +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(crate::zone_rpc_error("RPC unavailable")); + } + if let Some(value) = self + .slots + .lock() + .unwrap() + .get(&(account, slot, block_number)) + .copied() + { + 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/test_utils/local.rs b/crates/precompiles/src/test_utils/local.rs new file mode 100644 index 000000000..f140bd181 --- /dev/null +++ b/crates/precompiles/src/test_utils/local.rs @@ -0,0 +1,197 @@ +use alloy_evm::{ + EvmInternals, + precompiles::{DynPrecompile, Precompile as _, PrecompileInput}, +}; +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::PrecompileResult, +}; +use std::{cell::RefCell, rc::Rc}; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + storage::{actions::StorageActions, evm::EvmPrecompileStorageProvider}, + storage_credits::NonCreditableSlots, +}; + +use crate::{ + chaum_pedersen::{challenge_hash, recover_point}, + ecies::DecryptedDeposit, + execution::ProtocolPrecompileEnv, +}; + +pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; + +/// EVM context used by local precompile unit tests. +pub(crate) type TestContext = Context, CacheDB>; + +/// Create an empty test EVM context at the default Tempo hardfork. +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 TestContext, + 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, + ) +} + +/// Create the ordinary protocol precompile environment for a local unit test. +pub(crate) fn test_protocol_env(ctx: &TestContext) -> ProtocolPrecompileEnv { + ProtocolPrecompileEnv::new( + &ctx.cfg, + 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, + 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), + }) +} + +/// Assert that the Chaum-Pedersen proof inside a [`DecryptedDeposit`] is valid. +pub(crate) fn assert_cp_proof_valid( + dec: &DecryptedDeposit, + ephemeral_pub: &AffinePoint, + sequencer_pub: &AffinePoint, +) { + let s = >::reduce_bytes(&dec.proof.cp_proof_s.0.into()); + let c = >::reduce_bytes(&dec.proof.cp_proof_c.0.into()); + let shared_pt = + recover_point(&dec.proof.shared_secret.0, dec.proof.shared_secret_y_parity).unwrap(); + + let r1 = ProjectivePoint::GENERATOR * s - ProjectivePoint::from(*sequencer_pub) * c; + let r2 = ProjectivePoint::from(*ephemeral_pub) * s - ProjectivePoint::from(shared_pt) * c; + + let c_prime = challenge_hash( + ephemeral_pub, + sequencer_pub, + &shared_pt, + &r1.to_affine(), + &r2.to_affine(), + ); + assert_eq!(c, c_prime, "Chaum-Pedersen proof must verify"); +} + +/// Pre-computed encrypted deposit for testing. +/// All fields are deterministic (derived from fixed seed keys). +pub(crate) struct EncryptedDepositFixture { + pub seq_key: k256::SecretKey, + pub seq_pub: AffinePoint, + pub eph_pub: AffinePoint, + pub eph_pub_x: B256, + pub eph_pub_y_parity: u8, + pub portal: Address, + pub key_index: U256, + pub to: Address, + pub memo: B256, + pub ciphertext: Vec, + pub nonce: [u8; 12], + pub tag: [u8; 16], +} + +impl EncryptedDepositFixture { + /// Create a fixture with deterministic keys for reproducible tests. + pub(crate) fn new() -> Self { + use sha2::{Digest, Sha256}; + + // Deterministic sequencer key + let seq_bytes: [u8; 32] = Sha256::digest(b"test-sequencer-key").into(); + let seq_key = k256::SecretKey::from_slice(&seq_bytes).expect("valid key"); + let seq_scalar: Scalar = *seq_key.to_nonzero_scalar(); + let seq_pub = AffinePoint::from(ProjectivePoint::GENERATOR * seq_scalar); + + // Deterministic ephemeral key + let eph_bytes: [u8; 32] = Sha256::digest(b"test-ephemeral-key").into(); + let eph_key = k256::SecretKey::from_slice(&eph_bytes).expect("valid key"); + let eph_scalar: Scalar = *eph_key.to_nonzero_scalar(); + let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * eph_scalar); + let (eph_pub_x, eph_pub_y_parity) = compressed_x_and_parity(&eph_pub); + + // ECDH (depositor side) + let shared_proj = ProjectivePoint::from(seq_pub) * eph_scalar; + let shared_affine = AffinePoint::from(shared_proj); + let ss_enc = shared_affine.to_encoded_point(true); + let shared_secret_x: [u8; 32] = ss_enc.x().unwrap().as_slice().try_into().unwrap(); + + let portal = Address::repeat_byte(0xAA); + let key_index = U256::from(42u64); + + // HKDF key derivation + let info = crate::ecies::hkdf_info(&portal, &key_index, &eph_pub_x); + let aes_key = crate::ecies::hkdf_sha256(&shared_secret_x, b"ecies-aes-key", &info); + + // Build and encrypt plaintext + let to = Address::repeat_byte(0xBB); + let memo = B256::repeat_byte(0xCC); + let plaintext = build_plaintext(&to, &memo); + let (ciphertext, nonce, tag) = encrypt_plaintext(&aes_key, &plaintext); + + Self { + seq_key, + seq_pub, + eph_pub, + eph_pub_x, + eph_pub_y_parity, + portal, + key_index, + to, + memo, + ciphertext, + nonce, + tag, + } + } + + /// Decrypt using the fixture's sequencer key. + pub(crate) fn decrypt(&self) -> Option { + crate::ecies::decrypt_deposit( + &self.seq_key, + &self.eph_pub_x, + self.eph_pub_y_parity, + &self.ciphertext, + &self.nonce, + &self.tag, + self.portal, + self.key_index, + ) + } +} diff --git a/crates/precompiles/src/test_utils/mod.rs b/crates/precompiles/src/test_utils/mod.rs new file mode 100644 index 000000000..a6afa4007 --- /dev/null +++ b/crates/precompiles/src/test_utils/mod.rs @@ -0,0 +1,9 @@ +//! Shared test utilities for precompile and EVM integration tests. + +pub mod l1_reader; +pub use l1_reader::MockL1Reader; + +#[cfg(test)] +mod local; +#[cfg(test)] +pub(crate) use local::*; diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 091ea6e5a..599e20f64 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -63,8 +63,7 @@ mod tests { create_tip403_precompile, tempo_state::slots::TEMPO_BLOCK_NUMBER, test_utils::{ - MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, - test_storage_provider, + TestContext, call_precompile, test_context, test_protocol_env, test_storage_provider, }, }; @@ -79,7 +78,7 @@ mod tests { } impl RegistryHarness { - fn new(l1: MockL1Reader) -> Self { + fn new() -> Self { let mut ctx = test_context(); ctx.cfg.spec = TempoHardfork::T8; test_storage_provider(&mut ctx, u64::MAX, false) @@ -89,7 +88,7 @@ mod tests { U256::from(ANCHOR), ) .unwrap(); - let env = test_l1_env(&ctx, l1); + let env = test_protocol_env(&ctx); Self { ctx, precompile: create_tip403_precompile(&env), @@ -125,102 +124,9 @@ mod tests { } } - 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 mut harness = RegistryHarness::new(); let mutation = ITIP403Registry::createPolicyCall { admin: CALLER, policyType: ITIP403Registry::PolicyType::BLACKLIST, @@ -269,9 +175,8 @@ mod tests { } #[test] - fn delegate_calls_revert_before_anchor_or_l1_access() -> eyre::Result<()> { - let reader = MockL1Reader::default(); - let mut harness = RegistryHarness::new(reader.clone()); + fn delegate_calls_revert_before_execution() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(); let call = ITIP403Registry::policyIdCounterCall {}.abi_encode(); let output = harness.call_as( &call, @@ -286,47 +191,6 @@ mod tests { output.bytes, Bytes::from(DelegateCallNotAllowed {}.abi_encode()) ); - assert!(reader.storage_requests().is_empty()); Ok(()) } - - #[test] - 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 { - policyId: 5, - user: ALICE, - } - .abi_encode(); - let output = harness.call(&call, u64::MAX)?; - assert!(output.is_success()); - - let requests = reader.storage_requests(); - assert!(!requests.is_empty()); - assert!(requests.iter().all(|(_, _, block)| *block == ANCHOR)); - Ok(()) - } - - #[test] - fn anchored_storage_failures_fail_closed() { - let call = ITIP403Registry::isAuthorizedCall { - policyId: 5, - user: ALICE, - } - .abi_encode(); - - 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!( - storage_reader - .storage_requests() - .iter() - .all(|(_, _, block)| *block == ANCHOR) - ); - } } diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 2cc740425..78b58536e 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -4,9 +4,9 @@ //! 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. //! -//! 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. +//! Accepted calldata and callers are forwarded unchanged to Tempo. Ordinary token state remains +//! Zone-local while the EVM context's database adapter exposes selected policy values from the +//! finalized Tempo L1 state. use alloc::sync::Arc; @@ -179,8 +179,7 @@ mod tests { use tempo_zone_contracts::Unauthorized; use crate::test_utils::{ - MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, - test_storage_provider, + TestContext, call_precompile, test_context, test_protocol_env, test_storage_provider, }; #[derive(Clone, Copy)] @@ -205,11 +204,7 @@ mod tests { } impl PrecompileHarness { - fn new(l1_reader: MockL1Reader) -> eyre::Result { - Self::new_with_l1(l1_reader) - } - - fn new_with_l1(l1_reader: MockL1Reader) -> eyre::Result { + fn new() -> eyre::Result { let token = PATH_USD_ADDRESS; let admin = address!("0x00000000000000000000000000000000000000a1"); let alice = address!("0x00000000000000000000000000000000000000a2"); @@ -266,9 +261,7 @@ mod tests { })?; } - l1_reader.seed_transfer_policy_id(token, 7); - - let env = test_l1_env(&ctx, l1_reader); + let env = test_protocol_env(&ctx); let precompile = crate::create_tip20_precompile( token, &env, @@ -326,7 +319,7 @@ mod tests { #[test] fn balance_of_enforces_account_or_sequencer_access() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let calldata: Bytes = ITIP20::balanceOfCall { account: harness.alice, } @@ -354,7 +347,7 @@ mod tests { #[test] fn allowance_enforces_owner_spender_or_sequencer_access() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let calldata: Bytes = ITIP20::allowanceCall { owner: harness.alice, spender: harness.spender, @@ -389,7 +382,7 @@ mod tests { #[test] fn wrapper_still_enforces_privacy_and_fixed_gas() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let private_balance = harness.call( harness.bob, @@ -431,7 +424,7 @@ mod tests { let caller = address!("0x00000000000000000000000000000000000000a2"); let to = address!("0x00000000000000000000000000000000000000a3"); let mut ctx = test_context(); - let env = test_l1_env(&ctx, MockL1Reader::failing()); + let env = test_protocol_env(&ctx); let precompile = crate::create_tip20_precompile(token, &env, Arc::new(MockSequencer { address: None })); let calldata: Bytes = ITIP20::transferCall { @@ -463,7 +456,7 @@ mod tests { #[test] fn malformed_calldata_uses_upstream_dispatch() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let balance_of = harness.call( harness.alice, @@ -489,7 +482,7 @@ mod tests { #[test] fn bridge_auth_rejects_crossed_system_calls_and_keeps_allowed_paths() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let inbox_mint = harness.call( ZONE_INBOX_ADDRESS, @@ -556,7 +549,7 @@ mod tests { #[test] fn fixed_gas_selectors_charge_exactly_one_hundred_thousand_gas() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let approve = harness.call( harness.alice, @@ -665,7 +658,7 @@ mod tests { #[test] fn fixed_gas_selectors_fail_out_of_gas_below_threshold() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; for calldata in [ ITIP20::transferCall { @@ -715,7 +708,7 @@ mod tests { #[test] fn fixed_gas_keeps_allowance_and_balance_state_changes_intact() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let approve = harness.call( harness.alice, @@ -751,141 +744,9 @@ mod tests { Ok(()) } - #[test] - 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); - - 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 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])?; - - 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()) - ); - - Ok(()) - } - - #[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() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::failing())?; - - let calldata: Bytes = ITIP20::transferCall { - to: harness.bob, - amount: U256::from(100u64), - } - .abi_encode() - .into(); - - let result = harness.call(harness.alice, calldata, 100_000, false); - assert!( - result.is_err(), - "transfer must fail when policy resolution errors" - ); - - Ok(()) - } - - #[test] - fn mint_fails_on_l1_storage_error() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::failing())?; - - let calldata: Bytes = ITIP20::mintCall { - to: harness.alice, - amount: U256::from(100u64), - } - .abi_encode() - .into(); - - let result = harness.call(ZONE_INBOX_ADDRESS, calldata, 100_000, false); - assert!( - 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() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new(MockL1Reader::allow_all())?; + let mut harness = PrecompileHarness::new()?; let calldata: Bytes = IRolesAuth::hasRoleCall { account: harness.alice, role: *ISSUER_ROLE, From e3e59c140ab27391c00a0457bcffa0b25d339d1d Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 12:02:08 +0200 Subject: [PATCH 16/22] chore: simplify execution --- Cargo.lock | 1 - crates/evm/src/database.rs | 24 +-- crates/evm/src/lib.rs | 22 ++- crates/evm/src/zone_evm/contract_creation.rs | 6 +- crates/evm/src/zone_evm/mod.rs | 93 +++++----- crates/payload/Cargo.toml | 1 - crates/payload/src/builder.rs | 3 - crates/precompiles/src/aes_gcm/mod.rs | 4 +- crates/precompiles/src/execution.rs | 173 ++++++------------- crates/precompiles/src/lib.rs | 58 +++---- crates/precompiles/src/storage.rs | 83 ++------- crates/precompiles/src/tempo_state.rs | 68 ++++---- crates/precompiles/src/test_utils/local.rs | 8 +- crates/precompiles/src/tip403_proxy/mod.rs | 12 +- crates/precompiles/src/ztip20/mod.rs | 12 +- 15 files changed, 218 insertions(+), 350 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e707f3cb..5bd2c5726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13625,7 +13625,6 @@ dependencies = [ "tempo-zone-contracts", "tracing", "zone-chainspec", - "zone-evm", "zone-l1", "zone-precompiles", ] diff --git a/crates/evm/src/database.rs b/crates/evm/src/database.rs index 035da07fd..8700358ea 100644 --- a/crates/evm/src/database.rs +++ b/crates/evm/src/database.rs @@ -86,11 +86,11 @@ impl fmt::Debug for AnchoredZoneDb { impl AnchoredZoneDb { /// Creates an adapter around the caller-provided database. - pub fn new(inner: DB, l1: L1, controller: L1AnchorController) -> Self { + pub fn new(inner: DB, l1: L1) -> Self { Self { inner, l1, - controller, + controller: L1AnchorController::default(), packed_policy_slots: HashMap::new(), } } @@ -128,9 +128,7 @@ impl AnchoredZoneDb { .map_err(AnchoredZoneDbError::Inner)?; let anchor = u64::try_from(value).map_err(|_| AnchoredZoneDbError::AnchorOverflow(value))?; - self.controller - .initialize(anchor) - .map_err(AnchoredZoneDbError::AnchorTransition) + Ok(anchor) } fn l1_storage( @@ -263,7 +261,7 @@ mod tests { let expected = U256::from(99); let l1 = TestL1::default(); l1.insert(TIP403_REGISTRY_ADDRESS, slot, anchor, expected); - let mut db = AnchoredZoneDb::new(test_db(anchor), l1, L1AnchorController::default()); + let mut db = AnchoredZoneDb::new(test_db(anchor), l1); assert_eq!(db.storage(TIP403_REGISTRY_ADDRESS, slot).unwrap(), expected); assert_eq!(db.controller().current(), Some(anchor)); @@ -273,11 +271,7 @@ mod tests { fn l1_failures_and_read_before_advance_fail_closed() { let anchor = 42; let slot = U256::from(7); - let mut failing = AnchoredZoneDb::new( - test_db(anchor), - TestL1::failing_storage(), - L1AnchorController::default(), - ); + let mut failing = AnchoredZoneDb::new(test_db(anchor), TestL1::failing_storage()); assert!(matches!( failing.storage(TIP403_REGISTRY_ADDRESS, slot), Err(AnchoredZoneDbError::L1Read { anchor: 42, .. }) @@ -285,8 +279,8 @@ mod tests { let reader = TestL1::default(); reader.insert(TIP403_REGISTRY_ADDRESS, slot, anchor, U256::ONE); - let controller = L1AnchorController::default(); - let mut db = AnchoredZoneDb::new(test_db(anchor), reader.clone(), controller.clone()); + let mut db = AnchoredZoneDb::new(test_db(anchor), reader.clone()); + let controller = db.controller().clone(); assert_eq!( db.storage(TIP403_REGISTRY_ADDRESS, slot).unwrap(), U256::ONE @@ -307,7 +301,7 @@ mod tests { l1.insert(token, slot, anchor, l1_value); let mut inner = test_db(anchor); inner.insert_account_storage(token, slot, local).unwrap(); - let mut db = AnchoredZoneDb::new(inner, l1, L1AnchorController::default()); + let mut db = AnchoredZoneDb::new(inner, l1); let observed = db.storage(token, slot).unwrap(); let mut state = AddressMap::default(); @@ -338,7 +332,7 @@ mod tests { let value = U256::from(5); let mut inner = test_db(1); inner.insert_account_storage(address, slot, value).unwrap(); - let mut db = AnchoredZoneDb::new(inner, TestL1::default(), L1AnchorController::default()); + let mut db = AnchoredZoneDb::new(inner, TestL1::default()); assert_eq!(db.storage(address, slot).unwrap(), value); let mut inner: CacheDB = db.into_inner(); diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 3c9d0b9bf..ff5e58839 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -114,13 +114,10 @@ where db: DB, input: EvmEnv, ) -> Self::Evm { - let controller = L1AnchorController::default(); - let db = AnchoredZoneDb::new(db, self.l1_reader.clone(), controller.clone()); + let db = AnchoredZoneDb::new(db, self.l1_reader.clone()); + let controller = db.controller().clone(); let evm = TempoEvm::new(db, input); - ZoneEvm::new( - self.register_precompiles(evm, controller.clone()), - controller, - ) + ZoneEvm::new(self.register_precompiles(evm, controller)) } fn create_evm_with_inspector>>( @@ -129,13 +126,10 @@ where input: EvmEnv, inspector: I, ) -> Self::Evm { - let controller = L1AnchorController::default(); - let db = AnchoredZoneDb::new(db, self.l1_reader.clone(), controller.clone()); + let db = AnchoredZoneDb::new(db, self.l1_reader.clone()); + let controller = db.controller().clone(); let evm = TempoEvm::new(db, input).with_inspector(inspector); - ZoneEvm::new( - self.register_precompiles(evm, controller.clone()), - controller, - ) + ZoneEvm::new(self.register_precompiles(evm, controller)) } } @@ -174,6 +168,10 @@ impl BlockAssembler for ZoneBlockAssembler { .. } = input; + if let Err(error) = validate_advance_tempo_transactions(&transactions) { + return Err(alloy_evm::block::BlockValidationError::other(error).into()); + } + self.inner.assemble_block( BlockAssemblerInput::::new( evm_env, diff --git a/crates/evm/src/zone_evm/contract_creation.rs b/crates/evm/src/zone_evm/contract_creation.rs index c547e5b04..df318dc85 100644 --- a/crates/evm/src/zone_evm/contract_creation.rs +++ b/crates/evm/src/zone_evm/contract_creation.rs @@ -89,7 +89,6 @@ mod tests { use tempo_evm::{TempoBlockEnv, TempoHaltReason}; use tempo_primitives::transaction::Call; use tempo_revm::{TempoBatchCallEnv, TempoTxEnv}; - use zone_precompiles::L1AnchorController; type TestDb = CacheDB; type TestAdaptedDb = AnchoredZoneDb; @@ -128,9 +127,8 @@ mod tests { db: TestDb, input: EvmEnv, ) -> ZoneEvm { - let controller = L1AnchorController::default(); - let db = AnchoredZoneDb::new(db, TestL1::default(), controller.clone()); - ZoneEvm::new(TempoEvm::new(db, input), controller) + let db = AnchoredZoneDb::new(db, TestL1::default()); + ZoneEvm::new(TempoEvm::new(db, input)) } fn evm_with_contract(addr: Address, code: &[u8]) -> ZoneEvm { diff --git a/crates/evm/src/zone_evm/mod.rs b/crates/evm/src/zone_evm/mod.rs index cbcd99cef..7b774f4c6 100644 --- a/crates/evm/src/zone_evm/mod.rs +++ b/crates/evm/src/zone_evm/mod.rs @@ -18,26 +18,23 @@ use zone_l1::state::L1StateProvider; use zone_precompiles::{L1AnchorController, L1StorageReader}; use zone_primitives::constants::CONTRACT_DEPLOYER_ALLOWLIST; +type TempoResult = ResultAndState; +type AdaptedEvmError = EVMError, TempoInvalidTransaction>; +type ZoneEvmError = EVMError; + /// Zone runtime EVM. /// /// Execution uses an anchored database adapter internally while the public [`Evm::DB`] remains the /// exact database supplied by the caller. pub struct ZoneEvm { inner: TempoEvm, I>, - controller: L1AnchorController, } impl ZoneEvm { /// Creates a new `ZoneEvm` with guarded `CREATE` and `CREATE2` opcodes. - pub(super) fn new( - mut evm: TempoEvm, I>, - controller: L1AnchorController, - ) -> Self { + pub(super) fn new(mut evm: TempoEvm, I>) -> Self { contract_creation::configure_runtime(&mut evm); - Self { - inner: evm, - controller, - } + Self { inner: evm } } /// Provides a reference to the EVM context. @@ -51,8 +48,40 @@ impl ZoneEvm { } /// Returns the execution-local anchor controller. - pub const fn anchor_controller(&self) -> &L1AnchorController { - &self.controller + pub fn anchor_controller(&self) -> &L1AnchorController { + self.inner.ctx().journaled_state.database.controller() + } +} + +impl ZoneEvm +where + DB: Database, + L1: L1StorageReader, + I: Inspector>>, +{ + fn execute_inner( + &mut self, + execute: impl FnOnce( + &mut TempoEvm, I>, + ) -> Result>, + ) -> Result> { + let snapshot = self.anchor_controller().phase(); + let mut result = match execute(&mut self.inner) { + Ok(result) => result, + Err(error) => { + self.anchor_controller().restore(snapshot); + return Err(map_adapter_error(error)); + } + }; + if !result.result.is_success() { + self.anchor_controller().restore(snapshot); + return Ok(result); + } + if let Err(error) = self.inner.db().sanitize_state(&mut result.state) { + self.anchor_controller().restore(snapshot); + return Err(error.into_evm_error()); + } + Ok(result) } } @@ -64,7 +93,7 @@ where { type DB = DB; type Tx = TempoTxEnv; - type Error = EVMError; + type Error = ZoneEvmError; type HaltReason = TempoHaltReason; type Spec = tempo_chainspec::hardfork::TempoHardfork; type BlockEnv = TempoBlockEnv; @@ -88,23 +117,7 @@ where tx: Self::Tx, ) -> Result, Self::Error> { contract_creation::validate_transaction(&tx, CONTRACT_DEPLOYER_ALLOWLIST)?; - let snapshot = self.controller.phase(); - let mut result = match self.inner.transact_raw(tx) { - Ok(result) => result, - Err(err) => { - self.controller.restore(snapshot); - return Err(map_adapter_error(err)); - } - }; - if !result.result.is_success() { - self.controller.restore(snapshot); - return Ok(result); - } - if let Err(err) = self.inner.db().sanitize_state(&mut result.state) { - self.controller.restore(snapshot); - return Err(err.into_evm_error()); - } - Ok(result) + self.execute_inner(|evm| evm.transact_raw(tx)) } fn transact_system_call( @@ -113,23 +126,7 @@ where contract: Address, data: Bytes, ) -> Result, Self::Error> { - let snapshot = self.controller.phase(); - let mut result = match self.inner.transact_system_call(caller, contract, data) { - Ok(result) => result, - Err(err) => { - self.controller.restore(snapshot); - return Err(map_adapter_error(err)); - } - }; - if !result.result.is_success() { - self.controller.restore(snapshot); - return Ok(result); - } - if let Err(err) = self.inner.db().sanitize_state(&mut result.state) { - self.controller.restore(snapshot); - return Err(err.into_evm_error()); - } - Ok(result) + self.execute_inner(|evm| evm.transact_system_call(caller, contract, data)) } fn finish(self) -> (Self::DB, EvmEnv) { @@ -153,8 +150,8 @@ where } fn map_adapter_error( - error: EVMError, TempoInvalidTransaction>, -) -> EVMError { + error: AdaptedEvmError, +) -> ZoneEvmError { match error { EVMError::Transaction(error) => EVMError::Transaction(error), EVMError::Header(error) => EVMError::Header(error), diff --git a/crates/payload/Cargo.toml b/crates/payload/Cargo.toml index 03b7495c0..bab592223 100644 --- a/crates/payload/Cargo.toml +++ b/crates/payload/Cargo.toml @@ -20,7 +20,6 @@ tempo-revm.workspace = true tempo-transaction-pool.workspace = true tempo-zone-contracts = { workspace = true, features = ["std", "serde", "rpc"] } zone-chainspec.workspace = true -zone-evm.workspace = true zone-l1.workspace = true zone-precompiles = { workspace = true, features = ["std"] } diff --git a/crates/payload/src/builder.rs b/crates/payload/src/builder.rs index c5728954c..34dccf3f3 100644 --- a/crates/payload/src/builder.rs +++ b/crates/payload/src/builder.rs @@ -274,9 +274,6 @@ where block_access_list: _, } = builder.finish(&*state_provider, None)?; - zone_evm::validate_advance_tempo_transactions(&block.sealed_block().body().transactions) - .map_err(PayloadBuilderError::other)?; - let requests = chain_spec .is_prague_active_at_timestamp(attributes.timestamp()) .then_some(execution_result.requests.clone()); diff --git a/crates/precompiles/src/aes_gcm/mod.rs b/crates/precompiles/src/aes_gcm/mod.rs index c2840f7a8..d1c51284d 100644 --- a/crates/precompiles/src/aes_gcm/mod.rs +++ b/crates/precompiles/src/aes_gcm/mod.rs @@ -82,7 +82,7 @@ pub fn decrypt_aes_gcm( #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{test_context, test_storage_provider}; + use crate::test_utils::{test_context, test_env, test_storage_provider}; use alloy_primitives::Bytes; use alloy_sol_types::SolCall; use revm::precompile::PrecompileOutput; @@ -116,7 +116,7 @@ mod tests { fn call_precompile(calldata: Bytes) -> PrecompileOutput { let mut ctx = test_context(); - let precompile = AesGcmDecrypt::create(&ctx.cfg.clone()); + let precompile = AesGcmDecrypt::create(&test_env(&ctx)); crate::test_utils::call_precompile( &mut ctx, &precompile, diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index 283b0f678..d174390e1 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -8,9 +8,8 @@ //! //! 1. Protocol 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`]. -//! 4. Apply rules that may inspect anchored state through ordinary EVM storage. -//! 5. Forward the original calldata and caller, applying any configured fixed gas charge. +//! 3. Apply [`CallRules`], which may inspect local or anchored state through ordinary EVM storage. +//! 4. 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. @@ -32,14 +31,14 @@ use tempo_precompiles::{ /// Shared inputs for protocol precompiles whose storage is provided by the EVM context. #[derive(Clone)] -pub(crate) struct ProtocolPrecompileEnv { +pub struct ZonePrecompileEnv { cfg: revm::context::CfgEnv, actions: StorageActions, non_creditable_slots: Rc>, } -impl ProtocolPrecompileEnv { - pub(crate) fn new( +impl ZonePrecompileEnv { + pub fn new( cfg: &revm::context::CfgEnv, actions: StorageActions, non_creditable_slots: Rc>, @@ -60,10 +59,6 @@ impl ProtocolPrecompileEnv { /// 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], @@ -102,26 +97,22 @@ pub(crate) enum CallCheck { /// Selector-, caller-, and call-context-dependent rules evaluated by centralized precompile /// execution before invoking the implementation. /// -/// The local phase runs before rules that may inspect finalized L1 state. Anchored reads in either -/// phase are resolved by the EVM database adapter. Rules may -/// enforce admission policy and duplicate cheap business checks as fail-fast preflight, but the +/// Anchored reads are resolved by the EVM database adapter. 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 { + /// Returns whether this precompile accepts delegate calls. + fn is_delegate_call_allowed(&self) -> bool { + true + } + /// Return the fixed gas charge for this selector, if one applies. fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { None } - /// 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 - } - - /// Apply rules whose answer must come from finalized Tempo L1-backed storage. - fn check_with_l1_backed_state(&self, _call: ZoneCall<'_>) -> CallCheck { + /// Applies Zone-specific admission rules before invoking the upstream implementation. + fn check(&self, _call: ZoneCall<'_>) -> CallCheck { CallCheck::Continue } } @@ -133,83 +124,21 @@ 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_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { - if call.is_direct { - CallCheck::Continue - } else { - CallCheck::Return(Ok(StorageCtx::default() - .revert_output(SolError::abi_encode(&DelegateCallNotAllowed {}).into()))) - } + fn is_delegate_call_allowed(&self) -> bool { + false } } -/// 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( +pub(crate) fn create_precompile( id: &'static str, - cfg: &revm::context::CfgEnv, + env: &ZonePrecompileEnv, 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(); - + let env = env.clone(); DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { 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, - input.reservoir, - )); - } - - 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(), - ); - - if let Some(check_result) = - StorageCtx::enter(&mut storage, || match rules.check_with_local_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) - }) -} - -/// Create a direct-call-only protocol precompile using ordinary EVM storage. -/// -/// The Zone EVM's database adapter supplies anchored policy values below the revm journal, so this -/// helper does not install a per-precompile storage wrapper. -pub(crate) fn create_protocol_precompile( - id: &'static str, - env: ProtocolPrecompileEnv, - 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; - let actions = env.actions; - let non_creditable_slots = env.non_creditable_slots; - - DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { - let call = ZoneCall::new(&input); - if !call.is_direct { + if !rules.is_delegate_call_allowed() && !call.is_direct { return Ok(PrecompileOutput::revert( 0, SolError::abi_encode(&DelegateCallNotAllowed {}).into(), @@ -229,15 +158,15 @@ pub(crate) fn create_protocol_precompile( input.internals, fixed_gas.map_or(input.gas, |_| u64::MAX), input.reservoir, - zone_spec, - amsterdam_eip8037_enabled, + env.cfg.spec, + env.cfg.enable_amsterdam_eip8037, input.is_static, - gas_params.clone(), + env.cfg.gas_params.clone(), ) - .with_actions(actions.clone()) - .with_non_creditable_slots(non_creditable_slots.clone()); + .with_actions(env.actions.clone()) + .with_non_creditable_slots(env.non_creditable_slots.clone()); - match StorageCtx::enter(&mut storage, || rules.check_with_local_state(call)) { + match StorageCtx::enter(&mut storage, || rules.check(call)) { CallCheck::Continue => {} CallCheck::Return(result) => { let result = StorageCtx::enter(&mut storage, || add_input_cost(call.data, result)); @@ -245,17 +174,8 @@ pub(crate) fn create_protocol_precompile( } } - 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) + let result = StorageCtx::enter(&mut storage, || execute(call.data, call.caller)); + apply_fixed_gas(result, fixed_gas) }) } @@ -303,7 +223,7 @@ mod tests { Some(FIXED_GAS) } - fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + fn check(&self, call: ZoneCall<'_>) -> CallCheck { *self.0.borrow_mut() = Some(( Bytes::copy_from_slice(call.data), call.selector(), @@ -339,9 +259,14 @@ mod tests { 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", + let env = ZonePrecompileEnv::new( &cfg, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ); + let precompile = create_precompile( + "ForwardingTest", + &env, RecordingRules(recorded_rule.clone()), move |data, caller| { *execute_record.borrow_mut() = Some((Bytes::copy_from_slice(data), caller)); @@ -376,15 +301,15 @@ mod tests { let execute_spec = observed_spec.clone(); let mut cfg = revm::context::CfgEnv::::default(); cfg.spec = TempoHardfork::T8; - let env = ProtocolPrecompileEnv::new( + let env = ZonePrecompileEnv::new( &cfg, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), ); let checked = Rc::new(Cell::new(false)); - let rejected = create_protocol_precompile( + let rejected = create_precompile( "L1AdmissionTest", - env.clone(), + &env, RejectRules(checked.clone()), |_, _| panic!("rejected call must not execute"), ); @@ -397,11 +322,10 @@ mod tests { ); assert!(checked.get()); - let precompile = - create_protocol_precompile("ProtocolTest", env, NoCallRules, move |_, _| { - execute_spec.set(Some(StorageCtx::default().spec())); - Ok(StorageCtx::default().success_output(Bytes::new())) - }); + let precompile = create_precompile("ProtocolTest", &env, NoCallRules, move |_, _| { + execute_spec.set(Some(StorageCtx::default().spec())); + Ok(StorageCtx::default().success_output(Bytes::new())) + }); precompile .call(input(&mut ctx, &[], Address::ZERO, u64::MAX)) @@ -417,7 +341,7 @@ mod tests { Some(FIXED_GAS) } - fn check_with_local_state(&self, _call: ZoneCall<'_>) -> CallCheck { + fn check(&self, _call: ZoneCall<'_>) -> CallCheck { self.0.set(true); CallCheck::Return(Ok( StorageCtx::default().revert_output(Bytes::from_static(b"denied")) @@ -431,9 +355,14 @@ mod tests { let executed = Rc::new(Cell::new(false)); let execute_flag = executed.clone(); let cfg = revm::context::CfgEnv::::default(); - let precompile = create_local_precompile( - "AdmissionTest", + let env = ZonePrecompileEnv::new( &cfg, + StorageActions::disabled(), + Rc::new(RefCell::new(NonCreditableSlots::empty())), + ); + let precompile = create_precompile( + "AdmissionTest", + &env, RejectRules(checked.clone()), move |_, _| { execute_flag.set(true); diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index ad501c131..f1b7cf960 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -43,6 +43,7 @@ pub mod dispatch { } mod execution; +pub use execution::ZonePrecompileEnv; pub mod storage; pub mod tempo_state; pub mod tip20_factory; @@ -92,28 +93,27 @@ pub fn extend_zone_precompiles( non_creditable_slots: Rc>, controller: L1AnchorController, ) { - let protocol_env = - execution::ProtocolPrecompileEnv::new(cfg, actions.clone(), non_creditable_slots.clone()); + let env = ZonePrecompileEnv::new(cfg, 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(), controller.clone(), - cfg, + &env, )) }); precompiles.apply_precompile(&CHAUM_PEDERSEN_VERIFY_ADDRESS, |_| { - Some(ChaumPedersenVerify::create(cfg)) + Some(ChaumPedersenVerify::create(&env)) }); precompiles.apply_precompile(&AES_GCM_DECRYPT_ADDRESS, |_| { - Some(AesGcmDecrypt::create(cfg)) + Some(AesGcmDecrypt::create(&env)) }); precompiles.apply_precompile(&ZONE_TIP20_FACTORY_ADDRESS, |_| { - Some(ZoneTokenFactory::create(cfg)) + Some(ZoneTokenFactory::create(&env)) }); - let tip403_env = protocol_env.clone(); + let tip403_env = env.clone(); precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, move |_| { Some(create_tip403_precompile(&tip403_env)) }); @@ -124,14 +124,14 @@ pub fn extend_zone_precompiles( if is_tip20_prefix(*address) { Some(create_tip20_precompile( *address, - &protocol_env, + &env, sequencer.clone(), )) } else if *address == TIP_FEE_MANAGER_ADDRESS { - Some(execution::create_protocol_precompile( + Some(execution::create_precompile( "TipFeeManager", - protocol_env.clone(), - execution::NoCallRules, + &env, + execution::DirectCallOnly, |data, caller| TipFeeManager::new().call(data, caller), )) } else if *address == STABLECOIN_DEX_ADDRESS { @@ -148,10 +148,10 @@ pub fn extend_zone_precompiles( impl AesGcmDecrypt { /// Create the AES-GCM precompile with ordinary zone-local execution. - pub fn create(cfg: &CfgEnv) -> DynPrecompile { - execution::create_local_precompile( + pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( "AesGcmDecrypt", - cfg, + env, execution::NoCallRules, |data, caller| Self.call(data, caller), ) @@ -160,10 +160,10 @@ impl AesGcmDecrypt { impl ChaumPedersenVerify { /// Create the Chaum-Pedersen precompile with ordinary zone-local execution. - pub fn create(cfg: &CfgEnv) -> DynPrecompile { - execution::create_local_precompile( + pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( "ChaumPedersenVerify", - cfg, + env, execution::NoCallRules, |data, caller| Self.call(data, caller), ) @@ -177,11 +177,11 @@ impl TempoState { pub fn create( reader: P, controller: L1AnchorController, - cfg: &CfgEnv, + env: &ZonePrecompileEnv, ) -> DynPrecompile { - execution::create_local_precompile( + execution::create_precompile( "TempoState", - cfg, + env, execution::DirectCallOnly, move |data, caller| Self::new().call_with_provider(&reader, &controller, data, caller), ) @@ -190,10 +190,10 @@ impl TempoState { 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( + pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( "ZoneTokenFactory", - cfg, + env, execution::DirectCallOnly, |data, caller| Self::new().call(data, caller), ) @@ -201,10 +201,10 @@ impl ZoneTokenFactory { } /// Create upstream TIP-403 execution with zone read-only rules and finalized L1 state. -pub(crate) fn create_tip403_precompile(env: &execution::ProtocolPrecompileEnv) -> DynPrecompile { - execution::create_protocol_precompile( +pub(crate) fn create_tip403_precompile(env: &ZonePrecompileEnv) -> DynPrecompile { + execution::create_precompile( "ZoneTip403Registry", - env.clone(), + env, tip403_proxy::Tip403Rules, |data, caller| TIP403Registry::new().call(data, caller), ) @@ -213,12 +213,12 @@ pub(crate) fn create_tip403_precompile(env: &execution::ProtocolPrecompileEnv) - /// 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::ProtocolPrecompileEnv, + env: &ZonePrecompileEnv, sequencer: Arc, ) -> DynPrecompile { - execution::create_protocol_precompile( + execution::create_precompile( "TIP20Token", - env.clone(), + env, ztip20::TIP20Rules::new(sequencer), move |data, caller| TIP20Token::from_address_unchecked(address).call(data, caller), ) diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index f9aae3baa..73f5601c3 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -35,8 +35,6 @@ pub struct L1AnchorError { /// Operation applied to the execution-local anchor state machine. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum L1AnchorOperation { - /// Initialize from the checkpoint stored in selected Zone state. - Initialize { anchor: u64 }, /// Observe external Tempo state at an anchor. Read { anchor: u64 }, /// Advance from a parent Tempo block to its direct child. @@ -62,8 +60,6 @@ pub enum L1AnchorPhase { from: u64, /// Child Tempo block number. to: u64, - /// Whether external Tempo state was observed after advancement. - has_read_l1: bool, }, } @@ -77,39 +73,8 @@ impl L1AnchorPhase { } } - /// Returns whether this phase has completed the required advancement. - pub const fn is_advanced(self) -> bool { - matches!(self, Self::Advanced { .. }) - } - - const fn parent(anchor: u64) -> Self { - Self::Parent { - anchor, - has_read_l1: false, - } - } - const fn advanced(from: u64, to: u64) -> Self { - Self::Advanced { - from, - to, - has_read_l1: false, - } - } - - const fn with_l1_read(self) -> Self { - match self { - Self::Parent { anchor, .. } => Self::Parent { - anchor, - has_read_l1: true, - }, - Self::Advanced { from, to, .. } => Self::Advanced { - from, - to, - has_read_l1: true, - }, - Self::Uninitialized => Self::Uninitialized, - } + Self::Advanced { from, to } } fn apply(self, operation: L1AnchorOperation) -> Result { @@ -119,16 +84,16 @@ impl L1AnchorPhase { }; match operation { - L1AnchorOperation::Initialize { anchor: new } => match self { - Self::Uninitialized => Ok(Self::parent(new)), - Self::Parent { anchor: prev, .. } if prev == new => Ok(self), - Self::Advanced { to, .. } if to == new => Ok(self), - _ => Err(invalid()), - }, L1AnchorOperation::Read { anchor: new } => match self { - Self::Uninitialized => Ok(Self::parent(new).with_l1_read()), - Self::Parent { anchor: prev, .. } if prev == new => Ok(self.with_l1_read()), - Self::Advanced { to, .. } if to == new => Ok(self.with_l1_read()), + Self::Uninitialized => Ok(Self::Parent { + anchor: new, + has_read_l1: true, + }), + Self::Parent { anchor: prev, .. } if prev == new => Ok(Self::Parent { + anchor: prev, + has_read_l1: true, + }), + Self::Advanced { to, .. } if to == new => Ok(self), _ => Err(invalid()), }, L1AnchorOperation::Advance { from, to } => { @@ -185,12 +150,6 @@ impl L1AnchorController { self.phase().current() } - /// Initializes the controller from the selected Zone state. - pub fn initialize(&self, anchor: u64) -> Result { - let phase = self.apply(L1AnchorOperation::Initialize { anchor })?; - Ok(phase.current().expect("initialization produces an anchor")) - } - /// Records a successful external Tempo state read at `anchor`. pub fn observe_read(&self, anchor: u64) -> Result<(), L1AnchorError> { self.apply(L1AnchorOperation::Read { anchor })?; @@ -204,12 +163,6 @@ impl L1AnchorController { } } -/// Returns whether a slot is mirrored from Tempo L1. -pub fn is_mirrored_slot(address: Address, key: U256) -> bool { - address == tempo_contracts::precompiles::TIP403_REGISTRY_ADDRESS - || is_tip20_policy_id_slot(address, key) -} - /// Returns whether this is the packed TIP-20 transfer-policy slot. pub fn is_tip20_policy_id_slot(address: Address, key: U256) -> bool { address.is_tip20() && key == tip20_slots::TRANSFER_POLICY_ID @@ -241,18 +194,17 @@ mod tests { controller.observe_read(11).unwrap(); assert_eq!( controller.phase(), - L1AnchorPhase::Advanced { - from: 10, - to: 11, - has_read_l1: true - } + L1AnchorPhase::Advanced { from: 10, to: 11 } ); } #[test] fn controller_rejects_reads_at_wrong_anchor() { let controller = L1AnchorController::default(); - controller.initialize(10).unwrap(); + controller.restore(L1AnchorPhase::Parent { + anchor: 10, + has_read_l1: false, + }); assert!(controller.observe_read(11).is_err()); } @@ -266,7 +218,10 @@ mod tests { #[test] fn controller_snapshot_restores_phase() { let controller = L1AnchorController::default(); - controller.initialize(10).unwrap(); + controller.restore(L1AnchorPhase::Parent { + anchor: 10, + has_read_l1: false, + }); let snapshot = controller.phase(); controller.begin_advance(10, 11).unwrap(); controller.restore(snapshot); diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 1ca83bfc3..318804e00 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -80,6 +80,17 @@ impl TempoState { .revert_output(Error(message.into()).abi_encode().into())) } + fn observed_l1_anchor( + &mut self, + controller: &L1AnchorController, + ) -> tempo_precompiles::Result { + let anchor = self.tempo_block_number.read()?; + controller + .observe_read(anchor) + .map_err(|err| TempoPrecompileError::Fatal(err.to_string()))?; + Ok(anchor) + } + fn apply_checkpoint( &mut self, controller: &L1AnchorController, @@ -114,7 +125,7 @@ impl TempoState { if header.parent_hash() != prev_block_hash { return self.revert_error(TempoStateAbi::InvalidParentHash {}); } - if header.number() != prev_block_number.saturating_add(1) { + if prev_block_number.checked_add(1) != Some(header.number()) { return self.revert_error(TempoStateAbi::InvalidBlockNumber {}); } @@ -151,15 +162,10 @@ impl TempoState { .revert_string("TempoState: only zone system contracts can read Tempo state"); } - let block_number = match self.tempo_block_number.read() { + let block_number = match self.observed_l1_anchor(controller) { Ok(number) => number, Err(err) => return self.storage.error_result(err), }; - if let Err(err) = controller.observe_read(block_number) { - return self - .storage - .error_result(TempoPrecompileError::Fatal(err.to_string())); - } let value = provider.read_l1_storage(call.account, call.slot, block_number)?; Ok(self.storage.success_output( TempoStateAbi::readTempoStorageSlotCall::abi_encode_returns(&value).into(), @@ -178,15 +184,10 @@ impl TempoState { .revert_string("TempoState: only zone system contracts can read Tempo state"); } - let block_number = match self.tempo_block_number.read() { + let block_number = match self.observed_l1_anchor(controller) { Ok(number) => number, Err(err) => return self.storage.error_result(err), }; - if let Err(err) = controller.observe_read(block_number) { - return self - .storage - .error_result(TempoPrecompileError::Fatal(err.to_string())); - } let mut values = Vec::with_capacity(call.slots.len()); for slot in call.slots { values.push(provider.read_l1_storage(call.account, slot, block_number)?); @@ -234,7 +235,8 @@ mod tests { use crate::{ storage::L1AnchorPhase, test_utils::{ - MockL1Reader, TestContext, call_precompile, test_context, test_storage_provider, + MockL1Reader, TestContext, call_precompile, test_context, test_env, + test_storage_provider, }, }; use alloy_evm::precompiles::DynPrecompile; @@ -354,7 +356,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::returning(B256::repeat_byte(0x11)), controller.clone(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let read = TempoStateAbi::readTempoStorageSlotCall { @@ -399,7 +401,7 @@ mod tests { initialize(&mut ctx, &genesis_rlp)?; let controller = L1AnchorController::default(); let reader = MockL1Reader::returning(B256::repeat_byte(0x11)); - let precompile = TempoState::create(reader.clone(), controller.clone(), &ctx.cfg.clone()); + let precompile = TempoState::create(reader.clone(), controller.clone(), &test_env(&ctx)); let child = encode_header(&child_header(genesis_hash, 11)); call( @@ -422,11 +424,7 @@ mod tests { )?; assert_eq!( controller.phase(), - L1AnchorPhase::Advanced { - from: 10, - to: 11, - has_read_l1: true, - } + L1AnchorPhase::Advanced { from: 10, to: 11 } ); assert!( reader @@ -447,7 +445,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); assert_checkpoint(&mut ctx, &precompile, keccak256(&header_rlp), 42)?; @@ -468,7 +466,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( @@ -496,7 +494,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, @@ -521,7 +519,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let calldata = TempoStateAbi::tempoBlockHashCall {}.abi_encode(); let output = call_precompile( @@ -536,10 +534,8 @@ mod tests { )?; assert!(output.is_revert()); - assert_eq!( - output.gas_used, - tempo_precompiles::input_cost(calldata.len()) - ); + // Direct-call-only precompiles reject delegate calls before metering or storage setup. + assert_eq!(output.gas_used, 0); Ok(()) } @@ -556,7 +552,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, @@ -583,7 +579,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, @@ -613,7 +609,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, @@ -641,7 +637,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, @@ -669,7 +665,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::default(), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, @@ -695,7 +691,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::returning(expected), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let calldata: Bytes = TempoStateAbi::readTempoStorageSlotCall { account: address!("0x0000000000000000000000000000000000009999"), @@ -732,7 +728,7 @@ mod tests { let precompile = TempoState::create( MockL1Reader::returning(expected), L1AnchorController::default(), - &ctx.cfg.clone(), + &test_env(&ctx), ); let output = call( &mut ctx, diff --git a/crates/precompiles/src/test_utils/local.rs b/crates/precompiles/src/test_utils/local.rs index f140bd181..6ca818614 100644 --- a/crates/precompiles/src/test_utils/local.rs +++ b/crates/precompiles/src/test_utils/local.rs @@ -21,9 +21,9 @@ use tempo_precompiles::{ }; use crate::{ + ZonePrecompileEnv, chaum_pedersen::{challenge_hash, recover_point}, ecies::DecryptedDeposit, - execution::ProtocolPrecompileEnv, }; pub(crate) use crate::ecies::{build_plaintext, compressed_x_and_parity, encrypt_plaintext}; @@ -54,9 +54,9 @@ pub(crate) fn test_storage_provider( ) } -/// Create the ordinary protocol precompile environment for a local unit test. -pub(crate) fn test_protocol_env(ctx: &TestContext) -> ProtocolPrecompileEnv { - ProtocolPrecompileEnv::new( +/// Create the ordinary precompile environment for a local unit test. +pub(crate) fn test_env(ctx: &TestContext) -> ZonePrecompileEnv { + ZonePrecompileEnv::new( &ctx.cfg, StorageActions::disabled(), Rc::new(RefCell::new(NonCreditableSlots::empty())), diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 599e20f64..6fa6343f4 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -35,7 +35,11 @@ alloy_sol_types::sol! { pub(crate) struct Tip403Rules; impl CallRules for Tip403Rules { - fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + fn is_delegate_call_allowed(&self) -> bool { + false + } + + fn check(&self, call: ZoneCall<'_>) -> CallCheck { if call .selector() .is_some_and(|selector| TIP403_MUTATING_SELECTORS.contains(&selector)) @@ -62,9 +66,7 @@ mod tests { use crate::{ create_tip403_precompile, tempo_state::slots::TEMPO_BLOCK_NUMBER, - test_utils::{ - TestContext, call_precompile, test_context, test_protocol_env, test_storage_provider, - }, + test_utils::{TestContext, call_precompile, test_context, test_env, test_storage_provider}, }; const ANCHOR: u64 = 77; @@ -88,7 +90,7 @@ mod tests { U256::from(ANCHOR), ) .unwrap(); - let env = test_protocol_env(&ctx); + let env = test_env(&ctx); Self { ctx, precompile: create_tip403_precompile(&env), diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 78b58536e..51b91c8ef 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -68,6 +68,10 @@ impl TIP20Rules { } impl CallRules for TIP20Rules { + fn is_delegate_call_allowed(&self) -> bool { + false + } + fn fixed_gas(&self, selector: Option<[u8; 4]>) -> Option { selector .is_some_and(|selector| TIP20_FIXED_GAS_SELECTORS.contains(&selector)) @@ -75,7 +79,7 @@ impl CallRules for TIP20Rules { } /// Apply zone privacy and bridge-path checks before upstream execution. - fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + fn check(&self, call: ZoneCall<'_>) -> CallCheck { let Some(selector) = call.selector() else { return CallCheck::Continue; }; @@ -179,7 +183,7 @@ mod tests { use tempo_zone_contracts::Unauthorized; use crate::test_utils::{ - TestContext, call_precompile, test_context, test_protocol_env, test_storage_provider, + TestContext, call_precompile, test_context, test_env, test_storage_provider, }; #[derive(Clone, Copy)] @@ -261,7 +265,7 @@ mod tests { })?; } - let env = test_protocol_env(&ctx); + let env = test_env(&ctx); let precompile = crate::create_tip20_precompile( token, &env, @@ -424,7 +428,7 @@ mod tests { let caller = address!("0x00000000000000000000000000000000000000a2"); let to = address!("0x00000000000000000000000000000000000000a3"); let mut ctx = test_context(); - let env = test_protocol_env(&ctx); + let env = test_env(&ctx); let precompile = crate::create_tip20_precompile(token, &env, Arc::new(MockSequencer { address: None })); let calldata: Bytes = ITIP20::transferCall { From 62118469c88a8a39e143839603cc8c49e1cbf968 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 12:38:13 +0200 Subject: [PATCH 17/22] docs: update --- crates/evm/src/database.rs | 8 +++--- crates/evm/src/executor.rs | 2 +- crates/evm/src/lib.rs | 10 ++++--- crates/evm/src/zone_evm/mod.rs | 3 ++- crates/precompiles/src/aes_gcm/mod.rs | 4 +-- crates/precompiles/src/ecies.rs | 2 +- crates/precompiles/src/execution.rs | 21 ++++++++------- crates/precompiles/src/lib.rs | 30 +++++++++------------ crates/precompiles/src/storage.rs | 5 ++-- crates/precompiles/src/tip20_factory/mod.rs | 4 +-- crates/precompiles/src/ztip20/mod.rs | 10 +++---- 11 files changed, 51 insertions(+), 48 deletions(-) diff --git a/crates/evm/src/database.rs b/crates/evm/src/database.rs index 8700358ea..6499d03d0 100644 --- a/crates/evm/src/database.rs +++ b/crates/evm/src/database.rs @@ -1,4 +1,5 @@ -//! Execution-local database adapter for Tempo-owned state mirrored into a Zone. +//! Execution-local database adapter that overlays finalized Tempo L1 reads while preserving +//! the caller-provided Zone database as the sole canonical state backend. use std::{collections::HashMap, fmt}; @@ -66,7 +67,8 @@ struct PackedPolicySlot { l1: U256, } -/// Database adapter that resolves Tempo-owned policy storage at the Zone's finalized L1 anchor. +/// Resolves mirrored L1 reads at the active Tempo anchor and forwards all other database +/// operations to the caller-provided Zone database. pub struct AnchoredZoneDb { inner: DB, l1: L1, @@ -148,7 +150,7 @@ impl AnchoredZoneDb { }) } - /// Rejects registry writes and removes the mirrored field from packed TIP-20 transitions. + /// Rejects writes to the L1-mirrored slots and removes the mirrored field from packed TIP-20 transitions. pub fn sanitize_state( &self, state: &mut AddressMap, diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index 03b1a0230..258741a7a 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -1,7 +1,7 @@ //! Zone block executor. //! //! A simplified block executor for zone nodes that wraps [`EthBlockExecutor`] directly. -//! Unlike the Tempo L1 [`TempoBlockExecutor`], this executor does **not** enforce subblock +//! Unlike the Tempo L1 `TempoBlockExecutor`, this executor does **not** enforce subblock //! ordering, shared-gas accounting, or the end-of-block subblock metadata system transaction. use alloy_consensus::transaction::TxHashRef; diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index ff5e58839..9c5261462 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -1,7 +1,8 @@ //! Zone-specific EVM configuration. //! -//! Wraps [`TempoEvmConfig`] with a custom [`ZoneEvmFactory`] that registers -//! zone-specific native precompiles. +//! Wraps [`TempoEvmConfig`] with a [`ZoneEvmFactory`] that installs the L1-anchored database, +//! registers Zone-native precompiles, and preserves the original database at the [`Evm`] boundary. +//! Block assembly also validates that the `advanceTempo` system tx happens once at index zero. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -59,7 +60,7 @@ use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; type TempoCtx = ::Context; -/// Zone EVM factory — wraps [`TempoEvmFactory`] and registers the zone-native precompiles. +/// Zone EVM factory that adapts caller databases and registers the zone-native precompiles. #[derive(Debug, Clone)] pub struct ZoneEvmFactory { l1_reader: L1, @@ -133,7 +134,8 @@ where } } -/// Assembler for Zone blocks — delegates to [`TempoBlockAssembler`] after converting input types. +/// Assembler for Zone blocks — delegates to [`TempoBlockAssembler`] after validating system tx +/// ordering and converting input types. #[derive(Debug, Clone)] pub struct ZoneBlockAssembler { inner: TempoBlockAssembler, diff --git a/crates/evm/src/zone_evm/mod.rs b/crates/evm/src/zone_evm/mod.rs index 7b774f4c6..a3e4b1dec 100644 --- a/crates/evm/src/zone_evm/mod.rs +++ b/crates/evm/src/zone_evm/mod.rs @@ -25,7 +25,8 @@ type ZoneEvmError = EVMError; /// Zone runtime EVM. /// /// Execution uses an anchored database adapter internally while the public [`Evm::DB`] remains the -/// exact database supplied by the caller. +/// exact database supplied by the caller. Successful results are validated and sanitized before +/// their state transitions can be committed through that public database. pub struct ZoneEvm { inner: TempoEvm, I>, } diff --git a/crates/precompiles/src/aes_gcm/mod.rs b/crates/precompiles/src/aes_gcm/mod.rs index d1c51284d..03c7c387c 100644 --- a/crates/precompiles/src/aes_gcm/mod.rs +++ b/crates/precompiles/src/aes_gcm/mod.rs @@ -3,9 +3,9 @@ //! Registered at [`AES_GCM_DECRYPT_ADDRESS`] (`0x1C00...0101`). //! //! Decrypts ECIES ciphertext and verifies the GCM authentication tag, -//! enabling the [`ZoneInbox`] contract to process encrypted deposits. +//! enabling the `ZoneInbox` contract to process encrypted deposits. //! -//! Uses the NCC-audited [`aes-gcm`] crate (v0.10.3). +//! Uses the NCC-audited `aes-gcm` crate (v0.10.3). use alloc::vec::Vec; diff --git a/crates/precompiles/src/ecies.rs b/crates/precompiles/src/ecies.rs index d1f6e6770..afd835bc9 100644 --- a/crates/precompiles/src/ecies.rs +++ b/crates/precompiles/src/ecies.rs @@ -1,7 +1,7 @@ //! Sequencer-side ECIES operations for encrypted deposit decryption. //! //! These functions run **off-chain** in the payload builder to produce the -//! [`DecryptionData`] that the on-chain ZoneInbox contract verifies via the +//! `DecryptionData` that the on-chain ZoneInbox contract verifies via the //! Chaum-Pedersen and AES-GCM precompiles. use alloc::vec::Vec; diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index d174390e1..3fb5d4da6 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -1,18 +1,19 @@ //! Shared execution for Zone-native and upstream Tempo precompiles. //! -//! Both helpers install an EVM-backed [`StorageCtx`], apply Zone-specific [`CallRules`], and -//! forward admitted calls without changing their calldata or caller. Protocol storage reaches the -//! execution-local anchored database through the ordinary revm journal. +//! Every Zone wrapper installs the same EVM-backed [`StorageCtx`], applies Zone-specific +//! [`CallRules`], and forwards admitted calls without changing their calldata or caller. +//! The EVM database decides whether each storage read is local or resolved from L1-anchored state. //! //! # Call ordering //! -//! 1. Protocol execution rejects delegate calls before storage access. +//! 1. Direct-call-only rules reject delegate calls before storage access. //! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. //! 3. Apply [`CallRules`], which may inspect local or anchored state through ordinary EVM storage. //! 4. 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. +//! Admission-rule rejections include calldata input gas, while early delegate-call rejection is +//! unmetered. Calls without a fixed charge retain normal provider metering, and successful +//! fixed-price calls report exactly the configured charge. use alloc::rc::Rc; use core::cell::RefCell; @@ -29,7 +30,7 @@ use tempo_precompiles::{ storage_credits::NonCreditableSlots, }; -/// Shared inputs for protocol precompiles whose storage is provided by the EVM context. +/// Shared EVM configuration and accounting state installed for every Zone precompile wrapper. #[derive(Clone)] pub struct ZonePrecompileEnv { cfg: revm::context::CfgEnv, @@ -38,6 +39,7 @@ pub struct ZonePrecompileEnv { } impl ZonePrecompileEnv { + /// Captures the active EVM configuration and transaction-local storage accounting state. pub fn new( cfg: &revm::context::CfgEnv, actions: StorageActions, @@ -52,9 +54,8 @@ impl ZonePrecompileEnv { } /// Call metadata, independent of EVM internals, for [`CallRules`] running in a [`StorageCtx`]. -/// Provider-free precompiles can inspect `PrecompileInput` directly. /// -/// **MOTIVATION:** Execution helpers move `PrecompileInput::internals` into the +/// **MOTIVATION:** The execution wrapper moves `PrecompileInput::internals` into the /// [`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`]. @@ -86,7 +87,7 @@ impl<'a> ZoneCall<'a> { pub(crate) enum CallCheck { /// Allow the call and invoke the supplied precompile implementation. /// - /// Protocol precompiles observe finalized L1 policy state through the EVM database adapter. + /// Mirrored reads performed by the implementation resolve through the EVM database adapter. Continue, /// Reject the call without invoking the supplied implementation. /// diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index f1b7cf960..f04547c2d 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -78,12 +78,12 @@ use tempo_precompiles::{ }; use zone_primitives::constants::TEMPO_STATE_ADDRESS; -/// Register zone-native and currently supported Tempo precompiles. +/// Registers Zone-native and supported Tempo 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 ordinary EVM storage; -/// the Zone EVM context resolves mirrored reads through its anchored database adapter. +/// Every Zone wrapper receives the same [`ZonePrecompileEnv`]. Storage remains ordinary EVM +/// storage: the Zone database adapter transparently resolves mirrored TIP-20/TIP-403 reads at the +/// active Tempo anchor, while all other slots remain Zone-local. `TempoState` keeps its explicit L1 +/// reader for the system-only arbitrary-storage read ABI. pub fn extend_zone_precompiles( precompiles: &mut PrecompilesMap, cfg: &CfgEnv, @@ -122,11 +122,7 @@ pub fn extend_zone_precompiles( // 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(create_tip20_precompile( - *address, - &env, - sequencer.clone(), - )) + Some(create_tip20_precompile(*address, &env, sequencer.clone())) } else if *address == TIP_FEE_MANAGER_ADDRESS { Some(execution::create_precompile( "TipFeeManager", @@ -147,7 +143,7 @@ pub fn extend_zone_precompiles( } impl AesGcmDecrypt { - /// Create the AES-GCM precompile with ordinary zone-local execution. + /// Creates the AES-GCM precompile with the shared zone execution environment. pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { execution::create_precompile( "AesGcmDecrypt", @@ -159,7 +155,7 @@ impl AesGcmDecrypt { } impl ChaumPedersenVerify { - /// Create the Chaum-Pedersen precompile with ordinary zone-local execution. + /// Creates the Chaum-Pedersen precompile with the shared zone execution environment. pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { execution::create_precompile( "ChaumPedersenVerify", @@ -171,9 +167,9 @@ impl ChaumPedersenVerify { } impl TempoState { - /// Create the `TempoState` precompile with local storage and direct-call-only execution. + /// Creates the direct-call-only `TempoState` precompile with checkpoint storage. /// - /// Storage-slot RPC reads are delegated to `reader` at the checkpoint recorded in local state. + /// System-only arbitrary L1 storage reads are delegated to `reader` at the stored checkpoint. pub fn create( reader: P, controller: L1AnchorController, @@ -189,7 +185,7 @@ impl TempoState { } impl ZoneTokenFactory { - /// Create the zone token factory with local storage and direct-call-only execution. + /// Creates the direct-call-only token factory with zone-local storage and execution. pub fn create(env: &ZonePrecompileEnv) -> DynPrecompile { execution::create_precompile( "ZoneTokenFactory", @@ -200,7 +196,7 @@ impl ZoneTokenFactory { } } -/// Create upstream TIP-403 execution with zone read-only rules and finalized L1 state. +/// Creates upstream TIP-403 execution with zone read-only rules and adapter-backed L1 reads. pub(crate) fn create_tip403_precompile(env: &ZonePrecompileEnv) -> DynPrecompile { execution::create_precompile( "ZoneTip403Registry", @@ -210,7 +206,7 @@ pub(crate) fn create_tip403_precompile(env: &ZonePrecompileEnv) -> DynPrecompile ) } -/// Create upstream TIP-20 execution with zone rules and finalized L1 policy reads. +/// Creates upstream TIP-20 execution with zone rules and adapter-backed L1 policy reads. pub(crate) fn create_tip20_precompile( address: alloy_primitives::Address, env: &ZonePrecompileEnv, diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 73f5601c3..8e4054206 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -1,4 +1,5 @@ -//! Shared state for the Zone EVM's anchored Tempo database adapter. +//! Anchor coordination and packed-storage compatibility shared by the zone EVM database adapter +//! and the native `TempoState` precompile. use alloc::rc::Rc; use core::{cell::RefCell, fmt}; @@ -150,7 +151,7 @@ impl L1AnchorController { self.phase().current() } - /// Records a successful external Tempo state read at `anchor`. + /// Validates the active anchor and records an external Tempo state read. pub fn observe_read(&self, anchor: u64) -> Result<(), L1AnchorError> { self.apply(L1AnchorOperation::Read { anchor })?; Ok(()) diff --git a/crates/precompiles/src/tip20_factory/mod.rs b/crates/precompiles/src/tip20_factory/mod.rs index d8394ffd2..7b35ae1eb 100644 --- a/crates/precompiles/src/tip20_factory/mod.rs +++ b/crates/precompiles/src/tip20_factory/mod.rs @@ -1,11 +1,11 @@ //! Zone-side TIP20 factory precompile. //! -//! Deployed at the same address as the L1 [`TIP20Factory`] (`0x20FC…0000`), this +//! Deployed at the same address as the L1 `TIP20Factory` (`0x20FC…0000`), this //! precompile replaces the standard factory on the zone with a single //! `enableToken(address, string, string, string)` entrypoint. //! //! When the sequencer bridges a new TIP-20 token to the zone, the -//! [`ZoneInbox`] contract calls `enableToken` during `advanceTempo` to: +//! `ZoneInbox` contract calls `enableToken` during `advanceTempo` to: //! //! 1. Initialize the TIP-20 storage at the given address (name, symbol, currency). //! 2. Grant [`ISSUER_ROLE`] to both [`ZONE_INBOX_ADDRESS`] (for minting on diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 51b91c8ef..491427de6 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -1,7 +1,7 @@ //! Zone pre-execution rules for the upstream Tempo TIP20 precompile. //! -//! [`TIP20Token`] remains the source of truth for token and TIP403 policy behavior. -//! Before forwarding a call to Tempo, [`TIP20Rules`] applies only zone-specific checks: +//! `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. //! //! Accepted calldata and callers are forwarded unchanged to Tempo. Ordinary token state remains @@ -47,14 +47,14 @@ fn decode_and_check( /// 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 state provider so rules can authorize +/// sequencer-visible reads without depending on the concrete provider type. pub trait SequencerExt: Send + Sync { /// Return the latest known active sequencer. fn latest_sequencer(&self) -> Option
; } -/// Zone-specific rules applied before forwarding to upstream [`TIP20Token`]. +/// 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. From b8c96ee19440e68cd47ffd69535e1c0fed6e5158 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 12:55:44 +0200 Subject: [PATCH 18/22] chore: simplify `CallRules` --- crates/evm/src/lib.rs | 25 +- crates/evm/src/validation.rs | 154 ------ crates/precompiles/src/execution.rs | 130 ++--- crates/precompiles/src/storage.rs | 13 +- crates/precompiles/src/tempo_state.rs | 536 ++++++++------------- crates/precompiles/src/tip403_proxy/mod.rs | 20 +- crates/precompiles/src/ztip20/mod.rs | 87 ++-- 7 files changed, 297 insertions(+), 668 deletions(-) delete mode 100644 crates/evm/src/validation.rs diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 9c5261462..659a4bdf7 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -2,7 +2,6 @@ //! //! Wraps [`TempoEvmConfig`] with a [`ZoneEvmFactory`] that installs the L1-anchored database, //! registers Zone-native precompiles, and preserves the original database at the [`Evm`] boundary. -//! Block assembly also validates that the `advanceTempo` system tx happens once at index zero. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -14,12 +13,10 @@ pub mod precompiles; #[cfg(test)] mod test_utils; mod tx_context; -mod validation; mod zone_evm; pub use database::{AnchoredZoneDb, AnchoredZoneDbError}; pub use executor::ZoneBlockExecutor; -pub use validation::{AdvanceTempoValidationError, validate_advance_tempo_transactions}; pub use zone_evm::{ZoneEvm, contract_creation::validate_transaction}; use crate::{ @@ -134,8 +131,7 @@ where } } -/// Assembler for Zone blocks — delegates to [`TempoBlockAssembler`] after validating system tx -/// ordering and converting input types. +/// Assembler for Zone blocks - delegates to [`TempoBlockAssembler`] after converting input types. #[derive(Debug, Clone)] pub struct ZoneBlockAssembler { inner: TempoBlockAssembler, @@ -170,10 +166,6 @@ impl BlockAssembler for ZoneBlockAssembler { .. } = input; - if let Err(error) = validate_advance_tempo_transactions(&transactions) { - return Err(alloy_evm::block::BlockValidationError::other(error).into()); - } - self.inner.assemble_block( BlockAssemblerInput::::new( evm_env, @@ -330,9 +322,6 @@ impl ConfigureEvm for ZoneEvmConfig { use alloy_evm::eth::EthBlockExecutionCtx; use std::borrow::Cow; - validate_advance_tempo_transactions(&block.body().transactions) - .map_err(|err| TempoEvmError::InvalidEvmConfig(err.to_string()))?; - Ok(TempoBlockExecutionCtx { inner: EthBlockExecutionCtx { parent_hash: block.header().parent_hash(), @@ -392,24 +381,12 @@ impl ConfigureEngineEvm for ZoneEvmConfig { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::TestL1; - use alloy_evm::revm::database::EmptyDB; use reth_chainspec::{EthChainSpec, ForkCondition}; use tempo_chainspec::{ hardfork::TempoHardfork, spec::{DEV, MODERATO, TempoHardforks}, }; - #[test] - fn factory_context_adapts_and_recovers_original_database() { - let factory = ZoneEvmFactory::new(TestL1::default()); - let mut evm = factory.create_evm(EmptyDB::default(), EvmEnv::default()); - let _: &EmptyDB = evm.db(); - let _: &mut EmptyDB = evm.db_mut(); - let (db, _) = evm.finish(); - let _: EmptyDB = db; - } - #[test] fn composed_chain_spec_uses_zone_identity_and_parent_tempo_forks() { let zone = ZoneChainSpec::from(DEV.clone()); diff --git a/crates/evm/src/validation.rs b/crates/evm/src/validation.rs deleted file mode 100644 index 81a634c04..000000000 --- a/crates/evm/src/validation.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Zone block transaction-order validation. - -use alloy_consensus::Transaction; -use alloy_primitives::TxKind; -use alloy_sol_types::SolCall; -use tempo_primitives::TempoTxEnvelope; -use tempo_zone_contracts::ZoneInbox; -use thiserror::Error; -use zone_primitives::constants::ZONE_INBOX_ADDRESS; - -/// Invalid required `advanceTempo` transaction sequence. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum AdvanceTempoValidationError { - /// The block contains no transactions. - #[error("block is missing the required advanceTempo transaction")] - Missing, - /// Transaction zero is not a canonical Tempo system transaction. - #[error("transaction zero must use the canonical Tempo system transaction envelope")] - InvalidSystemEnvelope, - /// Transaction zero does not call the Zone inbox. - #[error("transaction zero must call the ZoneInbox advanceTempo entrypoint")] - InvalidTarget, - /// Transaction zero does not select `advanceTempo`. - #[error("transaction zero must select ZoneInbox.advanceTempo")] - InvalidSelector, - /// The `advanceTempo` calldata does not ABI-decode. - #[error("invalid advanceTempo calldata: {0}")] - InvalidCalldata(String), - /// A later transaction attempts another advancement. - #[error("duplicate advanceTempo transaction at index {index}")] - Duplicate { - /// Zero-based transaction index. - index: usize, - }, -} - -fn calls_advance_tempo(tx: &TempoTxEnvelope) -> bool { - matches!(tx.kind(), TxKind::Call(to) if to == ZONE_INBOX_ADDRESS) - && tx - .input() - .starts_with(&ZoneInbox::advanceTempoCall::SELECTOR) -} - -/// Validates that the block starts with exactly one canonical `advanceTempo` system transaction. -/// -/// Tempo's reserved system signature uniquely identifies the zero-address system sender, so -/// [`TempoTxEnvelope::is_system_tx`] validates both the envelope signature and recovered sender -/// convention used by block execution. -pub fn validate_advance_tempo_transactions( - transactions: &[TempoTxEnvelope], -) -> Result<(), AdvanceTempoValidationError> { - let first = transactions - .first() - .ok_or(AdvanceTempoValidationError::Missing)?; - if !first.is_system_tx() { - return Err(AdvanceTempoValidationError::InvalidSystemEnvelope); - } - if !matches!(first.kind(), TxKind::Call(to) if to == ZONE_INBOX_ADDRESS) { - return Err(AdvanceTempoValidationError::InvalidTarget); - } - if !first - .input() - .starts_with(&ZoneInbox::advanceTempoCall::SELECTOR) - { - return Err(AdvanceTempoValidationError::InvalidSelector); - } - ZoneInbox::advanceTempoCall::abi_decode(first.input()) - .map_err(|err| AdvanceTempoValidationError::InvalidCalldata(err.to_string()))?; - - if let Some((index, _)) = transactions - .iter() - .enumerate() - .skip(1) - .find(|(_, tx)| calls_advance_tempo(tx)) - { - return Err(AdvanceTempoValidationError::Duplicate { index }); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_consensus::{Signed, TxLegacy}; - use alloy_primitives::{Address, Bytes, U256}; - use tempo_primitives::transaction::envelope::TEMPO_SYSTEM_TX_SIGNATURE; - - fn advance_tx() -> TempoTxEnvelope { - let input = ZoneInbox::advanceTempoCall { - header: Bytes::new(), - deposits: Vec::new(), - decryptions: Vec::new(), - enabledTokens: Vec::new(), - } - .abi_encode() - .into(); - TempoTxEnvelope::Legacy(Signed::new_unhashed( - TxLegacy { - chain_id: None, - nonce: 0, - gas_price: 0, - gas_limit: 0, - to: ZONE_INBOX_ADDRESS.into(), - value: U256::ZERO, - input, - }, - TEMPO_SYSTEM_TX_SIGNATURE, - )) - } - - fn regular_tx() -> TempoTxEnvelope { - TempoTxEnvelope::Legacy(Signed::new_unhashed( - TxLegacy { - to: Address::repeat_byte(0x11).into(), - ..Default::default() - }, - alloy_primitives::Signature::test_signature(), - )) - } - - #[test] - fn requires_advance_at_transaction_zero() { - assert_eq!( - validate_advance_tempo_transactions(&[]), - Err(AdvanceTempoValidationError::Missing) - ); - assert_eq!( - validate_advance_tempo_transactions(&[regular_tx()]), - Err(AdvanceTempoValidationError::InvalidSystemEnvelope) - ); - assert!(validate_advance_tempo_transactions(&[advance_tx()]).is_ok()); - } - - #[test] - fn rejects_duplicate_advance() { - assert_eq!( - validate_advance_tempo_transactions(&[advance_tx(), regular_tx(), advance_tx(),]), - Err(AdvanceTempoValidationError::Duplicate { index: 2 }) - ); - } - - #[test] - fn rejects_malformed_advance_calldata() { - let mut tx = advance_tx(); - let TempoTxEnvelope::Legacy(signed) = &mut tx else { - unreachable!() - }; - signed.tx_mut().input = ZoneInbox::advanceTempoCall::SELECTOR.to_vec().into(); - assert!(matches!( - validate_advance_tempo_transactions(&[tx]), - Err(AdvanceTempoValidationError::InvalidCalldata(_)) - )); - } -} diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs index 3fb5d4da6..3cefc0414 100644 --- a/crates/precompiles/src/execution.rs +++ b/crates/precompiles/src/execution.rs @@ -8,7 +8,7 @@ //! //! 1. Direct-call-only rules reject delegate calls before storage access. //! 2. Decode the selector and reject calls that cannot cover a configured fixed gas charge. -//! 3. Apply [`CallRules`], which may inspect local or anchored state through ordinary EVM storage. +//! 3. Apply pure [`CallRules`] admission checks using calldata and caller metadata. //! 4. Forward the original calldata and caller, applying any configured fixed gas charge. //! //! Admission-rule rejections include calldata input gas, while early delegate-call rejection is @@ -18,8 +18,8 @@ use alloc::rc::Rc; use core::cell::RefCell; -use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; -use alloy_primitives::Address; +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolError; use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; use tempo_chainspec::hardfork::TempoHardfork; @@ -53,58 +53,22 @@ impl ZonePrecompileEnv { } } -/// Call metadata, independent of EVM internals, for [`CallRules`] running in a [`StorageCtx`]. -/// -/// **MOTIVATION:** The execution wrapper moves `PrecompileInput::internals` into the -/// [`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)] -pub(crate) struct ZoneCall<'a> { - /// Input calldata. - pub(crate) data: &'a [u8], - /// 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. - /// - /// Mirrored reads performed by the implementation resolve through the EVM database adapter. + /// Invoke the supplied precompile implementation. Continue, - /// Reject the call without invoking the supplied implementation. - /// - /// The execution helper charges calldata input gas before returning the result. - Return(PrecompileResult), + /// Revert with ABI-encoded data. The execution wrapper MUST apply input gas and reservoir. + Revert(Bytes), } -/// Selector-, caller-, and call-context-dependent rules evaluated by centralized precompile -/// execution before invoking the implementation. +/// Pure, selector and caller dependent, precompile call rules evaluated before storage setup. /// -/// Anchored reads are resolved by the EVM database adapter. 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. +/// Rules may enforce admission policy and duplicate cheap business checks as fail-fast preflight. +/// All state access remains in the implementation and resolves through the EVM database adapter. pub(crate) trait CallRules: 'static { /// Returns whether this precompile accepts delegate calls. fn is_delegate_call_allowed(&self) -> bool { - true + false } /// Return the fixed gas charge for this selector, if one applies. @@ -112,21 +76,21 @@ pub(crate) trait CallRules: 'static { None } - /// Applies Zone-specific admission rules before invoking the upstream implementation. - fn check(&self, _call: ZoneCall<'_>) -> CallCheck { + /// Applies pure Zone-specific admission rules before storage setup. + fn admit(&self, _data: &[u8], _caller: Address) -> CallCheck { CallCheck::Continue } } -/// 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 { +impl CallRules for DirectCallOnly {} + +/// Rules for precompiles that allow delegate calls without additional admission checks. +pub(crate) struct NoCallRules; +impl CallRules for NoCallRules { fn is_delegate_call_allowed(&self) -> bool { - false + true } } @@ -138,8 +102,7 @@ pub(crate) fn create_precompile( ) -> DynPrecompile { let env = env.clone(); DynPrecompile::new_stateful(PrecompileId::Custom(id.into()), move |input| { - let call = ZoneCall::new(&input); - if !rules.is_delegate_call_allowed() && !call.is_direct { + if !rules.is_delegate_call_allowed() && !input.is_direct_call() { return Ok(PrecompileOutput::revert( 0, SolError::abi_encode(&DelegateCallNotAllowed {}).into(), @@ -147,7 +110,8 @@ pub(crate) fn create_precompile( )); } - let fixed_gas = rules.fixed_gas(call.selector()); + let (data, caller) = (input.data, input.caller); + let fixed_gas = rules.fixed_gas(selector_from_calldata(data)); if fixed_gas.is_some_and(|gas| input.gas < gas) { return Ok(PrecompileOutput::halt( PrecompileHalt::OutOfGas, @@ -167,37 +131,31 @@ pub(crate) fn create_precompile( .with_actions(env.actions.clone()) .with_non_creditable_slots(env.non_creditable_slots.clone()); - match StorageCtx::enter(&mut storage, || rules.check(call)) { - CallCheck::Continue => {} - CallCheck::Return(result) => { - let result = StorageCtx::enter(&mut storage, || add_input_cost(call.data, result)); - return apply_fixed_gas(result, fixed_gas); + let mut result = StorageCtx::enter(&mut storage, || match rules.admit(data, caller) { + CallCheck::Continue => execute(data, caller), + CallCheck::Revert(output) => { + let s = StorageCtx::default(); + let output = s.revert_output(output); + add_input_cost(s, data, Ok(output)) } + }); + if let (Ok(output), Some(gas)) = (&mut result, fixed_gas) { + output.gas_used = gas; } - - let result = StorageCtx::enter(&mut storage, || execute(call.data, call.caller)); - apply_fixed_gas(result, fixed_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 { - let mut storage = StorageCtx::default(); - let gas_before = storage.gas_used(); - if let Some(err) = charge_input_cost(&mut storage, calldata) { +fn add_input_cost(mut s: StorageCtx, data: &[u8], mut res: PrecompileResult) -> PrecompileResult { + let gas_before = s.gas_used(); + if let Some(err) = charge_input_cost(&mut s, data) { return err; } - if let Ok(output) = &mut result { - let input_gas = storage.gas_used().saturating_sub(gas_before); + if let Ok(output) = &mut res { + let input_gas = s.gas_used().saturating_sub(gas_before); output.gas_used = output.gas_used.saturating_add(input_gas); } - result + res } #[cfg(test)] @@ -224,11 +182,11 @@ mod tests { Some(FIXED_GAS) } - fn check(&self, call: ZoneCall<'_>) -> CallCheck { + fn admit(&self, data: &[u8], caller: Address) -> CallCheck { *self.0.borrow_mut() = Some(( - Bytes::copy_from_slice(call.data), - call.selector(), - call.caller, + Bytes::copy_from_slice(data), + selector_from_calldata(data), + caller, )); CallCheck::Continue } @@ -342,11 +300,9 @@ mod tests { Some(FIXED_GAS) } - fn check(&self, _call: ZoneCall<'_>) -> CallCheck { + fn admit(&self, _data: &[u8], _caller: Address) -> CallCheck { self.0.set(true); - CallCheck::Return(Ok( - StorageCtx::default().revert_output(Bytes::from_static(b"denied")) - )) + CallCheck::Revert(Bytes::from_static(b"denied")) } } diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 8e4054206..33632a4ce 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -2,7 +2,7 @@ //! and the native `TempoState` precompile. use alloc::rc::Rc; -use core::{cell::RefCell, fmt}; +use core::{cell::Cell, fmt}; use alloy_primitives::{Address, B256, U256}; use revm::precompile::PrecompileError; @@ -117,7 +117,7 @@ impl L1AnchorPhase { /// The execution-local Tempo anchor used by the database adapter. #[derive(Clone, Default)] pub struct L1AnchorController { - state: Rc>, + state: Rc>, } impl fmt::Debug for L1AnchorController { @@ -130,20 +130,19 @@ impl fmt::Debug for L1AnchorController { impl L1AnchorController { fn apply(&self, operation: L1AnchorOperation) -> Result { - let mut phase = self.state.borrow_mut(); - let next = phase.apply(operation)?; - *phase = next; + let next = self.state.get().apply(operation)?; + self.state.set(next); Ok(next) } /// Returns the current phase. pub fn phase(&self) -> L1AnchorPhase { - *self.state.borrow() + self.state.get() } /// Restores a previous controller snapshot. pub fn restore(&self, snapshot: L1AnchorPhase) { - *self.state.borrow_mut() = snapshot; + self.state.set(snapshot); } /// Returns the anchor used by reads in the current phase, if initialized. diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index 318804e00..a10ea2461 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -245,36 +245,119 @@ mod tests { use alloy_sol_types::SolCall; use tempo_precompiles::storage::StorageCtx; - fn encode_header(header: &TempoHeader) -> Bytes { - let mut encoded = Vec::new(); - header.encode(&mut encoded); - encoded.into() + struct TempoStateHarness { + ctx: TestContext, + controller: L1AnchorController, + precompile: DynPrecompile, } - fn initialize(ctx: &mut TestContext, header: &[u8]) -> eyre::Result<()> { - let mut storage = test_storage_provider(ctx, u64::MAX, false); + impl TempoStateHarness { + fn new(header: &TempoHeader) -> eyre::Result { + Self::with_reader(header, MockL1Reader::default()) + } - StorageCtx::enter(&mut storage, || TempoState::new().initialize(header))?; - Ok(()) + fn with_reader(header: &TempoHeader, reader: MockL1Reader) -> eyre::Result { + let mut ctx = test_context(); + let encoded = encode_header(header); + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || TempoState::new().initialize(&encoded))?; + drop(storage); + + let controller = L1AnchorController::default(); + let precompile = TempoState::create(reader, controller.clone(), &test_env(&ctx)); + Ok(Self { + ctx, + controller, + precompile, + }) + } + + fn call( + &mut self, + caller: Address, + calldata: impl Into, + is_static: bool, + ) -> PrecompileResult { + self.call_as( + caller, + calldata, + is_static, + TEMPO_STATE_ADDRESS, + TEMPO_STATE_ADDRESS, + ) + } + + fn call_as( + &mut self, + caller: Address, + calldata: impl Into, + is_static: bool, + target: Address, + bytecode_address: Address, + ) -> PrecompileResult { + let calldata = calldata.into(); + call_precompile( + &mut self.ctx, + &self.precompile, + caller, + &calldata, + u64::MAX, + is_static, + target, + bytecode_address, + ) + } + + fn finalize_raw( + &mut self, + caller: Address, + header: Bytes, + is_static: bool, + ) -> PrecompileResult { + self.call(caller, finalize_calldata(header), is_static) + } + + fn finalize( + &mut self, + caller: Address, + header: &TempoHeader, + is_static: bool, + ) -> PrecompileResult { + self.finalize_raw(caller, encode_header(header), is_static) + } + + fn assert_checkpoint( + &mut self, + expected_hash: B256, + expected_number: u64, + ) -> eyre::Result<()> { + let block_hash = self.call( + Address::ZERO, + TempoStateAbi::tempoBlockHashCall {}.abi_encode(), + true, + )?; + assert_eq!( + TempoStateAbi::tempoBlockHashCall::abi_decode_returns(&block_hash.bytes)?, + expected_hash + ); + + let block_number = self.call( + Address::ZERO, + TempoStateAbi::tempoBlockNumberCall {}.abi_encode(), + true, + )?; + assert_eq!( + TempoStateAbi::tempoBlockNumberCall::abi_decode_returns(&block_number.bytes)?, + expected_number + ); + Ok(()) + } } - fn call( - ctx: &mut TestContext, - precompile: &DynPrecompile, - caller: Address, - calldata: Bytes, - is_static: bool, - ) -> PrecompileResult { - call_precompile( - ctx, - precompile, - caller, - &calldata, - u64::MAX, - is_static, - TEMPO_STATE_ADDRESS, - TEMPO_STATE_ADDRESS, - ) + fn encode_header(header: &TempoHeader) -> Bytes { + let mut encoded = Vec::new(); + header.encode(&mut encoded); + encoded.into() } fn child_header(parent_hash: B256, number: u64) -> TempoHeader { @@ -313,117 +396,50 @@ mod tests { .into() } - fn assert_checkpoint( - ctx: &mut TestContext, - precompile: &DynPrecompile, - expected_hash: B256, - expected_number: u64, - ) -> eyre::Result<()> { - let block_hash = call( - ctx, - precompile, - Address::ZERO, - TempoStateAbi::tempoBlockHashCall {}.abi_encode().into(), - true, - )?; - assert_eq!( - TempoStateAbi::tempoBlockHashCall::abi_decode_returns(&block_hash.bytes)?, - expected_hash - ); - - let block_number = call( - ctx, - precompile, - Address::ZERO, - TempoStateAbi::tempoBlockNumberCall {}.abi_encode().into(), - true, - )?; - assert_eq!( - TempoStateAbi::tempoBlockNumberCall::abi_decode_returns(&block_number.bytes)?, - expected_number - ); - Ok(()) + fn read_slot_calldata() -> Bytes { + TempoStateAbi::readTempoStorageSlotCall { + account: Address::repeat_byte(0x44), + slot: B256::ZERO, + } + .abi_encode() + .into() } #[test] fn explicit_read_before_finalize_blocks_advancement() -> eyre::Result<()> { let genesis = child_header(B256::repeat_byte(0xaa), 10); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - let controller = L1AnchorController::default(); - let precompile = TempoState::create( + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::with_reader( + &genesis, MockL1Reader::returning(B256::repeat_byte(0x11)), - controller.clone(), - &test_env(&ctx), - ); - - let read = TempoStateAbi::readTempoStorageSlotCall { - account: Address::repeat_byte(0x44), - slot: B256::ZERO, - }; - call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - read.abi_encode().into(), - true, )?; + + harness.call(ZONE_INBOX_ADDRESS, read_slot_calldata(), true)?; assert_eq!( - controller.phase(), + harness.controller.phase(), L1AnchorPhase::Parent { anchor: 10, has_read_l1: true } ); - let child = encode_header(&child_header(genesis_hash, 11)); - assert!( - call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child), - false, - ) - .is_err() - ); + let child = child_header(genesis_hash, 11); + assert!(harness.finalize(ZONE_INBOX_ADDRESS, &child, false).is_err()); Ok(()) } #[test] fn explicit_read_after_finalize_uses_advanced_anchor() -> eyre::Result<()> { let genesis = child_header(B256::repeat_byte(0xaa), 10); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - let controller = L1AnchorController::default(); + let genesis_hash = keccak256(encode_header(&genesis)); let reader = MockL1Reader::returning(B256::repeat_byte(0x11)); - let precompile = TempoState::create(reader.clone(), controller.clone(), &test_env(&ctx)); - - let child = encode_header(&child_header(genesis_hash, 11)); - call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child), - false, - )?; - let read = TempoStateAbi::readTempoStorageSlotCall { - account: Address::repeat_byte(0x44), - slot: B256::ZERO, - }; - call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - read.abi_encode().into(), - true, - )?; + let mut harness = TempoStateHarness::with_reader(&genesis, reader.clone())?; + + let child = child_header(genesis_hash, 11); + harness.finalize(ZONE_INBOX_ADDRESS, &child, false)?; + harness.call(ZONE_INBOX_ADDRESS, read_slot_calldata(), true)?; assert_eq!( - controller.phase(), + harness.controller.phase(), L1AnchorPhase::Advanced { from: 10, to: 11 } ); assert!( @@ -438,261 +454,131 @@ mod tests { #[test] 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(); - initialize(&mut ctx, &header_rlp)?; - - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - assert_checkpoint(&mut ctx, &precompile, keccak256(&header_rlp), 42)?; - - Ok(()) + let mut harness = TempoStateHarness::new(&header)?; + harness.assert_checkpoint(keccak256(encode_header(&header)), 42) } #[test] fn finalize_tempo_updates_checkpoint() -> eyre::Result<()> { let genesis = TempoHeader::default(); - let genesis_rlp = encode_header(&genesis); - let genesis_hash = keccak256(&genesis_rlp); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; let child = child_header(genesis_hash, 1); - let child_rlp = encode_header(&child); - let child_hash = keccak256(&child_rlp); - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - false, - )?; + let output = harness.finalize(ZONE_INBOX_ADDRESS, &child, false)?; assert!(output.is_success()); - assert_checkpoint(&mut ctx, &precompile, child_hash, 1)?; - - Ok(()) + harness.assert_checkpoint(keccak256(encode_header(&child)), 1) } #[test] 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); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - Address::ZERO, - finalize_calldata(child_rlp), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(genesis_hash, 1); - Ok(()) + assert!(harness.finalize(Address::ZERO, &child, false)?.is_revert()); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] fn delegate_call_reverts() -> eyre::Result<()> { - let genesis_rlp = encode_header(&TempoHeader::default()); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let calldata = TempoStateAbi::tempoBlockHashCall {}.abi_encode(); - let output = call_precompile( - &mut ctx, - &precompile, + let mut harness = TempoStateHarness::new(&TempoHeader::default())?; + let output = harness.call_as( Address::ZERO, - &calldata, - u64::MAX, + TempoStateAbi::tempoBlockHashCall {}.abi_encode(), true, TEMPO_STATE_ADDRESS, address!("0x000000000000000000000000000000000000dEaD"), )?; assert!(output.is_revert()); - // Direct-call-only precompiles reject delegate calls before metering or storage setup. assert_eq!(output.gas_used, 0); - Ok(()) } #[test] 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); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - true, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(genesis_hash, 1); - Ok(()) + assert!( + harness + .finalize(ZONE_INBOX_ADDRESS, &child, true)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] 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); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(Bytes::from(vec![0xff])), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; - Ok(()) + assert!( + harness + .finalize_raw(ZONE_INBOX_ADDRESS, Bytes::from(vec![0xff]), false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] 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); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 1)); - let mut malformed = child_rlp.to_vec(); + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let mut malformed = encode_header(&child_header(genesis_hash, 1)).to_vec(); malformed.push(0); - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(Bytes::from(malformed)), - false, - )?; - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; - - Ok(()) + assert!( + harness + .finalize_raw(ZONE_INBOX_ADDRESS, Bytes::from(malformed), false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] 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); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(B256::ZERO, 1)); - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(B256::ZERO, 1); - Ok(()) + assert!( + harness + .finalize(ZONE_INBOX_ADDRESS, &child, false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] 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); - let mut ctx = test_context(); - initialize(&mut ctx, &genesis_rlp)?; - - let child_rlp = encode_header(&child_header(genesis_hash, 2)); - let precompile = TempoState::create( - MockL1Reader::default(), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, - ZONE_INBOX_ADDRESS, - finalize_calldata(child_rlp), - false, - )?; - - assert!(output.is_revert()); - assert_checkpoint(&mut ctx, &precompile, genesis_hash, genesis.number())?; + let genesis_hash = keccak256(encode_header(&genesis)); + let mut harness = TempoStateHarness::new(&genesis)?; + let child = child_header(genesis_hash, 2); - Ok(()) + assert!( + harness + .finalize(ZONE_INBOX_ADDRESS, &child, false)? + .is_revert() + ); + harness.assert_checkpoint(genesis_hash, genesis.number()) } #[test] 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)?; - let expected = b256!("0xabababababababababababababababababababababababababababababababab"); - let precompile = TempoState::create( + let mut harness = TempoStateHarness::with_reader( + &TempoHeader::default(), MockL1Reader::returning(expected), - L1AnchorController::default(), - &test_env(&ctx), - ); + )?; let calldata: Bytes = TempoStateAbi::readTempoStorageSlotCall { account: address!("0x0000000000000000000000000000000000009999"), slot: B256::ZERO, @@ -700,46 +586,37 @@ mod tests { .abi_encode() .into(); - let outsider = call( - &mut ctx, - &precompile, - address!("0x000000000000000000000000000000000000aaaa"), - calldata.clone(), - true, - )?; - assert!(outsider.is_revert()); - - let system = call(&mut ctx, &precompile, ZONE_CONFIG_ADDRESS, calldata, true)?; + assert!( + harness + .call( + address!("0x000000000000000000000000000000000000aaaa"), + calldata.clone(), + true, + )? + .is_revert() + ); + let system = harness.call(ZONE_CONFIG_ADDRESS, calldata, true)?; assert_eq!( TempoStateAbi::readTempoStorageSlotCall::abi_decode_returns(&system.bytes)?, expected ); - Ok(()) } #[test] 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)?; - let expected = b256!("0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"); - let precompile = TempoState::create( + let mut harness = TempoStateHarness::with_reader( + &TempoHeader::default(), MockL1Reader::returning(expected), - L1AnchorController::default(), - &test_env(&ctx), - ); - let output = call( - &mut ctx, - &precompile, + )?; + let output = harness.call( ZONE_OUTBOX_ADDRESS, TempoStateAbi::readTempoStorageSlotsCall { account: address!("0x0000000000000000000000000000000000009999"), slots: vec![B256::ZERO, B256::with_last_byte(1)], } - .abi_encode() - .into(), + .abi_encode(), true, )?; @@ -747,7 +624,6 @@ mod tests { TempoStateAbi::readTempoStorageSlotsCall::abi_decode_returns(&output.bytes)?, vec![expected, expected] ); - Ok(()) } } diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index 6fa6343f4..ebefdf672 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}; use alloy_primitives::Address; use alloy_sol_types::{SolCall, SolError}; 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; @@ -35,18 +33,12 @@ alloy_sol_types::sol! { pub(crate) struct Tip403Rules; impl CallRules for Tip403Rules { - fn is_delegate_call_allowed(&self) -> bool { - false - } - - fn check(&self, call: ZoneCall<'_>) -> CallCheck { - if call - .selector() - .is_some_and(|selector| TIP403_MUTATING_SELECTORS.contains(&selector)) + fn admit(&self, data: &[u8], _caller: Address) -> CallCheck { + if TIP403_MUTATING_SELECTORS + .iter() + .any(|selector| data.starts_with(selector)) { - return CallCheck::Return(Ok( - StorageCtx::default().revert_output(ReadOnlyRegistry {}.abi_encode().into()) - )); + return CallCheck::Revert(ReadOnlyRegistry {}.abi_encode().into()); } CallCheck::Continue diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index 491427de6..b4cbf805b 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -12,15 +12,14 @@ use alloc::sync::Arc; use alloy_primitives::Address; use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::PrecompileOutput; use tempo_precompiles::{ - storage::StorageCtx, + dispatch::selector_from_calldata, tip20::{IRolesAuth, ITIP20}, }; use tempo_zone_contracts::Unauthorized; use zone_primitives::constants::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS}; -use crate::execution::{CallCheck, CallRules, ZoneCall}; +use crate::execution::{CallCheck, CallRules}; /// Fixed gas charged for TIP20 transfer and approval selectors on the zone. pub const TIP20_FIXED_TRANSFER_GAS: u64 = 100_000; @@ -33,15 +32,10 @@ pub(crate) const TIP20_FIXED_GAS_SELECTORS: &[[u8; 4]] = &[ ITIP20::approveCall::SELECTOR, ]; -type CallCheckResult = Result<(), PrecompileOutput>; - -fn decode_and_check( - args: &[u8], - check: impl FnOnce(C) -> CallCheckResult, -) -> CallCheckResult { +fn decode_and_check(args: &[u8], check: impl FnOnce(C) -> CallCheck) -> CallCheck { match C::abi_decode_raw_validate(args) { Ok(decoded) => check(decoded), - Err(_) => Ok(()), + Err(_) => CallCheck::Continue, } } @@ -68,10 +62,6 @@ impl TIP20Rules { } impl CallRules for TIP20Rules { - fn is_delegate_call_allowed(&self) -> bool { - false - } - fn fixed_gas(&self, selector: Option<[u8; 4]>) -> Option { selector .is_some_and(|selector| TIP20_FIXED_GAS_SELECTORS.contains(&selector)) @@ -79,91 +69,84 @@ impl CallRules for TIP20Rules { } /// Apply zone privacy and bridge-path checks before upstream execution. - fn check(&self, call: ZoneCall<'_>) -> CallCheck { - let Some(selector) = call.selector() else { + fn admit(&self, data: &[u8], caller: Address) -> CallCheck { + let Some(selector) = selector_from_calldata(data) else { return CallCheck::Continue; }; - let args = &call.data[4..]; + let args = &data[4..]; - let result = match selector { + match selector { ITIP20::mintCall::SELECTOR | ITIP20::mintWithMemoCall::SELECTOR => { - self.check_mint_auth(call.caller) + self.check_mint_auth(caller) } ITIP20::burnCall::SELECTOR | ITIP20::burnWithMemoCall::SELECTOR => { - self.check_burn_auth(call.caller) + self.check_burn_auth(caller) } ITIP20::balanceOfCall::SELECTOR => { decode_and_check::(args, |decoded| { - self.check_balance_read(decoded.account, call.caller) + self.check_balance_read(decoded.account, caller) }) } ITIP20::allowanceCall::SELECTOR => { decode_and_check::(args, |decoded| { - self.check_allowance_read(decoded.owner, decoded.spender, call.caller) + self.check_allowance_read(decoded.owner, decoded.spender, caller) }) } IRolesAuth::hasRoleCall::SELECTOR => { decode_and_check::(args, |decoded| { - self.check_balance_read(decoded.account, call.caller) + self.check_balance_read(decoded.account, caller) }) } - _ => Ok(()), - }; - - match result { - Ok(()) => CallCheck::Continue, - Err(output) => CallCheck::Return(Ok(output)), + _ => CallCheck::Continue, } } } -fn unauthorized_output() -> PrecompileOutput { - StorageCtx::default().revert_output(Unauthorized {}.abi_encode().into()) +fn unauthorized() -> CallCheck { + CallCheck::Revert(Unauthorized {}.abi_encode().into()) } impl TIP20Rules { - fn check_balance_read(&self, owner: Address, caller: Address) -> CallCheckResult { + fn check_balance_read(&self, owner: Address, caller: Address) -> CallCheck { if caller == owner { - return Ok(()); + return CallCheck::Continue; } self.check_sequencer(caller) } - fn check_allowance_read( - &self, - owner: Address, - spender: Address, - caller: Address, - ) -> CallCheckResult { + fn check_allowance_read(&self, owner: Address, spender: Address, caller: Address) -> CallCheck { if caller == spender { - return Ok(()); + return CallCheck::Continue; } self.check_balance_read(owner, caller) } - fn check_mint_auth(&self, caller: Address) -> CallCheckResult { - if caller != ZONE_INBOX_ADDRESS { - return Err(unauthorized_output()); + fn check_mint_auth(&self, caller: Address) -> CallCheck { + if caller == ZONE_INBOX_ADDRESS { + CallCheck::Continue + } else { + unauthorized() } - Ok(()) } - fn check_burn_auth(&self, caller: Address) -> CallCheckResult { - if caller != ZONE_OUTBOX_ADDRESS { - return Err(unauthorized_output()); + fn check_burn_auth(&self, caller: Address) -> CallCheck { + if caller == ZONE_OUTBOX_ADDRESS { + CallCheck::Continue + } else { + unauthorized() } - Ok(()) } - fn check_sequencer(&self, caller: Address) -> CallCheckResult { + fn check_sequencer(&self, caller: Address) -> CallCheck { if self .sequencer .latest_sequencer() - .is_none_or(|sequencer| caller != sequencer) + .is_some_and(|sequencer| caller == sequencer) { - return Err(unauthorized_output()); + CallCheck::Continue + } else { + unauthorized() } - Ok(()) } } From 604908a9a40e65d56cb89e05ecc4417a94f7ab27 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 13:53:00 +0200 Subject: [PATCH 19/22] test: simplify --- crates/precompiles/src/tip403_proxy/mod.rs | 30 +- crates/precompiles/src/ztip20/mod.rs | 447 +++++++-------------- 2 files changed, 153 insertions(+), 324 deletions(-) diff --git a/crates/precompiles/src/tip403_proxy/mod.rs b/crates/precompiles/src/tip403_proxy/mod.rs index ebefdf672..663ba0230 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -119,17 +119,19 @@ mod tests { } #[test] - fn mutations_revert_while_receive_policy_reads_use_upstream_dispatch() -> eyre::Result<()> { - let mut harness = RegistryHarness::new(); - let mutation = ITIP403Registry::createPolicyCall { - admin: CALLER, - policyType: ITIP403Registry::PolicyType::BLACKLIST, + fn mutating_selectors_are_rejected_by_admission() { + let rules = Tip403Rules; + for selector in TIP403_MUTATING_SELECTORS { + assert!(matches!( + rules.admit(selector, CALLER), + CallCheck::Revert(data) if data == ReadOnlyRegistry {}.abi_encode() + )); } - .abi_encode(); - let output = harness.call(&mutation, u64::MAX)?; - assert!(output.is_revert()); - assert_eq!(output.bytes, Bytes::from(ReadOnlyRegistry {}.abi_encode())); + } + #[test] + fn receive_policy_reads_use_upstream_dispatch() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(); let receive = harness.call( &ITIP403Registry::receivePolicyCall { account: CALLER }.abi_encode(), u64::MAX, @@ -155,16 +157,6 @@ mod tests { 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(()) } diff --git a/crates/precompiles/src/ztip20/mod.rs b/crates/precompiles/src/ztip20/mod.rs index b4cbf805b..ddc9af5e5 100644 --- a/crates/precompiles/src/ztip20/mod.rs +++ b/crates/precompiles/src/ztip20/mod.rs @@ -155,13 +155,14 @@ mod tests { use super::*; use alloy::primitives::{Address, Bytes, U256, address}; use alloy_evm::precompiles::DynPrecompile; - use alloy_sol_types::{SolCall, SolError, SolInterface}; - use revm::precompile::{PrecompileHalt, PrecompileResult}; + use alloy_sol_types::{SolCall, SolInterface}; + use revm::precompile::PrecompileResult; use tempo_contracts::precompiles::TIP20Error; use tempo_precompiles::{ PATH_USD_ADDRESS, storage::StorageCtx, - tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, RolesAuthError, TIP20Token}, + test_util::TIP20Setup, + tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, TIP20Token}, }; use tempo_zone_contracts::Unauthorized; @@ -180,6 +181,26 @@ mod tests { } } + fn rules(sequencer: Address) -> TIP20Rules { + TIP20Rules::new(Arc::new(MockSequencer { + address: Some(sequencer), + })) + } + + fn assert_allowed(rules: &TIP20Rules, call: impl SolCall, caller: Address) { + assert!(matches!( + rules.admit(&call.abi_encode(), caller), + CallCheck::Continue + )); + } + + fn assert_unauthorized(rules: &TIP20Rules, call: impl SolCall, caller: Address) { + assert!(matches!( + rules.admit(&call.abi_encode(), caller), + CallCheck::Revert(data) if data == Unauthorized {}.abi_encode() + )); + } + struct PrecompileHarness { ctx: TestContext, token: Address, @@ -209,41 +230,15 @@ mod tests { 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), - }, - )?; + 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(()) })?; } @@ -305,66 +300,31 @@ mod tests { } #[test] - fn balance_of_enforces_account_or_sequencer_access() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new()?; - let calldata: Bytes = ITIP20::balanceOfCall { - account: harness.alice, - } - .abi_encode() - .into(); - - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::balanceOfCall::abi_decode_returns(&owner.bytes)?, - U256::from(1_000_000u64) - ); - - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::balanceOfCall::abi_decode_returns(&sequencer.bytes)?, - U256::from(1_000_000u64) - ); - - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) - } - - #[test] - fn allowance_enforces_owner_spender_or_sequencer_access() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new()?; - let calldata: Bytes = ITIP20::allowanceCall { - owner: harness.alice, - spender: harness.spender, + fn read_privacy_rules_allow_owner_spender_and_sequencer() { + let owner = Address::repeat_byte(0x11); + let spender = Address::repeat_byte(0x22); + let sequencer = Address::repeat_byte(0x33); + let outsider = Address::repeat_byte(0x44); + let rules = rules(sequencer); + + let balance = ITIP20::balanceOfCall { account: owner }; + assert_allowed(&rules, balance.clone(), owner); + assert_allowed(&rules, balance.clone(), sequencer); + assert_unauthorized(&rules, balance, outsider); + + let allowance = ITIP20::allowanceCall { owner, spender }; + for caller in [owner, spender, sequencer] { + assert_allowed(&rules, allowance.clone(), caller); } - .abi_encode() - .into(); - - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::allowanceCall::abi_decode_returns(&owner.bytes)?, - U256::from(300_000u64) - ); - - let spender = harness.call(harness.spender, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::allowanceCall::abi_decode_returns(&spender.bytes)?, - U256::from(300_000u64) - ); + assert_unauthorized(&rules, allowance, outsider); - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert_eq!( - ITIP20::allowanceCall::abi_decode_returns(&sequencer.bytes)?, - U256::from(300_000u64) - ); - - let outsider = harness.call(harness.bob, calldata, 100_000, true)?; - assert!(outsider.is_revert()); - assert_eq!(outsider.bytes, Bytes::from(Unauthorized {}.abi_encode())); - - Ok(()) + let role = IRolesAuth::hasRoleCall { + account: owner, + role: *ISSUER_ROLE, + }; + assert_allowed(&rules, role.clone(), owner); + assert_allowed(&rules, role.clone(), sequencer); + assert_unauthorized(&rules, role, outsider); } #[test] @@ -468,8 +428,22 @@ mod tests { } #[test] - fn bridge_auth_rejects_crossed_system_calls_and_keeps_allowed_paths() -> eyre::Result<()> { + fn bridge_auth_rules_and_allowed_paths() -> eyre::Result<()> { let mut harness = PrecompileHarness::new()?; + let rules = rules(harness.sequencer); + assert_unauthorized( + &rules, + ITIP20::mintCall { + to: harness.bob, + amount: U256::ONE, + }, + ZONE_OUTBOX_ADDRESS, + ); + assert_unauthorized( + &rules, + ITIP20::burnCall { amount: U256::ONE }, + ZONE_INBOX_ADDRESS, + ); let inbox_mint = harness.call( ZONE_INBOX_ADDRESS, @@ -498,199 +472,88 @@ mod tests { assert!(outbox_burn.is_success()); assert_eq!(harness.balance_of(ZONE_OUTBOX_ADDRESS)?, U256::ZERO); - let crossed_mint = harness.call( - ZONE_OUTBOX_ADDRESS, - ITIP20::mintCall { - to: harness.bob, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - 100_000, - false, - )?; - assert!(crossed_mint.is_revert()); - assert_eq!( - crossed_mint.bytes, - Bytes::from(RolesAuthError::unauthorized().selector().to_vec()) - ); - - let crossed_burn = harness.call( - ZONE_INBOX_ADDRESS, - ITIP20::burnCall { - amount: U256::from(1u64), - } - .abi_encode() - .into(), - 100_000, - false, - )?; - assert!(crossed_burn.is_revert()); - assert_eq!( - crossed_burn.bytes, - Bytes::from(RolesAuthError::unauthorized().selector().to_vec()) - ); - Ok(()) } #[test] fn fixed_gas_selectors_charge_exactly_one_hundred_thousand_gas() -> eyre::Result<()> { let mut harness = PrecompileHarness::new()?; - - let approve = harness.call( - harness.alice, - ITIP20::approveCall { - spender: harness.spender, - amount: U256::from(111_111u64), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(approve.gas_used, TIP20_FIXED_TRANSFER_GAS); - assert_eq!(approve.state_gas_used, 0); - - let approve_update = harness.call( - harness.alice, - ITIP20::approveCall { - spender: harness.spender, - amount: U256::from(222_222u64), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(approve_update.gas_used, TIP20_FIXED_TRANSFER_GAS); - assert_eq!(approve_update.state_gas_used, 0); - - let transfer_new = harness.call( - harness.alice, - ITIP20::transferCall { - to: harness.bob, - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_new.gas_used, TIP20_FIXED_TRANSFER_GAS); - assert_eq!(transfer_new.state_gas_used, 0); - - let transfer_existing = harness.call( - harness.alice, - ITIP20::transferCall { - to: harness.bob, - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_existing.gas_used, TIP20_FIXED_TRANSFER_GAS); - assert_eq!(transfer_existing.state_gas_used, 0); - - let transfer_with_memo = harness.call( - harness.alice, - ITIP20::transferWithMemoCall { - to: harness.bob, - amount: U256::from(10_000u64), - memo: Default::default(), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - 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( - harness.spender, - ITIP20::transferFromCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(10_000u64), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - 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( - harness.spender, - ITIP20::transferFromWithMemoCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(10_000u64), - memo: Default::default(), - } - .abi_encode() - .into(), - TIP20_FIXED_TRANSFER_GAS, - false, - )?; - assert_eq!(transfer_from_with_memo.gas_used, TIP20_FIXED_TRANSFER_GAS); - assert_eq!(transfer_from_with_memo.state_gas_used, 0); - + let calls: Vec<(Address, ITIP20::ITIP20Calls)> = vec![ + ( + harness.alice, + ITIP20::ITIP20Calls::approve(ITIP20::approveCall { + spender: harness.spender, + amount: U256::from(111_111u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::approve(ITIP20::approveCall { + spender: harness.spender, + amount: U256::from(222_222u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::transfer(ITIP20::transferCall { + to: harness.bob, + amount: U256::from(10_000u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::transfer(ITIP20::transferCall { + to: harness.bob, + amount: U256::from(10_000u64), + }), + ), + ( + harness.alice, + ITIP20::ITIP20Calls::transferWithMemo(ITIP20::transferWithMemoCall { + to: harness.bob, + amount: U256::from(10_000u64), + memo: Default::default(), + }), + ), + ( + harness.spender, + ITIP20::ITIP20Calls::transferFrom(ITIP20::transferFromCall { + from: harness.alice, + to: harness.bob, + amount: U256::from(10_000u64), + }), + ), + ( + harness.spender, + ITIP20::ITIP20Calls::transferFromWithMemo(ITIP20::transferFromWithMemoCall { + from: harness.alice, + to: harness.bob, + amount: U256::from(10_000u64), + memo: Default::default(), + }), + ), + ]; + + for (caller, call) in calls { + let calldata = call.abi_encode().into(); + let output = harness.call(caller, calldata, TIP20_FIXED_TRANSFER_GAS, false)?; + assert_eq!(output.gas_used, TIP20_FIXED_TRANSFER_GAS); + assert_eq!(output.state_gas_used, 0); + } Ok(()) } #[test] - fn fixed_gas_selectors_fail_out_of_gas_below_threshold() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new()?; - - for calldata in [ - ITIP20::transferCall { - to: harness.bob, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - ITIP20::transferFromCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - ITIP20::transferWithMemoCall { - to: harness.bob, - amount: U256::from(1u64), - memo: Default::default(), - } - .abi_encode() - .into(), - ITIP20::transferFromWithMemoCall { - from: harness.alice, - to: harness.bob, - amount: U256::from(1u64), - memo: Default::default(), - } - .abi_encode() - .into(), - ITIP20::approveCall { - spender: harness.spender, - amount: U256::from(1u64), - } - .abi_encode() - .into(), - ] { - let output = harness - .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)); + fn fixed_gas_selector_mapping_is_complete() { + let rules = rules(Address::ZERO); + for selector in TIP20_FIXED_GAS_SELECTORS { + assert_eq!( + rules.fixed_gas(Some(*selector)), + Some(TIP20_FIXED_TRANSFER_GAS) + ); } - - Ok(()) + assert_eq!(rules.fixed_gas(Some([0xff; 4])), None); + assert_eq!(rules.fixed_gas(None), None); } #[test] @@ -730,30 +593,4 @@ mod tests { Ok(()) } - - #[test] - fn has_role_enforces_account_or_sequencer_access() -> eyre::Result<()> { - let mut harness = PrecompileHarness::new()?; - let calldata: Bytes = IRolesAuth::hasRoleCall { - account: harness.alice, - role: *ISSUER_ROLE, - } - .abi_encode() - .into(); - - // Owner can query their own roles - let owner = harness.call(harness.alice, calldata.clone(), 100_000, true)?; - assert!(owner.is_success()); - - // Sequencer can query anyone's roles - let sequencer = harness.call(harness.sequencer, calldata.clone(), 100_000, true)?; - assert!(sequencer.is_success()); - - // 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())); - - Ok(()) - } } From b62c49fcfdd01c23a0ae4beac7e7f845b3e2371e Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 17:04:09 +0200 Subject: [PATCH 20/22] refactor(evm): make L1 anchor state tx-local --- crates/evm/src/database.rs | 29 ++++++++++-- crates/evm/src/executor.rs | 26 +++-------- crates/evm/src/zone_evm/mod.rs | 40 +++++++++-------- crates/precompiles/src/storage.rs | 64 +++++---------------------- crates/precompiles/src/tempo_state.rs | 5 +-- 5 files changed, 66 insertions(+), 98 deletions(-) diff --git a/crates/evm/src/database.rs b/crates/evm/src/database.rs index 6499d03d0..3033c930e 100644 --- a/crates/evm/src/database.rs +++ b/crates/evm/src/database.rs @@ -116,6 +116,12 @@ impl AnchoredZoneDb { pub const fn controller(&self) -> &L1AnchorController { &self.controller } + + /// Clears bookkeeping that is valid only for the current transaction attempt. + pub(crate) fn reset_transaction_state(&mut self) { + self.controller.reset(); + self.packed_policy_slots.clear(); + } } impl AnchoredZoneDb { @@ -245,6 +251,8 @@ mod tests { database::{CacheDB, EmptyDB}, state::EvmStorageSlot, }; + use tempo_precompiles::{PATH_USD_ADDRESS, tip20::tip20_slots}; + fn test_db(anchor: u64) -> CacheDB { let mut db = CacheDB::new(EmptyDB::default()); db.insert_account_storage( @@ -293,9 +301,7 @@ mod tests { #[test] fn packed_policy_field_is_removed_from_canonical_transition() { - let anchor = 42; - let token = tempo_precompiles::PATH_USD_ADDRESS; - let slot = tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID; + let (anchor, token, slot) = (42, PATH_USD_ADDRESS, tip20_slots::TRANSFER_POLICY_ID); let offset = tempo_precompiles::tip20::tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8; let local = storage::merge_transfer_policy_id(U256::from(10), U256::from(2) << offset); let l1_value = U256::from(99) << offset; @@ -327,6 +333,23 @@ mod tests { assert_eq!(sanitized & U256::from(u64::MAX), U256::from(11)); } + #[test] + fn transaction_reset_clears_anchor_and_packed_slot_observations() { + let (anchor, token, slot) = (42, PATH_USD_ADDRESS, tip20_slots::TRANSFER_POLICY_ID); + let l1 = TestL1::default(); + l1.insert(token, slot, anchor, U256::from(7)); + let mut db = AnchoredZoneDb::new(test_db(anchor), l1); + + db.storage(token, slot).unwrap(); + assert_eq!(db.controller().current(), Some(anchor)); + assert_eq!(db.packed_policy_slots.len(), 1); + + db.reset_transaction_state(); + + assert_eq!(db.controller().current(), None); + assert!(db.packed_policy_slots.is_empty()); + } + #[test] fn ordinary_storage_is_local_and_inner_database_is_recoverable() { let address = Address::repeat_byte(0x11); diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index 258741a7a..4a852d075 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -7,10 +7,7 @@ use alloy_consensus::transaction::TxHashRef; use alloy_evm::{ Database, Evm, RecoveredTx, - block::{ - BlockExecutionError, BlockExecutionResult, BlockExecutor, CommitChanges, ExecutableTx, - GasOutput, - }, + block::{BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, GasOutput}, eth::{EthBlockExecutor, EthTxResult}, }; use reth_evm::block::StateDB; @@ -111,23 +108,12 @@ where } let _tx_hash_guard = tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); - self.inner - .execute_transaction_without_commit((tx_env, recovered)) - } + let result = self + .inner + .execute_transaction_without_commit((tx_env, recovered)); - /// Restores execution-local L1 anchor state when a simulated transaction is not committed. - fn execute_transaction_with_commit_condition( - &mut self, - tx: impl ExecutableTx, - f: impl FnOnce(&Self::Result) -> CommitChanges, - ) -> Result, BlockExecutionError> { - let snapshot = self.evm().anchor_controller().phase(); - let output = self.execute_transaction_without_commit(tx)?; - if !f(&output).should_commit() { - self.evm().anchor_controller().restore(snapshot); - return Ok(None); - } - Ok(Some(self.commit_transaction(output))) + self.evm_mut().reset_transaction_state(); + result } fn commit_transaction(&mut self, output: Self::Result) -> GasOutput { diff --git a/crates/evm/src/zone_evm/mod.rs b/crates/evm/src/zone_evm/mod.rs index a3e4b1dec..5d280f0e8 100644 --- a/crates/evm/src/zone_evm/mod.rs +++ b/crates/evm/src/zone_evm/mod.rs @@ -15,7 +15,7 @@ use revm::context::{ use tempo_evm::{TempoBlockEnv, TempoHaltReason, evm::TempoEvm}; use tempo_revm::{TempoInvalidTransaction, TempoTxEnv}; use zone_l1::state::L1StateProvider; -use zone_precompiles::{L1AnchorController, L1StorageReader}; +use zone_precompiles::L1StorageReader; use zone_primitives::constants::CONTRACT_DEPLOYER_ALLOWLIST; type TempoResult = ResultAndState; @@ -48,9 +48,13 @@ impl ZoneEvm { self.inner.ctx_mut() } - /// Returns the execution-local anchor controller. - pub fn anchor_controller(&self) -> &L1AnchorController { - self.inner.ctx().journaled_state.database.controller() + /// Clears database-adapter bookkeeping left by the current transaction attempt. + pub(crate) fn reset_transaction_state(&mut self) { + self.inner + .ctx_mut() + .journaled_state + .database + .reset_transaction_state(); } } @@ -66,23 +70,21 @@ where &mut TempoEvm, I>, ) -> Result>, ) -> Result> { - let snapshot = self.anchor_controller().phase(); - let mut result = match execute(&mut self.inner) { - Ok(result) => result, - Err(error) => { - self.anchor_controller().restore(snapshot); - return Err(map_adapter_error(error)); + let result = match execute(&mut self.inner) { + Ok(mut result) => { + if result.result.is_success() + && let Err(error) = self.inner.db().sanitize_state(&mut result.state) + { + Err(error.into_evm_error()) + } else { + Ok(result) + } } + Err(error) => Err(map_adapter_error(error)), }; - if !result.result.is_success() { - self.anchor_controller().restore(snapshot); - return Ok(result); - } - if let Err(error) = self.inner.db().sanitize_state(&mut result.state) { - self.anchor_controller().restore(snapshot); - return Err(error.into_evm_error()); - } - Ok(result) + + self.reset_transaction_state(); + result } } diff --git a/crates/precompiles/src/storage.rs b/crates/precompiles/src/storage.rs index 33632a4ce..6be4245ce 100644 --- a/crates/precompiles/src/storage.rs +++ b/crates/precompiles/src/storage.rs @@ -48,12 +48,10 @@ pub enum L1AnchorPhase { /// The selected Zone state's checkpoint has not been loaded yet. #[default] Uninitialized, - /// Execution is still at the parent anchor. + /// External Tempo state was read at the parent anchor. Parent { /// Parent Tempo block number. anchor: u64, - /// Whether external Tempo state was observed at this anchor. - has_read_l1: bool, }, /// The required system transaction advanced this execution to the child anchor. Advanced { @@ -69,15 +67,11 @@ impl L1AnchorPhase { pub const fn current(self) -> Option { match self { Self::Uninitialized => None, - Self::Parent { anchor, .. } => Some(anchor), + Self::Parent { anchor } => Some(anchor), Self::Advanced { to, .. } => Some(to), } } - const fn advanced(from: u64, to: u64) -> Self { - Self::Advanced { from, to } - } - fn apply(self, operation: L1AnchorOperation) -> Result { let invalid = || L1AnchorError { operation, @@ -86,28 +80,16 @@ impl L1AnchorPhase { match operation { L1AnchorOperation::Read { anchor: new } => match self { - Self::Uninitialized => Ok(Self::Parent { - anchor: new, - has_read_l1: true, - }), - Self::Parent { anchor: prev, .. } if prev == new => Ok(Self::Parent { - anchor: prev, - has_read_l1: true, - }), + Self::Uninitialized => Ok(Self::Parent { anchor: new }), + Self::Parent { anchor } if anchor == new => Ok(self), Self::Advanced { to, .. } if to == new => Ok(self), _ => Err(invalid()), }, L1AnchorOperation::Advance { from, to } => { - if from.checked_add(1) != Some(to) { - return Err(invalid()); - } - match self { - Self::Uninitialized => Ok(Self::advanced(from, to)), - Self::Parent { - anchor, - has_read_l1: false, - } if anchor == from => Ok(Self::advanced(from, to)), - _ => Err(invalid()), + if from.checked_add(1) == Some(to) && matches!(self, Self::Uninitialized) { + Ok(Self::Advanced { from, to }) + } else { + Err(invalid()) } } } @@ -140,9 +122,9 @@ impl L1AnchorController { self.state.get() } - /// Restores a previous controller snapshot. - pub fn restore(&self, snapshot: L1AnchorPhase) { - self.state.set(snapshot); + /// Resets the controller for the next transaction attempt. + pub fn reset(&self) { + self.state.set(L1AnchorPhase::Uninitialized); } /// Returns the anchor used by reads in the current phase, if initialized. @@ -201,10 +183,7 @@ mod tests { #[test] fn controller_rejects_reads_at_wrong_anchor() { let controller = L1AnchorController::default(); - controller.restore(L1AnchorPhase::Parent { - anchor: 10, - has_read_l1: false, - }); + controller.observe_read(10).unwrap(); assert!(controller.observe_read(11).is_err()); } @@ -214,23 +193,4 @@ mod tests { controller.begin_advance(10, 11).unwrap(); assert!(controller.begin_advance(11, 12).is_err()); } - - #[test] - fn controller_snapshot_restores_phase() { - let controller = L1AnchorController::default(); - controller.restore(L1AnchorPhase::Parent { - anchor: 10, - has_read_l1: false, - }); - let snapshot = controller.phase(); - controller.begin_advance(10, 11).unwrap(); - controller.restore(snapshot); - assert_eq!( - controller.phase(), - L1AnchorPhase::Parent { - anchor: 10, - has_read_l1: false - } - ); - } } diff --git a/crates/precompiles/src/tempo_state.rs b/crates/precompiles/src/tempo_state.rs index a10ea2461..833755b27 100644 --- a/crates/precompiles/src/tempo_state.rs +++ b/crates/precompiles/src/tempo_state.rs @@ -417,10 +417,7 @@ mod tests { harness.call(ZONE_INBOX_ADDRESS, read_slot_calldata(), true)?; assert_eq!( harness.controller.phase(), - L1AnchorPhase::Parent { - anchor: 10, - has_read_l1: true - } + L1AnchorPhase::Parent { anchor: 10 } ); let child = child_header(genesis_hash, 11); From 73dfe078f62f498ac23480794f846fa18b1040f3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 17:36:07 +0200 Subject: [PATCH 21/22] refactor(l1): revert cache changes --- crates/l1/src/state/cache.rs | 273 +++++--------------------- crates/l1/src/state/provider.rs | 10 +- crates/l1/src/subscriber.rs | 91 ++++----- crates/l1/src/tests.rs | 105 ++-------- crates/node/README.md | 23 +-- crates/node/src/node.rs | 81 +------- crates/node/tests/it/e2e.rs | 10 - crates/node/tests/it/tip403_policy.rs | 21 +- crates/node/tests/it/utils.rs | 73 +++---- 9 files changed, 152 insertions(+), 535 deletions(-) diff --git a/crates/l1/src/state/cache.rs b/crates/l1/src/state/cache.rs index 3594f9d19..bf62cf3e8 100644 --- a/crates/l1/src/state/cache.rs +++ b/crates/l1/src/state/cache.rs @@ -15,9 +15,8 @@ //! //! - The [`L1Subscriber`](crate::l1::L1Subscriber) writes storage diffs for tracked contracts //! as they arrive, tagged with the L1 tip block number. -//! - The [`L1StateProvider`](super::provider::L1StateProvider) writes eligible forward RPC -//! misses, tagged with the requested block number. Misses below the canonical Zone anchor -//! floor are returned without being inserted. +//! - The [`L1StateProvider`](super::provider::L1StateProvider) writes RPC-fetched values on +//! cache miss, tagged with the block number that was requested. //! //! ## Reorg handling //! @@ -29,7 +28,7 @@ use alloy_primitives::{Address, B256}; use derive_more::Deref; use parking_lot::RwLock; use std::{ - collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, sync::Arc, }; @@ -56,33 +55,15 @@ impl L1StateCache { /// i.e. the value that was current at that height. This allows the zone to read L1 state at /// the `tempoBlockNumber` it committed to, even if the L1 chain has since advanced. /// -/// Contract mutation barriers prevent values from crossing blocks where logs signal a possible -/// storage change without requiring slot-level decoding. Reorgs clear slot values and mutation -/// history atomically. -/// -/// The subscriber anchor and canonical floor track independent progress. The anchor is the latest -/// confirmed L1 block observed by the subscriber and may run ahead while blocks are queued. The -/// floor is the latest L1 height committed by canonical Zone execution. It advances monotonically -/// and drives lazy history compaction without scanning the cache on the import path. +/// The anchor tracks the latest L1 block the cache has received data for, used by the +/// [`L1Subscriber`](crate::l1::L1Subscriber) for reorg detection. #[derive(Debug, Default)] pub struct L1StateCacheInner { tracked_contracts: HashSet
, /// Per-slot value history: `(address, slot) → { block_number → value }`. /// The `BTreeMap` enables efficient range lookups for "latest value at or before block N". slots: HashMap<(Address, B256), BTreeMap>, - /// Per-address mutation barriers: `address → { block_number }`. - /// A slot value cached at block V may serve block N only when no barrier exists in `(V, N]`. - /// The subscriber records barriers for contracts whose logs imply possible storage changes. - invalidations: HashMap>, - /// Latest L1 block height committed by canonical Zone execution. - /// - /// New fallback values below this floor are not admitted. Histories are compacted lazily - /// against it when their slot/address is next mutated, so older entries may remain physically - /// present until touched. This floor may lag the subscriber [`anchor`](Self::anchor). - block_floor: u64, - /// Latest confirmed L1 block observed by the subscriber, used for reorg detection. - /// - /// The anchor may run ahead of [`block_floor`](Self::block_floor) while L1 blocks are queued. + /// Latest L1 block the cache has received data for, used for reorg detection. anchor: NumHash, } @@ -97,72 +78,28 @@ impl L1StateCacheInner { /// Returns the cached value for a storage slot at the given block number. /// - /// Returns `None` below the canonical floor because pruned mutation history cannot safely serve - /// historical inheritance. Otherwise returns the most recent valid value at or before - /// `block_number`. + /// Returns the most recent value at or before `block_number`, or `None` if no + /// value has been cached for this slot at or before the requested block. pub fn get(&self, address: Address, slot: B256, block_number: u64) -> Option { - if block_number < self.block_floor { - return None; - } - - let (value_block, value) = self - .slots - .get(&(address, slot))? - .range(..=block_number) - .next_back()?; - let latest_invalidation = self - .invalidations - .get(&address) - .and_then(|blocks| blocks.range(..=block_number).next_back()) - .copied(); - if latest_invalidation.is_some_and(|invalidated_at| invalidated_at > *value_block) { - return None; - } - Some(*value) + self.slots + .get(&(address, slot)) + .and_then(|history| history.range(..=block_number).next_back().map(|(_, v)| *v)) } - /// Invalidates inherited slot values for `address` starting at `block_number`. - /// - /// Values subsequently inserted at the same block are post-block state and remain valid. - pub fn invalidate(&mut self, address: Address, block_number: u64) { - let blocks = self.invalidations.entry(address).or_default(); - blocks.insert(block_number); - prune_invalidation_history(blocks, self.block_floor); + /// Sets a storage slot value in the cache at the given block number. + pub fn set(&mut self, address: Address, slot: B256, block_number: u64, value: B256) { + self.slots + .entry((address, slot)) + .or_default() + .insert(block_number, value); } - /// Sets a storage slot value in the forward cache at the given block number. - /// - /// Returns `false` without inserting when `block_number` is below the canonical Zone anchor - /// floor. Callers that materialize synthetic/test state should check the result so a rejected - /// seed cannot turn into an unexpected RPC fallback. - #[must_use = "check whether the cache write was admitted above the block floor"] - pub fn set(&mut self, address: Address, slot: B256, block_number: u64, value: B256) -> bool { - if block_number < self.block_floor { - return false; - } - - let history = self.slots.entry((address, slot)).or_default(); - history.insert(block_number, value); - prune_slot_history(history, self.block_floor); - true - } - - /// Advances the canonical Zone anchor floor monotonically in O(1). - pub fn advance_floor(&mut self, block_number: u64) { - self.block_floor = self.block_floor.max(block_number); - } - - /// Returns the latest L1 height consumed by canonical Zone execution. - pub fn block_floor(&self) -> u64 { - self.block_floor - } - - /// Updates the latest confirmed L1 block observed by the subscriber. + /// Updates the anchor block that this cache has received data up to. pub fn update_anchor(&mut self, anchor: NumHash) { self.anchor = anchor; } - /// Returns the latest confirmed L1 block observed by the subscriber. + /// Returns the current anchor block. pub fn anchor(&self) -> NumHash { self.anchor } @@ -172,38 +109,30 @@ impl L1StateCacheInner { self.tracked_contracts.contains(address) } - /// Clears subscriber-derived chain data while retaining tracked contracts and the canonical floor. + /// Clears all cached slot values but retains the tracked-contract set. pub fn clear(&mut self) { self.slots.clear(); - self.invalidations.clear(); self.anchor = NumHash::default(); } -} - -/// Retains the latest pre-floor entry as a baseline and every newer entry. -fn prune_slot_history(history: &mut BTreeMap, min_block: u64) { - if history.range(..min_block).nth(1).is_none() { - return; - } - - let mut retained = history.split_off(&min_block); - if let Some(baseline) = history.pop_last() { - retained.insert(baseline.0, baseline.1); - } - *history = retained; -} -/// Retains the latest pre-floor barrier and every newer barrier. -fn prune_invalidation_history(history: &mut BTreeSet, min_block: u64) { - if history.range(..min_block).nth(1).is_none() { - return; - } + /// Remove all entries with block numbers strictly less than `min_block`. + /// + /// Retains at most one entry per slot below the threshold — the latest one — so that + /// lookups at `min_block` still have a baseline value. + pub fn prune_before(&mut self, min_block: u64) { + for history in self.slots.values_mut() { + let keep_from = history.range(..min_block).next_back().map(|(k, _)| *k); + + if let Some(keep) = keep_from { + let to_remove: Vec = history.range(..keep).map(|(k, _)| *k).collect(); + for k in to_remove { + history.remove(&k); + } + } + } - let mut retained = history.split_off(&min_block); - if let Some(baseline) = history.pop_last() { - retained.insert(baseline); + self.slots.retain(|_, history| !history.is_empty()); } - *history = retained; } #[cfg(test)] @@ -225,27 +154,17 @@ mod tests { let slot = B256::with_last_byte(1); let value = B256::with_last_byte(0xff); - assert!(cache.set(PORTAL, slot, 10, value)); + cache.set(PORTAL, slot, 10, value); assert_eq!(cache.get(PORTAL, slot, 10), Some(value)); } - #[test] - fn set_reports_rejection_below_floor() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - let slot = B256::with_last_byte(1); - cache.advance_floor(10); - - assert!(!cache.set(PORTAL, slot, 9, B256::with_last_byte(0xff))); - assert_eq!(cache.get(PORTAL, slot, 10), None); - } - #[test] fn get_returns_latest_value_at_or_before_requested_block() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a))); - assert!(cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14))); + cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a)); + cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14)); assert_eq!( cache.get(PORTAL, slot, 10), @@ -270,17 +189,15 @@ mod tests { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0xff))); + cache.set(PORTAL, slot, 10, B256::with_last_byte(0xff)); assert_eq!(cache.get(PORTAL, slot, 9), None); } #[test] - fn clear_removes_chain_data_but_preserves_canonical_floor() { + fn clear_removes_slots_and_resets_anchor() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - assert!(cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1))); - cache.invalidate(PORTAL, 101); - cache.advance_floor(100); + cache.set(PORTAL, B256::ZERO, 100, B256::with_last_byte(1)); cache.update_anchor(NumHash { number: 100, hash: B256::with_last_byte(0xab), @@ -289,38 +206,9 @@ mod tests { cache.clear(); assert_eq!(cache.get(PORTAL, B256::ZERO, 100), None); - assert!(cache.invalidations.is_empty()); - assert_eq!(cache.block_floor, 100); assert_eq!(cache.anchor(), NumHash::default()); } - #[test] - fn invalidation_blocks_inheritance_until_slot_is_refetched() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - let slot = B256::with_last_byte(1); - let old = B256::with_last_byte(0x0a); - let new = B256::with_last_byte(0x14); - let other = Address::with_last_byte(0x43); - - assert!(cache.set(PORTAL, slot, 10, old)); - assert!(cache.set(other, slot, 10, old)); - cache.invalidate(PORTAL, 20); - cache.invalidate(PORTAL, 20); // Multiple logs in one block deduplicate. - - assert_eq!(cache.get(PORTAL, slot, 19), Some(old)); - assert_eq!(cache.get(PORTAL, slot, 20), None); - assert_eq!(cache.get(PORTAL, slot, 30), None); - assert_eq!(cache.get(other, slot, 30), Some(old)); - assert_eq!(cache.invalidations[&PORTAL].len(), 1); - - assert!(cache.set(PORTAL, slot, 20, new)); - assert_eq!(cache.get(PORTAL, slot, 20), Some(new)); - assert_eq!(cache.get(PORTAL, slot, 30), Some(new)); - - cache.invalidate(PORTAL, 31); - assert_eq!(cache.get(PORTAL, slot, 31), None); - } - #[test] fn anchor_defaults_to_zero() { let cache = L1StateCacheInner::new(HashSet::from([PORTAL])); @@ -353,8 +241,8 @@ mod tests { let addr_b = address!("0x0000000000000000000000000000000000004343"); let slot = B256::with_last_byte(1); - assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0xaa))); - assert!(cache.set(addr_b, slot, 10, B256::with_last_byte(0xbb))); + cache.set(PORTAL, slot, 10, B256::with_last_byte(0xaa)); + cache.set(addr_b, slot, 10, B256::with_last_byte(0xbb)); assert_eq!( cache.get(PORTAL, slot, 10), @@ -367,81 +255,28 @@ mod tests { } #[test] - fn advance_floor_is_lazy_and_touched_histories_keep_their_baselines() { + fn prune_keeps_baseline_entry() { let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); let slot = B256::with_last_byte(1); - assert!(cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05))); - assert!(cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a))); - assert!(cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14))); - cache.invalidate(PORTAL, 5); - cache.invalidate(PORTAL, 12); - cache.invalidate(PORTAL, 18); - - cache.advance_floor(15); - - // Advancing only moves the floor. - assert_eq!( - cache.slots[&(PORTAL, slot)] - .keys() - .copied() - .collect::>(), - vec![5, 10, 20] - ); - assert_eq!(cache.get(PORTAL, slot, 10), None); - assert_eq!(cache.get(PORTAL, slot, 15), None); - assert_eq!(cache.get(PORTAL, slot, 19), None); - assert_eq!( - cache.get(PORTAL, slot, 20), - Some(B256::with_last_byte(0x14)) - ); + cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05)); + cache.set(PORTAL, slot, 10, B256::with_last_byte(0x0a)); + cache.set(PORTAL, slot, 20, B256::with_last_byte(0x14)); - // A historical fallback cannot repopulate the forward cache. - assert!(!cache.set(PORTAL, slot, 14, B256::with_last_byte(0xee))); - assert!(!cache.slots[&(PORTAL, slot)].contains_key(&14)); + cache.prune_before(15); - // Touching one slot/address compacts only that history and preserves its baseline. - assert!(cache.set(PORTAL, slot, 15, B256::with_last_byte(0x0f))); - cache.invalidate(PORTAL, 21); assert_eq!(cache.get(PORTAL, slot, 5), None); assert_eq!( - cache.slots[&(PORTAL, slot)] - .keys() - .copied() - .collect::>(), - vec![10, 15, 20] - ); - assert_eq!( - cache.invalidations[&PORTAL] - .iter() - .copied() - .collect::>(), - vec![12, 18, 21] + cache.get(PORTAL, slot, 10), + Some(B256::with_last_byte(0x0a)) ); assert_eq!( cache.get(PORTAL, slot, 15), - Some(B256::with_last_byte(0x0f)) + Some(B256::with_last_byte(0x0a)) ); - } - - #[test] - fn below_floor_lookup_misses_after_invalidation_pruning() { - let mut cache = L1StateCacheInner::new(HashSet::from([PORTAL])); - let slot = B256::with_last_byte(1); - - assert!(cache.set(PORTAL, slot, 5, B256::with_last_byte(0x05))); - cache.invalidate(PORTAL, 10); - cache.invalidate(PORTAL, 20); - cache.advance_floor(30); - cache.invalidate(PORTAL, 40); - assert_eq!( - cache.invalidations[&PORTAL] - .iter() - .copied() - .collect::>(), - vec![20, 40] + cache.get(PORTAL, slot, 20), + Some(B256::with_last_byte(0x14)) ); - assert_eq!(cache.get(PORTAL, slot, 15), None); } } diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index 48fd2a3ac..13ff03409 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -2,8 +2,8 @@ //! //! [`L1StateProvider`] wraps a [`L1StateCache`] and a [`DynProvider`] backed by an //! HTTP transport. Reads are served from the in-memory cache when possible. On cache miss the -//! provider falls back to `eth_getStorageAt` via the shared HTTP provider, and forward reads are -//! written back. Misses below the canonical Zone anchor floor are returned without caching. +//! provider falls back to `eth_getStorageAt` via the shared HTTP provider and writes the result +//! back into the cache. //! //! Both a synchronous ([`L1StateProvider::get_storage`]) and an asynchronous //! ([`L1StateProvider::get_storage_async`]) entry point are provided. The synchronous variant is @@ -198,8 +198,7 @@ impl L1StateProvider { match result { Ok(value) => { - // Historical reads below the canonical floor are deliberately not readmitted. - let _ = self.cache.write().set(address, slot, block_number, value); + self.cache.write().set(address, slot, block_number, value); if attempt > 1 { info!(%address, %slot, block_number, %value, ?elapsed, attempt, "L1 storage RPC fetch succeeded after retries"); } else { @@ -270,8 +269,7 @@ impl L1StateProvider { warn!(%address, %slot, block_number, "L1 storage cache miss, fetching from RPC"); let value = self.fetch_slot(address, slot, block_number).await?; - // Historical reads below the canonical floor are deliberately not readmitted. - let _ = self.cache.write().set(address, slot, block_number, value); + self.cache.write().set(address, slot, block_number, value); Ok(value) } diff --git a/crates/l1/src/subscriber.rs b/crates/l1/src/subscriber.rs index cbedda12f..d34ff35b3 100644 --- a/crates/l1/src/subscriber.rs +++ b/crates/l1/src/subscriber.rs @@ -1,5 +1,4 @@ use super::*; -use std::collections::HashSet; /// Poll interval for the HTTP block filter fallback (500ms, matching L1 block time). const HTTP_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); @@ -47,9 +46,6 @@ where } } -/// Events extracted from an L1 block: portal events, policy events, and tracked tokens. -type SubscriberEventsOutput = (L1PortalEvents, Vec, HashSet
); - /// L1 chain subscriber that listens for new blocks and extracts deposit events. #[derive(Clone)] pub struct L1Subscriber { @@ -402,16 +398,15 @@ impl L1Subscriber { while let Some((header, receipts)) = fetched.try_next().await? { let block_number = header.number(); - let (portal_events, policy_events, mutated_accounts) = - self.extract_events(block_number, &receipts); + let (events, policy_events) = self.extract_events(block_number, &receipts); self.record_seen_block(block_number, to.saturating_sub(block_number)); let sealed = SealedHeader::seal_slow(header); - self.update_l1_state_anchor(&sealed, &mutated_accounts); + self.update_l1_state_anchor(block_number, sealed.hash(), sealed.parent_hash()); self.apply_policy_events(block_number, &policy_events); - self.apply_portal_state_events(block_number, &portal_events); + self.apply_portal_state_events(block_number, &events); self.deposit_queue - .enqueue_sealed(sealed, portal_events, policy_events); + .enqueue_sealed(sealed, events, policy_events); enqueued += 1; self.subscriber_metrics.blocks_enqueued.increment(1); @@ -471,7 +466,11 @@ impl L1Subscriber { // A block is only flushed to the deposit queue once the NEXT block // arrives with a matching parent hash, proving the buffered block // is on the canonical chain. - let mut unconfirmed_tip: Option<(SealedHeader, SubscriberEventsOutput)> = None; + let mut unconfirmed_tip: Option<( + SealedHeader, + L1PortalEvents, + Vec, + )> = None; loop { let stream_wait_start = std::time::Instant::now(); @@ -484,23 +483,23 @@ impl L1Subscriber { }; let block_number = header.number(); let sealed = SealedHeader::seal_slow(header.inner.into_consensus()); - let events = self.extract_events(block_number, &receipts); + let (events, policy_events) = self.extract_events(block_number, &receipts); self.record_seen_block(block_number, 0); // If we have a buffered tip, check if the new block confirms it. - if let Some((tip_header, (portal_events, policy_events, mutated_accounts))) = - unconfirmed_tip.take() - { + if let Some((tip_header, tip_events, tip_policy_events)) = unconfirmed_tip.take() { if sealed.parent_hash() == tip_header.hash() { // Confirmed — update the L1 state anchor, apply events, and // flush to the queue. let tip_number = tip_header.number(); - self.update_l1_state_anchor(&tip_header, &mutated_accounts); - self.apply_policy_events(tip_number, &policy_events); - self.apply_portal_state_events(tip_number, &portal_events); + let tip_hash = tip_header.hash(); + let tip_parent = tip_header.parent_hash(); + self.update_l1_state_anchor(tip_number, tip_hash, tip_parent); + self.apply_policy_events(tip_number, &tip_policy_events); + self.apply_portal_state_events(tip_number, &tip_events); match self .deposit_queue - .try_enqueue(tip_header, portal_events, policy_events) + .try_enqueue(tip_header, tip_events, tip_policy_events) { EnqueueOutcome::Accepted => { self.subscriber_metrics.blocks_enqueued.increment(1); @@ -536,32 +535,30 @@ impl L1Subscriber { } // Buffer the new block as unconfirmed tip. - unconfirmed_tip = Some((sealed, events)); + unconfirmed_tip = Some((sealed, events, policy_events)); } warn!("L1 block subscription stream ended"); Ok(()) } - /// Extract portal and policy events from pre-fetched receipts (no RPC) and mutated accounts. - pub(crate) fn extract_events( + /// Extract portal and policy events from pre-fetched receipts (no RPC). + fn extract_events( &mut self, block_number: u64, receipts: &[tempo_alloy::rpc::TempoTransactionReceipt], - ) -> SubscriberEventsOutput { + ) -> (L1PortalEvents, Vec) { use tempo_contracts::precompiles::{ITIP20::TransferPolicyUpdate, TIP403_REGISTRY_ADDRESS}; let portal_address = self.config.portal_address; let mut portal_events = L1PortalEvents::default(); let mut policy_events = Vec::new(); - let mut mutated_accounts = HashSet::new(); for receipt in receipts { for log in receipt.logs() { let addr = log.address(); if addr == portal_address { - mutated_accounts.insert(addr); let prev_len = portal_events.enabled_tokens.len(); if let Err(e) = portal_events.push_log(log, block_number) { warn!(block_number, %e, "Failed to decode portal event from receipt"); @@ -574,24 +571,20 @@ impl L1Subscriber { } } } else if addr == TIP403_REGISTRY_ADDRESS { - mutated_accounts.insert(addr); if let Some(event) = PolicyEvent::decode_registry(log) { policy_events.push(event); } } else if self.tracked_tokens.contains(&addr) && log.topics().first() == Some(&TransferPolicyUpdate::SIGNATURE_HASH) + && let Some(event) = PolicyEvent::decode_tip20(log) { - // TODO: Remove once Tempo has migrated policy IDs to the TIP-403 registry. - mutated_accounts.insert(addr); - if let Some(event) = PolicyEvent::decode_tip20(log) { - policy_events.push(event); - } + policy_events.push(event); } } } self.record_portal_event_metrics(&portal_events); - (portal_events, policy_events, mutated_accounts) + (portal_events, policy_events) } fn record_seen_block(&self, block_number: u64, lag_blocks: u64) { @@ -684,30 +677,24 @@ impl L1Subscriber { ); } - /// Update the L1 state cache anchor. Detects reorgs by comparing `parent_hash` - /// against the current anchor and clears the cache when they diverge. - pub(crate) fn update_l1_state_anchor( - &self, - header: &SealedHeader, - mutated_accounts: &HashSet
, - ) { + /// Update the L1 state cache anchor. Detects reorgs by comparing + /// `parent_hash` against the current anchor and clears the cache when they + /// diverge. + pub(crate) fn update_l1_state_anchor(&self, number: u64, hash: B256, parent_hash: B256) { let mut guard = self.config.l1_state_cache.write(); let anchor = guard.anchor(); - if anchor.hash != B256::ZERO && header.parent_hash() != anchor.hash { + if anchor.hash != B256::ZERO && parent_hash != anchor.hash { self.subscriber_metrics.reorgs_detected.increment(1); warn!( old_anchor = %anchor.hash, - new_parent = %header.parent_hash(), - block_number = header.number(), + new_parent = %parent_hash, + block_number = number, "Reorg detected, clearing L1 state cache" ); guard.clear(); self.config.policy_cache.write().clear(); } - for &address in mutated_accounts { - guard.invalidate(address, header.number()); - } - guard.update_anchor(header.num_hash()); + guard.update_anchor(NumHash::new(number, hash)); } } @@ -775,21 +762,9 @@ pub(crate) fn apply_sequencer_events_to_cache( block_number: u64, sequencer_events: &[L1SequencerEvent], ) { - let mut set_cache_slot = |slot, value| { - if !cache.set(portal_address, slot, block_number, value) { - let floor = cache.block_floor(); - debug!( - %portal_address, - %slot, - block_number, - floor, - "discarding sequencer cache update below canonical floor" - ); - } - }; + let mut set_cache_slot = |slot, value| cache.set(portal_address, slot, block_number, value); for event in sequencer_events { - // Reorg/backfill may replay events below the monotonic floor; those writes stay rejected. match *event { L1SequencerEvent::TransferStarted { current_sequencer, diff --git a/crates/l1/src/tests.rs b/crates/l1/src/tests.rs index 76ebb8ba7..079e52c0c 100644 --- a/crates/l1/src/tests.rs +++ b/crates/l1/src/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::abi::{DepositType, PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT}; use alloy_consensus::{Header, ReceiptWithBloom}; -use alloy_primitives::{Bloom, Bytes, FixedBytes, LogData, address}; +use alloy_primitives::{Bloom, FixedBytes, address}; use alloy_rpc_types_eth::TransactionReceipt; use alloy_sol_types::SolEvent; use alloy_transport::mock::Asserter; @@ -226,12 +226,6 @@ fn make_test_receipt( } } -fn make_test_receipt_with_logs(logs: Vec) -> TempoTransactionReceipt { - let mut receipt = make_test_receipt(1, B256::ZERO, B256::ZERO, 0, 21_000, Bloom::ZERO); - receipt.inner.inner.receipt.logs = logs; - receipt -} - fn calculate_test_receipts_root(receipts: &[TempoTransactionReceipt]) -> B256 { let receipts = receipts .iter() @@ -405,7 +399,7 @@ fn assert_tempo_header_rejected(input: &[u8]) { } #[test] -fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { +fn update_l1_state_anchor_reorg_clears_stale_policy_state() { use crate::state::tip403::AuthRole; use tempo_contracts::precompiles::ITIP403Registry::PolicyType; @@ -416,17 +410,9 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { let token = address!("0x0000000000000000000000000000000000000011"); let user = address!("0x0000000000000000000000000000000000000022"); - let old_header = seal(make_test_header(10)); - subscriber.update_l1_state_anchor(&old_header, &HashSet::new()); - { - let mut cache = subscriber.config.l1_state_cache.write(); - assert!(cache.set( - token, - B256::with_last_byte(1), - 10, - B256::with_last_byte(0xaa), - )); - } + let old_header = make_test_header(10); + let old_hash = header_hash(&old_header); + subscriber.update_l1_state_anchor(10, old_hash, old_header.inner.parent_hash); { let mut cache = subscriber.config.policy_cache.write(); cache.set_token_policy(token, 10, 2); @@ -436,17 +422,8 @@ fn update_l1_state_anchor_reorg_clears_stale_policy_and_raw_l1_state() { } let replacement_parent = B256::with_last_byte(0x44); - let replacement_header = seal(make_chained_header(11, replacement_parent)); - subscriber.update_l1_state_anchor(&replacement_header, &HashSet::new()); - assert_eq!( - subscriber - .config - .l1_state_cache - .read() - .get(token, B256::with_last_byte(1), 10), - None, - "reorg must clear raw L1 state" - ); + let replacement_header = make_chained_header(11, replacement_parent); + subscriber.update_l1_state_anchor(11, header_hash(&replacement_header), replacement_parent); subscriber.apply_policy_events( 11, &[ @@ -486,9 +463,12 @@ fn confirm_shared(queue: &DepositQueue) -> L1BlockDeposits { queue.confirm(num_hash).expect("confirm mismatch") } -fn make_log(address: Address, data: LogData) -> Log { +fn make_portal_log(portal_address: Address, event: E) -> Log { Log { - inner: alloy_primitives::Log { address, data }, + inner: alloy_primitives::Log { + address: portal_address, + data: event.encode_log_data(), + }, block_hash: None, block_number: None, block_timestamp: None, @@ -499,67 +479,6 @@ fn make_log(address: Address, data: LogData) -> Log { } } -fn make_portal_log(portal_address: Address, event: E) -> Log { - make_log(portal_address, event.encode_log_data()) -} - -#[test] -fn extract_events_tracks_portal_mutations() { - let mut subscriber = test_subscriber( - Arc::new(SequenceLocalTempoCheckpointReader::new([0])), - Some(0), - ); - let portal = subscriber.config.portal_address; - let log = make_log( - portal, - LogData::new_unchecked(vec![B256::repeat_byte(0xff)], Bytes::new()), - ); - - let (_, _, mutated_accounts) = - subscriber.extract_events(1, &[make_test_receipt_with_logs(vec![log])]); - - assert_eq!(mutated_accounts, HashSet::from([portal])); -} - -#[test] -fn extract_events_conservatively_tracks_policy_mutations() { - use tempo_contracts::precompiles::{ITIP20::TransferPolicyUpdate, TIP403_REGISTRY_ADDRESS}; - - let tracked_token = Address::with_last_byte(0x11); - let untracked_token = Address::with_last_byte(0x22); - let mut subscriber = test_subscriber( - Arc::new(SequenceLocalTempoCheckpointReader::new([0])), - Some(0), - ); - subscriber.tracked_tokens = vec![tracked_token]; - - let unknown_registry_log = make_log( - TIP403_REGISTRY_ADDRESS, - LogData::new_unchecked(vec![B256::repeat_byte(0xff)], Bytes::new()), - ); - let policy_update = TransferPolicyUpdate { - updater: Address::ZERO, - newPolicyId: 7, - }; - let tracked_update = make_log(tracked_token, policy_update.encode_log_data()); - let untracked_update = make_log(untracked_token, policy_update.encode_log_data()); - let receipt = - make_test_receipt_with_logs(vec![unknown_registry_log, tracked_update, untracked_update]); - - let (_, policy_events, mutated_accounts) = subscriber.extract_events(1, &[receipt]); - - assert!(mutated_accounts.contains(&TIP403_REGISTRY_ADDRESS)); - assert!(mutated_accounts.contains(&tracked_token)); - assert!(!mutated_accounts.contains(&untracked_token)); - assert!(matches!( - policy_events.as_slice(), - [PolicyEvent::TokenPolicyChanged { - token, - policy_id: 7, - }] if *token == tracked_token - )); -} - #[tokio::test] async fn test_resolve_start_block_reads_live_local_state_each_time() { let subscriber = test_subscriber( diff --git a/crates/node/README.md b/crates/node/README.md index 1049f67b2..7d947e8cb 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -126,21 +126,16 @@ header chain linking back to the target block. The zone enforces TIP-403 transfer policies (whitelist, blacklist, compound) 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 +1. **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 + read and caching the result. +2. **AnchoredZoneDb** — 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. +3. **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 diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index e6fed5b7b..aacf662c4 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -30,7 +30,7 @@ use reth_node_builder::{ }, }; use reth_primitives_traits::SealedHeader; -use reth_provider::{CanonStateSubscriptions, ChainSpecProvider}; +use reth_provider::ChainSpecProvider; use reth_rpc_builder::Identity; use reth_rpc_eth_api::EthApiTypes; use reth_storage_api::{BlockNumReader, EmptyBodyStorage, HeaderProvider, StateProviderFactory}; @@ -62,7 +62,7 @@ use tracing::{debug, info, warn}; use zone_chainspec::ZoneChainSpec; use zone_evm::ZoneEvmConfig; use zone_l1::{ - ChainTempoStateExt, DepositQueue, L1Subscriber, L1SubscriberConfig, PolicyCache, TempoStateExt, + DepositQueue, L1Subscriber, L1SubscriberConfig, PolicyCache, TempoStateExt, state::{ L1StateCache, L1StateProvider, L1StateProviderConfig, PolicyProvider, spawn_policy_resolution_task, spawn_pool_prefetch_task, @@ -392,8 +392,6 @@ where l1_config: L1SubscriberConfig, /// TIP-403 policy cache policy_cache: PolicyCache, - /// Block-versioned L1 storage cache shared with the subscriber and precompiles. - l1_state_cache: L1StateCache, /// ZonePortal address on L1. portal_address: Address, /// Pre-configured list of initial tokens. @@ -425,7 +423,6 @@ where deposit_queue: DepositQueue, l1_config: L1SubscriberConfig, policy_cache: PolicyCache, - l1_state_cache: L1StateCache, portal_address: Address, initial_tokens: Option>, private_rpc_config: ZonePrivateRpcConfig, @@ -444,7 +441,6 @@ where deposit_queue, l1_config, policy_cache, - l1_state_cache, portal_address, initial_tokens, private_rpc_config, @@ -475,11 +471,7 @@ where let sp = ctx.node.provider().latest()?; let tempo_block_number = sp.tempo_block_number()?; self.policy_cache.set_last_l1_block(tempo_block_number); - self.l1_state_cache - .write() - .advance_floor(tempo_block_number); - self.spawn_l1_state_cache_floor_task(&ctx); - info!(target: "reth::cli", tempo_block_number, "Initialized L1 cache progress from local TempoState"); + info!(target: "reth::cli", tempo_block_number, "Read local tempoBlockNumber for L1 subscriber"); let l1_provider = alloy_provider::ProviderBuilder::new_with_network::() .connect_with_config( @@ -692,72 +684,6 @@ where Ok(()) } - /// Track the canonical Zone anchor and advance the raw L1 cache floor for every node role. - fn spawn_l1_state_cache_floor_task(&self, ctx: &AddOnsContext<'_, N>) { - let provider = ctx.node.provider().clone(); - let mut notifications = provider.subscribe_to_canonical_state(); - let cache = self.l1_state_cache.clone(); - - ctx.node.task_executor().spawn_critical_task( - "l1-state-cache-floor", - async move { - loop { - let block_number = match notifications.recv().await { - Ok(notification) => { - let committed = notification.committed(); - if committed.is_empty() { - // A pure revert has no new execution outcome. The floor is - // monotonic, but read the new canonical head for completeness. - match provider - .latest() - .and_then(|state| state.tempo_block_number()) - { - Ok(block_number) => block_number, - Err(err) => { - warn!(target: "zone::l1_cache", %err, "Failed to resync L1 cache floor after canonical revert"); - continue; - } - } - } else { - committed.tempo_block_number() - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - // A later notification normally catches the floor up, but resync now - // so an idle follower cannot remain behind after notification loss. - warn!(target: "zone::l1_cache", skipped, "Canonical notifications lagged; resyncing L1 cache floor"); - match provider - .latest() - .and_then(|state| state.tempo_block_number()) - { - Ok(block_number) => block_number, - Err(err) => { - warn!(target: "zone::l1_cache", %err, "Failed to resync lagged L1 cache floor"); - continue; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - panic!("canonical state notifications closed unexpectedly") - } - }; - - let mut cache = cache.write(); - let previous_floor = cache.block_floor(); - cache.advance_floor(block_number); - if block_number > previous_floor { - debug!( - target: "zone::l1_cache", - previous_floor, - block_number, - "Advanced L1 cache floor from canonical Zone state" - ); - } - } - }, - ); - } - /// Spawn the L1 subscriber. Listens for new blocks and deposit events. fn spawn_l1_subscriber(&mut self, ctx: &AddOnsContext<'_, N>) { L1Subscriber::spawn( @@ -1008,7 +934,6 @@ where self.deposit_queue.clone(), self.l1_config.clone(), self.policy_cache.clone(), - self.l1_state_cache.clone(), self.portal_address, self.initial_tokens.clone(), self.private_rpc_config.clone(), diff --git a/crates/node/tests/it/e2e.rs b/crates/node/tests/it/e2e.rs index 78b703cbb..35910531a 100644 --- a/crates/node/tests/it/e2e.rs +++ b/crates/node/tests/it/e2e.rs @@ -1099,16 +1099,6 @@ async fn test_chain_tempo_state_ext_from_canon_notification() -> eyre::Result<() // Wait for tempoBlockNumber to reach 3 via RPC (ensures blocks are mined). zone.wait_for_tempo_block_number(3, DEFAULT_TIMEOUT).await?; - // The engine does not own the raw cache floor. Reaching 3 here proves the role-independent - // canonical-state task observed the imports and advanced the shared cache. - let cache = zone.l1_state_cache().clone(); - let floor = poll_until(DEFAULT_TIMEOUT, DEFAULT_POLL, "L1 cache floor >= 3", || { - let floor = cache.read().block_floor(); - async move { Ok((floor >= 3).then_some(floor)) } - }) - .await?; - assert_eq!(floor, 3, "raw L1 cache floor should match the Zone anchor"); - // Drain canon notifications and collect the L1 NumHash from each committed chain. let mut num_hashes: Vec = Vec::new(); while let Ok(notification) = canon_rx.try_recv() { diff --git a/crates/node/tests/it/tip403_policy.rs b/crates/node/tests/it/tip403_policy.rs index 10fd5e6f4..ddb0fa95a 100644 --- a/crates/node/tests/it/tip403_policy.rs +++ b/crates/node/tests/it/tip403_policy.rs @@ -121,13 +121,12 @@ async fn test_l1_blacklisted_sender_cannot_pay_for_empty_transaction() -> eyre:: let anchor = zone.wait_for_tempo_block_number(1, DEFAULT_TIMEOUT).await?; const BLACKLIST_POLICY_ID: u64 = 42; - { - let mut cache = zone.l1_state_cache().write(); - eyre::ensure!( - seed_raw_tip20_policy_id(&mut cache, anchor, PATH_USD_ADDRESS, BLACKLIST_POLICY_ID,), - "fee-token policy seed must be admitted at the current anchor" - ); - } + seed_raw_tip20_policy_id( + &mut zone.l1_state_cache().write(), + anchor, + PATH_USD_ADDRESS, + BLACKLIST_POLICY_ID, + ); seed_raw_tip403_policy( zone.l1_state_cache(), anchor, @@ -187,7 +186,7 @@ async fn test_policy_proxy_whitelist_authorization() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate raw L1 state before block 1 advances the cache floor. + // Populate raw L1 state at block 1. seed_raw_tip403_policy( zone.l1_state_cache(), 1, @@ -238,7 +237,7 @@ async fn test_policy_proxy_blacklist_authorization() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Populate raw L1 state before block 1 advances the cache floor. + // Populate raw L1 state at block 1. seed_raw_tip403_policy( zone.l1_state_cache(), 1, @@ -277,7 +276,7 @@ async fn test_policy_proxy_compound_policy() -> eyre::Result<()> { let alice = address!("0x000000000000000000000000000000000000A11C"); let bob = address!("0x0000000000000000000000000000000000000B0B"); - // Seed the compound policy graph before block 1 advances the cache floor. + // Seed the compound policy graph at block 1. // Policy 5 = sender whitelist, policy 6 = recipient blacklist; compound policy 10 // references them. seed_raw_tip403_policy( @@ -391,7 +390,7 @@ async fn test_compound_policy_transfer_role_authorization() -> eyre::Result<()> let bob = address!("0x0000000000000000000000000000000000000B0B"); let carol = address!("0x000000000000000000000000000000000000CA01"); - // Seed the complete policy membership before block 1 advances the cache floor. + // Seed the complete policy membership at block 1. seed_raw_tip403_policy( zone.l1_state_cache(), 1, diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index eddb8b6c7..c32a0199c 100644 --- a/crates/node/tests/it/utils.rs +++ b/crates/node/tests/it/utils.rs @@ -329,20 +329,19 @@ fn pack_transfer_policy_id(policy_id: u64) -> U256 { } /// Seed a TIP-20 transfer policy ID in the canonical packed L1 storage slot. -#[must_use = "check whether the policy seed was admitted above the cache floor"] pub(crate) fn seed_raw_tip20_policy_id( cache: &mut zone_l1::state::L1StateCacheInner, block_number: u64, token: Address, policy_id: u64, -) -> bool { +) { 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`]. @@ -433,17 +432,13 @@ pub(crate) fn seed_raw_tip403_policy( })?; let mut cache = cache.write(); - let block_floor = cache.block_floor(); for slot in slots { let value = storage.sload(TIP403_REGISTRY_ADDRESS, slot)?; - eyre::ensure!( - cache.set( - TIP403_REGISTRY_ADDRESS, - slot.into(), - block_number, - value.into(), - ), - "TIP-403 seed rejected below cache floor: block={block_number} floor={block_floor} slot={slot}" + cache.set( + TIP403_REGISTRY_ADDRESS, + slot.into(), + block_number, + value.into(), ); } Ok(()) @@ -987,10 +982,7 @@ impl ZoneTestNode { if is_local_dummy_l1 { seed_local_policy_cache(&policy_cache); let mut cache = l1_state_cache.write(); - assert!( - seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID), - "pathUSD policy seed must be admitted before node startup" - ); + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); } let node_handle = NodeBuilder::new(node_config) @@ -3681,42 +3673,39 @@ impl L1Fixture { // 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. - assert!(cache.set( + 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]; sequencer_bytes[12..].copy_from_slice(sequencer.as_slice()); - assert!(cache.set( + cache.set( portal_address, B256::ZERO, block, B256::new(sequencer_bytes), - )); + ); // Deposit queue hash slot (5) — read by ZoneInbox after finalizeTempo. // The initial value is B256::ZERO (empty queue). - assert!(cache.set(portal_address, deposit_queue_hash_slot, block, B256::ZERO)); - assert!(cache.set(portal_address, refunds_slot, block, B256::ZERO)); + cache.set(portal_address, deposit_queue_hash_slot, block, B256::ZERO); + cache.set(portal_address, refunds_slot, block, B256::ZERO); // Local fixtures treat pathUSD as the default enabled bridge token. // ZoneConfig reads the L1 ZonePortal TokenConfig mapping directly, so // seed the packed { enabled, depositsActive } value to avoid a dummy // RPC fallback on self-contained tests. - assert!(cache.set( + cache.set( portal_address, path_usd_config_slot, block, enabled_token_config, - )); + ); } - assert!( - seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID), - "pathUSD policy baseline must be admitted before the cache floor advances" - ); + seed_raw_tip20_policy_id(&mut cache, 0, PATH_USD_ADDRESS, ALLOW_ALL_POLICY_ID); cache.update_anchor(NumHash { number: num_blocks, hash: B256::ZERO, @@ -3735,16 +3724,11 @@ impl L1Fixture { // 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() { - let mut cache = cache.write(); - let block_floor = cache.block_floor(); - eyre::ensure!( - cache.set( - TIP403_REGISTRY_ADDRESS, - B256::from(receive_policy_slot.to_be_bytes()), - block_number, - B256::ZERO, - ), - "receive-policy seed rejected below cache floor: recipient={recipient} block={block_number} floor={block_floor} slot={receive_policy_slot}" + cache.write().set( + TIP403_REGISTRY_ADDRESS, + B256::from(receive_policy_slot.to_be_bytes()), + block_number, + B256::ZERO, ); } Ok(()) @@ -3761,14 +3745,11 @@ impl L1Fixture { for cache in self.caches.lock().unwrap().iter() { let mut cache = cache.write(); for token in tokens { - assert!( - seed_raw_tip20_policy_id( - &mut cache, - block_number, - token.token, - ALLOW_ALL_POLICY_ID, - ), - "enabled-token policy fixture seed must be admitted" + seed_raw_tip20_policy_id( + &mut cache, + block_number, + token.token, + ALLOW_ALL_POLICY_ID, ); } } From d5b4d2859e5c5102cf5e617128b629acbe8c11ff Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 17 Jul 2026 18:11:30 +0200 Subject: [PATCH 22/22] fix: simulate policy-rejected transfer in test --- crates/node/tests/it/l1_e2e.rs | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/crates/node/tests/it/l1_e2e.rs b/crates/node/tests/it/l1_e2e.rs index 36f1737df..7569ee818 100644 --- a/crates/node/tests/it/l1_e2e.rs +++ b/crates/node/tests/it/l1_e2e.rs @@ -1415,10 +1415,9 @@ async fn test_blacklisted_sender_transfer_rejected() -> eyre::Result<()> { ) .await?; - // --- Step 5: Alice attempts a transfer → should be rejected --- - // The transfer may be rejected at the pool level (send() returns Err) or - // accepted into a block but reverted (receipt.status() == false). Either - // outcome proves the blacklist is enforced. + // --- Step 5: Alice simulates a transfer → should be rejected --- + // Use an exact stateful call instead of waiting on pool inclusion: policy-invalid + // transactions are allowed to remain pending, so absence of a receipt is not proof. let bob = Address::with_last_byte(0xBB); let alice_provider = alloy::providers::ProviderBuilder::new() @@ -1426,28 +1425,15 @@ async fn test_blacklisted_sender_transfer_rejected() -> eyre::Result<()> { .connect_http(zone.http_url().clone()); let tip20 = tempo_contracts::precompiles::ITIP20::new(ZONE_TOKEN_ADDRESS, &alice_provider); - let send_result = tip20 + let transfer = tip20 .transfer(bob, U256::from(200_000u128)) - .gas_price(tempo_chainspec::spec::TEMPO_T1_BASE_FEE as u128) - .gas(500_000) - .send() + .from(alice) + .call() .await; - - match send_result { - Err(_) => { - // Pool-level rejection — blacklist enforced before inclusion. - } - Ok(pending) => { - // Tx was accepted into the pool — wait for it to be included and - // verify it reverted. - tokio::time::sleep(Duration::from_secs(3)).await; - let receipt = pending.get_receipt().await?; - assert!( - !receipt.status(), - "transfer from blacklisted sender should revert, but succeeded" - ); - } - } + assert!( + transfer.is_err(), + "transfer simulation from blacklisted sender should revert" + ); // Bob should have zero balance let bob_balance = zone.balance_of(ZONE_TOKEN_ADDRESS, bob).await?;