diff --git a/Cargo.lock b/Cargo.lock index 42752ab4a..6ce544fdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13396,7 +13396,6 @@ dependencies = [ "alloy-evm", "alloy-primitives", "alloy-provider", - "alloy-sol-types", "eyre", "reth-chainspec", "reth-evm", @@ -13410,9 +13409,7 @@ dependencies = [ "tempo-precompiles", "tempo-primitives", "tempo-revm", - "tempo-zone-contracts", "tokio", - "tracing", "zone-chainspec", "zone-l1", "zone-precompiles", @@ -13614,6 +13611,7 @@ dependencies = [ "alloy-sol-types", "const-hex", "derive_more", + "eyre", "k256", "paste", "rand 0.8.7", diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index bd20bb0a6..e2129466f 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -1,18 +1,18 @@ pub mod common; +pub mod outbox; pub mod swap_and_deposit_router; pub mod tempo_state; pub mod zone_factory; pub mod zone_inbox; -pub mod zone_outbox; pub mod zone_portal; pub mod zone_tx_context; pub use common::*; +pub use outbox::*; pub use swap_and_deposit_router::*; pub use tempo_state::*; pub use zone_factory::*; pub use zone_inbox::*; -pub use zone_outbox::*; pub use zone_portal::*; pub use zone_tx_context::*; @@ -21,6 +21,7 @@ pub use zone_tx_context::*; // contracts crate, e.g. `tempo_zone_contracts::TEMPO_STATE_ADDRESS`. pub use zone_primitives::constants::{ EMPTY_SENTINEL, MAX_WITHDRAWAL_GAS_LIMIT, NO_QUEUE_INDEX, PORTAL_ADMIN_SLOT, - PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT, TEMPO_STATE_ADDRESS, ZONE_CONFIG_ADDRESS, - ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZONE_TX_CONTEXT_ADDRESS, + PORTAL_PENDING_SEQUENCER_SLOT, PORTAL_SEQUENCER_SLOT, PORTAL_TOKEN_CONFIGS_SLOT, + TEMPO_STATE_ADDRESS, ZONE_CONFIG_ADDRESS, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, + ZONE_TOKEN_ADDRESS, ZONE_TX_CONTEXT_ADDRESS, }; diff --git a/crates/contracts/src/precompiles/zone_outbox.rs b/crates/contracts/src/precompiles/outbox.rs similarity index 55% rename from crates/contracts/src/precompiles/zone_outbox.rs rename to crates/contracts/src/precompiles/outbox.rs index 907118bd7..bdad50348 100644 --- a/crates/contracts/src/precompiles/zone_outbox.rs +++ b/crates/contracts/src/precompiles/outbox.rs @@ -1,12 +1,13 @@ //! `ZoneOutbox` — deployed on the Zone L2. -pub use ZoneOutbox::LastBatch; +pub use IZoneOutbox::{ + IZoneOutboxErrors as ZoneOutboxError, IZoneOutboxEvents as ZoneOutboxEvent, LastBatch, + PendingWithdrawal, StaticCallNotAllowed, +}; crate::sol! { - #[derive(Debug)] - contract ZoneOutbox { - // -- Shared types -- - + #[derive(Debug, PartialEq, Eq)] + contract IZoneOutbox { struct LastBatch { bytes32 withdrawalQueueHash; uint64 withdrawalBatchIndex; @@ -43,6 +44,8 @@ crate::sol! { ); event BatchFinalized(bytes32 indexed withdrawalQueueHash, uint64 withdrawalBatchIndex); + event TempoGasRateUpdated(uint128 tempoGasRate); + event MaxWithdrawalsPerBlockUpdated(uint32 maxWithdrawalsPerBlock); // -- Errors -- @@ -52,9 +55,21 @@ crate::sol! { error InvalidWithdrawalCount(uint256 actual, uint256 expected); error InvalidEncryptedSenderCount(uint256 actual, uint256 expected); error InvalidEncryptedSenderLength(uint256 actual, uint256 expected); + error InvalidFallbackRecipient(); + error CallbackDataTooLarge(); + error GasFeeRateTooHigh(); + error TransferFailed(); + error InvalidBlockNumber(); + error TooManyWithdrawalsThisBlock(); + error InvalidRevealTo(); + error InvalidCurrentTxHash(); + error StaticCallNotAllowed(); // -- View functions -- + function config() external view returns (address); + function tempoGasRate() external view returns (uint128); + function maxWithdrawalsPerBlock() external view returns (uint32); function lastBatch() external view returns (LastBatch memory); function withdrawalBatchIndex() external view returns (uint64); function lastFinalizedTimestamp() external view returns (uint64); @@ -64,10 +79,17 @@ crate::sol! { function getPendingWithdrawals() external view returns (PendingWithdrawal[] memory); function consumeFallbackRecipient(uint64 fallbackNonce) external returns (address recipient); function calculateWithdrawalFee(uint64 gasLimit) external view returns (uint128 fee); + function MAX_CALLBACK_DATA_SIZE() external view returns (uint256); function MAX_WITHDRAWAL_GAS_LIMIT() external view returns (uint64); + function MAX_GAS_FEE_RATE() external view returns (uint128); + function WITHDRAWAL_BASE_GAS() external view returns (uint64); + function REVEAL_TO_KEY_LENGTH() external view returns (uint256); + function AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH() external view returns (uint256); // -- State-changing functions -- + function setTempoGasRate(uint128 _tempoGasRate) external; + function setMaxWithdrawalsPerBlock(uint32 _maxWithdrawalsPerBlock) external; function requestWithdrawal( address token, address to, @@ -86,3 +108,34 @@ crate::sol! { function finalizeWithdrawalBatch(uint256 count, uint64 blockNumber, bytes[] calldata encryptedSenders) external returns (bytes32 withdrawalQueueHash); } } + +crate::sol! { + /// Legacy seven-argument withdrawal interface. The current overload adds `revealTo`. + #[derive(Debug)] + interface ILegacyZoneOutbox { + function requestWithdrawal( + address token, + address to, + uint128 amount, + bytes32 memo, + uint64 gasLimit, + address fallbackRecipient, + bytes calldata data + ) external; + } +} + +impl From for IZoneOutbox::requestWithdrawalCall { + fn from(call: ILegacyZoneOutbox::requestWithdrawalCall) -> Self { + Self { + token: call.token, + to: call.to, + amount: call.amount, + memo: call.memo, + gasLimit: call.gasLimit, + fallbackRecipient: call.fallbackRecipient, + data: call.data, + revealTo: Default::default(), + } + } +} diff --git a/crates/contracts/src/precompiles/zone_portal.rs b/crates/contracts/src/precompiles/zone_portal.rs index 812b1c4aa..243dedc53 100644 --- a/crates/contracts/src/precompiles/zone_portal.rs +++ b/crates/contracts/src/precompiles/zone_portal.rs @@ -2,15 +2,16 @@ pub use ZonePortal::{ BlockTransition, DepositQueueTransition, EncryptedDeposit, EncryptedDepositPayload, Withdrawal, + ZonePortalErrors as ZonePortalError, }; -use crate::ZoneOutbox; +use crate::IZoneOutbox; use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_sol_types::SolValue; -use zone_primitives::constants::EMPTY_SENTINEL; +use zone_primitives::constants::{EMPTY_SENTINEL, PORTAL_TOKEN_CONFIGS_SLOT}; crate::sol! { - #[derive(Debug)] + #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] contract ZonePortal { // -- Shared types -- @@ -168,6 +169,7 @@ crate::sol! { error InvalidTempoBlockNumber(); error PolicyForbids(); error InvalidBouncebackRecipient(); + error TokenNotEnabled(); // -- View functions -- @@ -344,6 +346,7 @@ impl core::fmt::Display for ZonePortal::ZonePortalErrors { Self::InvalidTempoBlockNumber(_) => f.write_str("InvalidTempoBlockNumber"), Self::PolicyForbids(_) => f.write_str("PolicyForbids"), Self::InvalidBouncebackRecipient(_) => f.write_str("InvalidBouncebackRecipient"), + Self::TokenNotEnabled(_) => f.write_str("TokenNotEnabled"), } } } @@ -364,7 +367,7 @@ impl Withdrawal { /// Reconstruct the public L1-facing withdrawal from a zone-side withdrawal request event. pub fn from_requested_event( - event: &ZoneOutbox::WithdrawalRequested, + event: &IZoneOutbox::WithdrawalRequested, tx_hash: B256, encrypted_sender: Bytes, ) -> Self { @@ -388,6 +391,11 @@ impl Withdrawal { } } + /// Hash this withdrawal as one link in a withdrawal queue. + pub fn hash_with_tail(&self, tail: B256) -> B256 { + keccak256((self.clone(), tail).abi_encode_params()) + } + /// Compute the withdrawal queue hash for a slice of withdrawals. /// /// The hash chain has the oldest withdrawal at the outermost layer for efficient FIFO removal: @@ -404,9 +412,14 @@ impl Withdrawal { } let mut hash = EMPTY_SENTINEL; - for w in withdrawals.iter().rev() { - hash = keccak256((w.clone(), hash).abi_encode_params()); + for withdrawal in withdrawals.iter().rev() { + hash = withdrawal.hash_with_tail(hash); } hash } } + +/// Return the storage slot for `token` in the portal token-config mapping. +pub fn portal_token_config_slot(token: Address) -> B256 { + keccak256((token, PORTAL_TOKEN_CONFIGS_SLOT).abi_encode()) +} diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index c9b524a77..15316a5fd 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -12,7 +12,6 @@ workspace = true [dependencies] # zones -tempo-zone-contracts = { workspace = true, features = ["std", "serde", "rpc"] } zone-chainspec.workspace = true zone-l1.workspace = true zone-primitives.workspace = true @@ -38,14 +37,12 @@ alloy-consensus.workspace = true alloy-evm.workspace = true alloy-primitives.workspace = true alloy-provider = { workspace = true, features = ["reqwest"] } -alloy-sol-types.workspace = true # revm revm.workspace = true # misc tokio.workspace = true -tracing.workspace = true [dev-dependencies] eyre.workspace = true diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index 214ca3754..d482ec4b7 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -21,7 +21,7 @@ use tempo_primitives::{TempoReceipt, TempoTxEnvelope, TempoTxType}; use tempo_revm::{TempoStateAccess, evm::TempoContext}; use zone_chainspec::ZoneChainSpec; -use crate::{ZoneEvm, tx_context}; +use crate::ZoneEvm; /// Simplified block executor for zone nodes. /// @@ -102,7 +102,8 @@ where // transaction's resolved fee token, so the handler skips FeeAMM. self.override_validator_token(); - let _tx_hash_guard = tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); + let _tx_hash_guard = + zone_precompiles::tx_context::set_current_tx_hash(*recovered.tx().tx_hash()); self.inner .execute_transaction_without_commit((tx_env, recovered)) } diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 39d30dbdf..c22199999 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -9,19 +9,13 @@ mod executor; pub mod precompiles; -mod tx_context; mod zone_evm; 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, - }, - tx_context::ZoneTxContext, + precompiles::{SequencerExt, extend_zone_precompiles}, }; use alloy_evm::{ Database, Evm, EvmEnv, EvmFactory, @@ -47,18 +41,12 @@ 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 zone_chainspec::ZoneChainSpec; -use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig, PolicyProvider}; +use zone_l1::state::{L1StateCache, L1StateProvider, L1StateProviderConfig}; type TempoCtx = ::Context; @@ -67,22 +55,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 +69,15 @@ 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 - } - }); evm } } @@ -295,12 +221,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/evm/src/tx_context.rs b/crates/evm/src/tx_context.rs deleted file mode 100644 index b935fc64d..000000000 --- a/crates/evm/src/tx_context.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Transaction-hash execution context for authenticated withdrawals. -//! -//! The zone outbox needs the real hash of the currently executing user transaction so it can -//! commit `senderTag = keccak256(sender || txHash)` on-chain. The block executor publishes that -//! hash into a thread-local context before EVM execution, and this precompile exposes it to -//! Solidity at a fixed system address. - -use std::{cell::RefCell, thread_local}; - -use alloy_evm::precompiles::DynPrecompile; -use alloy_primitives::{B256, Bytes}; -use alloy_sol_types::{SolCall, SolError}; -use revm::precompile::{PrecompileId, PrecompileOutput}; -use tracing::{debug, warn}; - -alloy_sol_types::sol! { - function currentTxHash() external returns (bytes32); - error DelegateCallNotAllowed(); -} - -thread_local! { - static CURRENT_TX_HASH: RefCell> = const { RefCell::new(None) }; -} - -/// Guard that clears the current tx hash when dropped. -pub(crate) struct TxHashGuard; - -impl Drop for TxHashGuard { - fn drop(&mut self) { - clear_current_tx_hash(); - } -} - -/// Publish the current executing transaction hash for the duration of EVM execution. -pub(crate) fn set_current_tx_hash(tx_hash: B256) -> TxHashGuard { - CURRENT_TX_HASH.with(|slot| { - *slot.borrow_mut() = Some(tx_hash); - }); - TxHashGuard -} - -fn clear_current_tx_hash() { - CURRENT_TX_HASH.with(|slot| { - *slot.borrow_mut() = None; - }); -} - -fn current_tx_hash() -> Option { - CURRENT_TX_HASH.with(|slot| *slot.borrow()) -} - -/// `DynPrecompile` implementation that returns the currently executing zone tx hash. -pub(crate) struct ZoneTxContext; - -impl ZoneTxContext { - pub(crate) fn create() -> DynPrecompile { - DynPrecompile::new_stateful(PrecompileId::Custom("ZoneTxContext".into()), move |input| { - if !input.is_direct_call() { - warn!( - target: "zone::precompile", - "ZoneTxContext called via DELEGATECALL — rejecting" - ); - return Ok(PrecompileOutput::revert( - 0, - DelegateCallNotAllowed {}.abi_encode().into(), - input.reservoir, - )); - } - - let data = input.data; - if data.len() < 4 { - warn!( - target: "zone::precompile", - data_len = data.len(), - "ZoneTxContext called with insufficient data" - ); - return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); - } - - let selector: [u8; 4] = data[..4].try_into().expect("len >= 4"); - if selector != currentTxHashCall::SELECTOR { - warn!( - target: "zone::precompile", - ?selector, - "ZoneTxContext: unknown selector" - ); - return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); - } - - debug!(target: "zone::precompile", "ZoneTxContext: currentTxHash"); - - let Some(tx_hash) = current_tx_hash() else { - warn!( - target: "zone::precompile", - "ZoneTxContext: current transaction hash is not set" - ); - return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); - }; - let encoded = currentTxHashCall::abi_encode_returns(&tx_hash); - Ok(PrecompileOutput::new(20, encoded.into(), input.reservoir)) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_evm::{ - EvmInternals, - precompiles::{Precompile, PrecompileInput}, - }; - use alloy_primitives::{Address, U256}; - use revm::{ - Context, - database::{CacheDB, EmptyDB}, - }; - use tempo_chainspec::hardfork::TempoHardfork; - - type TestContext = Context< - revm::context::BlockEnv, - revm::context::TxEnv, - revm::context::CfgEnv, - CacheDB, - >; - - fn call_with_tx_hash(tx_hash: Option) -> PrecompileOutput { - let _guard = tx_hash.map(set_current_tx_hash); - let mut ctx: TestContext = - Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); - let calldata = currentTxHashCall {}.abi_encode(); - - ZoneTxContext::create() - .call(PrecompileInput { - data: &calldata, - gas: u64::MAX, - reservoir: 0, - caller: Address::ZERO, - value: U256::ZERO, - target_address: Address::ZERO, - is_static: true, - bytecode_address: Address::ZERO, - internals: EvmInternals::from_context(&mut ctx), - }) - .expect("precompile call should not fail") - } - - #[test] - fn returns_current_transaction_hash() { - let tx_hash = B256::repeat_byte(0x42); - let output = call_with_tx_hash(Some(tx_hash)); - - assert!(!output.is_revert()); - assert_eq!( - output.bytes, - currentTxHashCall::abi_encode_returns(&tx_hash) - ); - } - - #[test] - fn reverts_when_current_transaction_hash_is_not_set() { - let output = call_with_tx_hash(None); - - assert!(output.is_revert()); - assert!(output.bytes.is_empty()); - } -} diff --git a/crates/l1/src/state/provider.rs b/crates/l1/src/state/provider.rs index a01d0b092..a07e62432 100644 --- a/crates/l1/src/state/provider.rs +++ b/crates/l1/src/state/provider.rs @@ -279,6 +279,10 @@ impl L1StateProvider { } impl L1StorageReader for L1StateProvider { + fn portal_address(&self) -> Address { + self.portal_address + } + fn read_l1_storage( &self, account: Address, diff --git a/crates/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/e2e.rs b/crates/node/tests/it/e2e.rs index 9d796347a..a9a2909e8 100644 --- a/crates/node/tests/it/e2e.rs +++ b/crates/node/tests/it/e2e.rs @@ -14,8 +14,8 @@ use alloy_sol_types::SolCall; use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; use tempo_precompiles::PATH_USD_ADDRESS; use tempo_zone_contracts::{ - TEMPO_STATE_ADDRESS, TempoState, Withdrawal, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, - ZoneInbox, ZoneOutbox, + IZoneOutbox, TEMPO_STATE_ADDRESS, TempoState, Withdrawal, ZONE_INBOX_ADDRESS, + ZONE_OUTBOX_ADDRESS, ZoneInbox, }; use zone_l1::ChainTempoStateExt; @@ -417,7 +417,7 @@ async fn test_withdrawal_batch_finalization() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; - let zone_outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); + let zone_outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); let initial_batch_index = zone_outbox.withdrawalBatchIndex().call().await?; // Local test nodes finalize empty batches every eight zone blocks. @@ -538,7 +538,7 @@ async fn submit_withdrawal( dev_address: Address, amount: u128, ) -> eyre::Result { - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); let pending = outbox .requestWithdrawal( PATH_USD_ADDRESS, @@ -573,7 +573,7 @@ async fn test_withdrawal_requests_finalize_next_block() -> eyre::Result<()> { let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); let deposit_amount: u128 = 2_000_000; let deposit = fixture.make_deposit(PATH_USD_ADDRESS, dev_address, dev_address, deposit_amount); @@ -686,7 +686,7 @@ async fn test_withdrawal_requests_finalize_next_block() -> eyre::Result<()> { .await? .ok_or_else(|| eyre::eyre!("finalizeWithdrawalBatch tx {tx_hash} not found"))?; let finalize_call = - ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; + IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; assert_eq!( finalize_call.count, U256::from(1), @@ -708,7 +708,7 @@ async fn test_consecutive_withdrawal_blocks_joined_into_one_batch() -> eyre::Res let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); let deposit = fixture.make_deposit(PATH_USD_ADDRESS, dev_address, dev_address, 2_000_000); fixture.inject_deposits(zone.deposit_queue(), vec![deposit]); @@ -805,7 +805,7 @@ async fn test_consecutive_withdrawal_blocks_joined_into_one_batch() -> eyre::Res .await? .ok_or_else(|| eyre::eyre!("finalizeWithdrawalBatch tx {tx_hash} not found"))?; let finalize_call = - ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; + IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; assert_eq!( finalize_call.count, U256::from(2), @@ -823,7 +823,7 @@ async fn test_current_only_block_finalizes_at_batch_boundary() -> eyre::Result<( let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, provider.clone()); // Local test nodes finalize empty batches every eight zone blocks. const BATCH_INTERVAL_BLOCKS: u64 = 8; @@ -953,7 +953,7 @@ async fn test_current_only_block_finalizes_at_batch_boundary() -> eyre::Result<( .await? .ok_or_else(|| eyre::eyre!("finalizeWithdrawalBatch tx {tx_hash} not found"))?; let finalize_call = - ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; + IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref())?; assert_eq!(finalize_call.count, U256::from(1)); Ok(()) @@ -962,7 +962,7 @@ async fn test_current_only_block_finalizes_at_batch_boundary() -> eyre::Result<( /// Submit a signed L2 withdrawal request with an over-cap callback gas limit. /// /// This exercises the RPC transaction path: the transaction is accepted into a -/// zone block, reverts in `ZoneOutbox`, and does not enter the pending +/// zone block, reverts in `IZoneOutbox`, and does not enter the pending /// withdrawal queue. #[tokio::test(flavor = "multi_thread")] async fn test_withdrawal_request_rejects_over_max_callback_gas() -> eyre::Result<()> { @@ -971,7 +971,7 @@ async fn test_withdrawal_request_rejects_over_max_callback_gas() -> eyre::Result let (zone, mut fixture) = start_local_zone_with_fixture(10).await?; let (provider, dev_address) = local_dev_zone_account(&zone)?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); let deposit_amount: u128 = 1_000_000; let deposit = fixture.make_deposit(PATH_USD_ADDRESS, dev_address, dev_address, deposit_amount); diff --git a/crates/node/tests/it/restart_e2e.rs b/crates/node/tests/it/restart_e2e.rs index caf5f9c0a..59aaef617 100644 --- a/crates/node/tests/it/restart_e2e.rs +++ b/crates/node/tests/it/restart_e2e.rs @@ -9,7 +9,7 @@ use crate::utils::{L1TestNode, ZoneAccount, ZoneTestNode, spawn_sequencer}; use alloy::primitives::{Address, U256}; -use tempo_zone_contracts::{ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZoneOutbox, ZonePortal}; +use tempo_zone_contracts::{IZoneOutbox, ZONE_OUTBOX_ADDRESS, ZONE_TOKEN_ADDRESS, ZonePortal}; /// Longer timeout for real L1 tests. const L1_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -46,7 +46,7 @@ async fn wait_for_withdrawal_requested( sender: Address, amount: u128, ) -> eyre::Result { - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); let expected_amount = U256::from(amount); crate::utils::poll_until( @@ -459,7 +459,7 @@ async fn test_deferred_withdrawal_survives_sequencer_restart() -> eyre::Result<( let withdrawal_block = wait_for_withdrawal_requested(&zone, account.address(), withdrawal_amount).await?; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, zone.provider()); let same_block_finalized = outbox .BatchFinalized_filter() .from_block(withdrawal_block) diff --git a/crates/node/tests/it/tip403_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/tip403_transfers.rs b/crates/node/tests/it/tip403_transfers.rs index 4f226014b..dc7dbf330 100644 --- a/crates/node/tests/it/tip403_transfers.rs +++ b/crates/node/tests/it/tip403_transfers.rs @@ -16,7 +16,7 @@ use tempo_chainspec::spec::TEMPO_T0_BASE_FEE; use tempo_contracts::precompiles::ITIP20; use tempo_node::rpc::NATIVE_BALANCE_PLACEHOLDER; use tempo_precompiles::PATH_USD_ADDRESS; -use tempo_zone_contracts::{ZONE_OUTBOX_ADDRESS, ZoneOutbox}; +use tempo_zone_contracts::{IZoneOutbox, ZONE_OUTBOX_ADDRESS}; use crate::utils::{ DEFAULT_TIMEOUT, TEST_MNEMONIC, TIP20_TX_GAS, WITHDRAWAL_TX_GAS, approve_outbox, @@ -120,7 +120,7 @@ async fn test_deposit_then_request_withdrawal() -> eyre::Result<()> { let withdrawal_amount: u128 = 250_000; - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &provider); let withdrawal_fee = outbox.calculateWithdrawalFee(0).call().await?; let gas_buffer = u128::from(TIP20_TX_GAS) diff --git a/crates/node/tests/it/utils.rs b/crates/node/tests/it/utils.rs index 9fe48f385..cc58bcfb3 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) @@ -2402,7 +2534,7 @@ impl ZoneAccount { args: WithdrawalArgs, ) -> eyre::Result<()> { use tempo_contracts::precompiles::ITIP20; - use tempo_zone_contracts::{ZONE_OUTBOX_ADDRESS, ZoneOutbox}; + use tempo_zone_contracts::{IZoneOutbox, ZONE_OUTBOX_ADDRESS}; // Approve outbox for this token ITIP20::new(token, &self.l2_provider) @@ -2416,7 +2548,7 @@ impl ZoneAccount { let to = args.to.unwrap_or(self.address); let fallback_recipient = args.fallback_recipient.unwrap_or(self.address); - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &self.l2_provider); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &self.l2_provider); let receipt = outbox .requestWithdrawal( token, @@ -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/payload/src/builder.rs b/crates/payload/src/builder.rs index 34dccf3f3..376e594c3 100644 --- a/crates/payload/src/builder.rs +++ b/crates/payload/src/builder.rs @@ -521,7 +521,7 @@ where /// Build the `finalizeWithdrawalBatch(count)` system transaction. /// /// This must be the **last** transaction in each finalizing zone block. It calls -/// [`ZoneOutbox.finalizeWithdrawalBatch`](crate::abi::ZoneOutbox) which: +/// [`IZoneOutbox.finalizeWithdrawalBatch`](crate::abi::IZoneOutbox) which: /// - Collects up to `count` pending withdrawals /// - Builds the withdrawal hash chain (oldest outermost) /// - Increments `withdrawalBatchIndex` @@ -535,7 +535,7 @@ pub(crate) fn build_finalize_withdrawal_batch_tx( block_number: u64, encrypted_senders: Vec, ) -> Recovered { - let calldata = abi::ZoneOutbox::finalizeWithdrawalBatchCall { + let calldata = abi::IZoneOutbox::finalizeWithdrawalBatchCall { count, blockNumber: block_number, encryptedSenders: encrypted_senders, @@ -579,11 +579,11 @@ where fn read_pending_withdrawals_from_outbox( builder: &mut B, block_number: u64, -) -> Result, PayloadBuilderError> +) -> Result, PayloadBuilderError> where B: BlockBuilder, { - let calldata = abi::ZoneOutbox::getPendingWithdrawalsCall {}.abi_encode(); + let calldata = abi::IZoneOutbox::getPendingWithdrawalsCall {}.abi_encode(); let output = execute_outbox_view_call( builder, calldata.into(), @@ -591,7 +591,7 @@ where "getPendingWithdrawals", )?; - abi::ZoneOutbox::getPendingWithdrawalsCall::abi_decode_returns(&output).map_err(|err| { + abi::IZoneOutbox::getPendingWithdrawalsCall::abi_decode_returns(&output).map_err(|err| { PayloadBuilderError::Internal(reth_errors::RethError::msg(format!( "failed to decode getPendingWithdrawals return data: {err}" ))) diff --git a/crates/precompiles/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/ecies.rs b/crates/precompiles/src/ecies.rs index d1f6e6770..77a9c38c6 100644 --- a/crates/precompiles/src/ecies.rs +++ b/crates/precompiles/src/ecies.rs @@ -12,6 +12,7 @@ use k256::{ AffinePoint, ProjectivePoint, Scalar, elliptic_curve::{PrimeField, sec1::ToEncodedPoint}, }; +use tempo_zone_contracts::Withdrawal; use crate::{ aes_gcm::decrypt_aes_gcm, @@ -24,8 +25,23 @@ pub const ENCRYPTED_PAYLOAD_PLAINTEXT_SIZE: usize = 64; /// Plaintext size for authenticated-withdrawal sender reveals: 20 bytes (sender) + 32 bytes (tx hash). pub const AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE: usize = 52; +/// Encoded size of a compressed secp256k1 public key. +pub const COMPRESSED_PUBLIC_KEY_SIZE: usize = 33; + /// Total encoded size of `encryptedSender`. -pub const AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE: usize = 33 + 12 + 52 + 16; +pub const AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE: usize = + COMPRESSED_PUBLIC_KEY_SIZE + 12 + AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE + 16; + +/// Decode a SEC1-compressed secp256k1 public key. +pub(crate) fn decode_compressed_public_key(encoded: &[u8]) -> Option { + let encoded: &[u8; COMPRESSED_PUBLIC_KEY_SIZE] = encoded.try_into().ok()?; + let parity = encoded[0]; + if !matches!(parity, 0x02 | 0x03) { + return None; + } + let x: &[u8; 32] = encoded[1..].try_into().ok()?; + recover_point(x, parity) +} const AUTH_WITHDRAWAL_EPHEMERAL_DOMAIN: &[u8] = b"tempo-zone-authenticated-withdrawal-ephemeral-v1"; const AUTH_WITHDRAWAL_NONCE_DOMAIN: &[u8] = b"tempo-zone-authenticated-withdrawal-nonce-v1"; @@ -229,16 +245,7 @@ fn encrypt_authenticated_withdrawal_with_material( eph_scalar: &Scalar, nonce_bytes: [u8; 12], ) -> Option> { - if reveal_to.len() != 33 { - return None; - } - let parity = reveal_to[0]; - if parity != 0x02 && parity != 0x03 { - return None; - } - - let reveal_to_x = B256::from_slice(&reveal_to[1..]); - let reveal_pub = recover_point(&reveal_to_x.0, parity)?; + let reveal_pub = decode_compressed_public_key(reveal_to)?; let eph_pub = AffinePoint::from(ProjectivePoint::GENERATOR * *eph_scalar); let eph_encoded = eph_pub.to_encoded_point(true); @@ -575,10 +582,7 @@ pub fn build_authenticated_withdrawal_plaintext( sender: &Address, tx_hash: &B256, ) -> [u8; AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE] { - let mut buf = [0u8; AUTHENTICATED_WITHDRAWAL_PLAINTEXT_SIZE]; - buf[..20].copy_from_slice(sender.as_slice()); - buf[20..].copy_from_slice(tx_hash.as_slice()); - buf + Withdrawal::authenticated_sender_plaintext(*sender, *tx_hash) } /// Build the 84-byte HKDF info parameter: `[portal(20) | key_index(32) | eph_pub_x(32)]`. diff --git a/crates/precompiles/src/error.rs b/crates/precompiles/src/error.rs index 8e849855f..2293e4e25 100644 --- a/crates/precompiles/src/error.rs +++ b/crates/precompiles/src/error.rs @@ -3,10 +3,10 @@ use alloy_sol_types::{SolError, SolInterface}; use revm::precompile::{PrecompileOutput, PrecompileResult}; use tempo_precompiles::IntoPrecompileResult; +use tempo_zone_contracts::{ZoneOutboxError, ZonePortalError}; use crate::{tip20_factory::ZoneTokenFactoryError, tip403_proxy::ReadOnlyRegistry}; -// Required by the `#[contract]` proc macro expansion. pub use tempo_precompiles::error::{Result, TempoPrecompileError}; /// Result type for zone-native precompile operations. @@ -23,19 +23,26 @@ pub enum ZonePrecompileError { /// An error originating in the upstream Tempo precompiles crate. #[error(transparent)] Tempo(TempoPrecompileError), + /// Error from the ZonePortal. + #[error("ZonePortal error: {0:?}")] + Portal(ZonePortalError), + /// Error from the ZoneOutbox. + #[error("ZoneOutbox error: {0:?}")] + Outbox(ZoneOutboxError), /// Error from the zone TIP-20 factory. #[error("Zone TIP-20 factory error: {0:?}")] ZoneTokenFactory(ZoneTokenFactoryError), - /// Error from the zone TIP-403 registry. + /// Error from the read-only zone TIP-403 registry. #[error("Zone TIP-403 registry error: {0:?}")] Zone403Registry(ReadOnlyRegistry), } impl IntoPrecompileResult for ZonePrecompileError { - #[inline] fn into_precompile_result(self, gas: u64, reservoir: u64) -> PrecompileResult { let data = match self { Self::Tempo(error) => return error.into_precompile_result(gas, reservoir), + Self::Portal(error) => error.abi_encode(), + Self::Outbox(error) => error.abi_encode(), Self::ZoneTokenFactory(error) => error.abi_encode(), Self::Zone403Registry(error) => error.abi_encode(), }; @@ -46,23 +53,33 @@ impl IntoPrecompileResult for ZonePrecompileError { #[cfg(test)] mod tests { use super::*; - use revm::precompile::{PrecompileHalt, PrecompileStatus}; + use alloy_primitives::U256; + use alloy_sol_types::SolError; + use revm::precompile::PrecompileHalt; + use tempo_zone_contracts::IZoneOutbox; #[test] - fn zone_errors_revert_with_abi_encoded_data() { - let factory_error = ZoneTokenFactoryError::only_zone_inbox(); + fn outbox_errors_revert_with_exact_abi_data() { for (error, expected) in [ ( - ZonePrecompileError::from(factory_error.clone()), - factory_error.abi_encode(), + ZoneOutboxError::GasLimitTooHigh(IZoneOutbox::GasLimitTooHigh {}), + IZoneOutbox::GasLimitTooHigh {}.abi_encode(), ), ( - ZonePrecompileError::from(ReadOnlyRegistry {}), - ReadOnlyRegistry {}.abi_encode(), + ZoneOutboxError::InvalidWithdrawalCount(IZoneOutbox::InvalidWithdrawalCount { + actual: U256::from(1), + expected: U256::from(2), + }), + IZoneOutbox::InvalidWithdrawalCount { + actual: U256::from(1), + expected: U256::from(2), + } + .abi_encode(), ), ] { - let output = error.into_precompile_result(10, 20).unwrap(); - + let output = ZonePrecompileError::from(error) + .into_precompile_result(10, 20) + .unwrap(); assert!(output.is_revert()); assert_eq!(output.gas_used, 10); assert_eq!(output.reservoir, 20); @@ -71,15 +88,18 @@ mod tests { } #[test] - fn tempo_error_preserves_upstream_behavior() { - let output = ZonePrecompileError::from(TempoPrecompileError::OutOfGas) + fn other_zone_and_tempo_errors_preserve_conversion_behavior() { + let factory_error = ZoneTokenFactoryError::only_zone_inbox(); + let output = ZonePrecompileError::from(factory_error.clone()) .into_precompile_result(10, 20) .unwrap(); + assert!(output.is_revert()); + assert_eq!(output.bytes, factory_error.abi_encode()); - assert!(matches!( - output.status, - PrecompileStatus::Halt(PrecompileHalt::OutOfGas) - )); + let output = ZonePrecompileError::from(TempoPrecompileError::OutOfGas) + .into_precompile_result(10, 20) + .unwrap(); + assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); assert_eq!(output.reservoir, 20); } } diff --git a/crates/precompiles/src/execution.rs b/crates/precompiles/src/execution.rs new file mode 100644 index 000000000..e31edf9ff --- /dev/null +++ b/crates/precompiles/src/execution.rs @@ -0,0 +1,529 @@ +//! Shared execution for zone-native and L1-backed Tempo precompiles. +//! +//! Both helpers install an EVM-backed [`StorageCtx`], apply zone-specific [`CallRules`], and +//! forward admitted calls without changing their calldata or caller. +//! +//! # Execution modes +//! +//! - [`create_local_precompile`] executes against ordinary zone-local EVM state. +//! - [`create_l1_backed_precompile`] reads the finalized Tempo block recorded in `TempoState`, +//! resolves the Tempo hardfork at that same block, and overlays selected policy storage from L1. +//! +//! # Call ordering +//! +//! 1. 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. Calls that do not require L1 execute immediately against zone-local state. +//! 5. For calls requiring L1, resolve the anchor, hardfork, and storage overlay, then apply the +//! L1-backed rules phase. +//! 6. Forward the original calldata and caller, applying any configured fixed gas charge. +//! +//! Rule-level rejections include calldata input gas. Calls without a fixed charge retain normal +//! provider metering, while successful fixed-price calls report exactly the configured charge. + +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 and active hardfork are resolved from the local `TempoState` anchor during execution. +#[derive(Clone)] +pub(crate) struct L1BackedPrecompileEnv

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

L1BackedPrecompileEnv

{ + /// Capture the configuration and providers shared by L1-backed calls. + pub(crate) fn new( + cfg: &revm::context::CfgEnv, + l1_reader: P, + actions: StorageActions, + non_creditable_slots: Rc>, + ) -> Self { + Self { + cfg: cfg.clone(), + l1_reader, + actions, + non_creditable_slots, + } + } +} + +/// 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, + /// Whether the EVM call is static. + pub(crate) is_static: 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(), + is_static: input.is_static, + } + } + + 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), +} + +impl CallCheck { + /// Reject a call with a typed Tempo or zone-native precompile error. + pub(crate) fn from_error(error: impl Into) -> Self { + Self::Return(StorageCtx::default().error_result(error.into())) + } +} + +/// Selector-, caller-, and call-context-dependent rules evaluated by centralized precompile +/// execution before invoking the implementation. +/// +/// The local phase always runs before optional finalized-L1 state resolution. Calls selected for +/// anchored execution then run the L1-backed phase against that exact overlay. Rules may enforce +/// admission policy and duplicate cheap business checks as fail-fast preflight, but the precompile +/// implementation remains responsible for its canonical business invariants. +pub(crate) trait CallRules: 'static { + /// Return the fixed gas charge for this selector, if one applies. + fn fixed_gas(&self, _selector: Option<[u8; 4]>) -> Option { + None + } + + /// Return whether this selector requires execution against the finalized-L1 storage overlay. + /// Calls returning `false` remain entirely local and execute after the local rules phase. + fn requires_l1(&self, _selector: Option<[u8; 4]>) -> bool { + true + } + + /// Apply rules using only calldata, caller, call context, and ordinary zone-local state. + /// + /// This phase always runs before optional L1 anchor resolution. It may reject invalid calls + /// 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 when [`Self::requires_l1`] returns `true`, after the anchor and overlay + /// have been resolved. + fn check_with_l1_backed_state(&self, _call: ZoneCall<'_>) -> CallCheck { + CallCheck::Continue + } +} + +/// 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 with selector-dependent finalized-L1 backing. +/// +/// The helper rejects delegate calls before storage access and applies the local rules phase. +/// Selectors for which [`CallRules::requires_l1`] returns `false` execute immediately against local +/// state. Other selectors read the `TempoState` anchor once and construct +/// [`ZonePrecompileStorageProvider`] with that exact block. Construction is fallible because the +/// provider resolves the active hardfork from the same anchor. Any anchor, hardfork, or L1 storage +/// failure is returned as a precompile error rather than falling back to local or latest state. +/// +/// Admitted calls retain their original calldata and caller in either execution mode. +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); + } + } + + if !rules.requires_l1(call.selector()) { + let exec_result = StorageCtx::enter(&mut inner, || execute(call.data, call.caller)); + return apply_fixed_gas(exec_result, fixed_gas); + } + + let mut storage = match ZonePrecompileStorageProvider::try_new(inner, l1_reader.clone()) { + Ok(storage) => storage, + Err(err) => return err.into_precompile_result(), + }; + + 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..e14546327 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,20 +45,200 @@ pub mod dispatch { }; } -pub mod policy; +mod execution; +pub mod outbox; +pub mod storage; pub mod tempo_state; pub mod tip20_factory; pub mod tip403_proxy; +pub mod tx_context; pub mod ztip20; pub use aes_gcm::{AES_GCM_DECRYPT_ADDRESS, AesGcmDecrypt}; pub use chaum_pedersen::{CHAUM_PEDERSEN_VERIFY_ADDRESS, ChaumPedersenVerify}; -pub use tempo_state::{L1StorageReader, TempoState}; +pub use outbox::ZoneOutbox; +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 tempo_zone_contracts::ZONE_TX_CONTEXT_ADDRESS; +use zone_primitives::constants::{TEMPO_STATE_ADDRESS, ZONE_OUTBOX_ADDRESS}; + +/// Register zone-native and currently supported Tempo precompiles. +/// +/// - **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 outbox_env = l1_env.clone(); + let portal = l1_reader.portal_address(); + precompiles.apply_precompile(&ZONE_OUTBOX_ADDRESS, move |_| { + Some(execution::create_l1_backed_precompile( + "ZoneOutbox", + outbox_env.clone(), + outbox::ZoneOutboxRules::new(portal), + |data, caller| ZoneOutbox::new().call(data, caller), + )) + }); + precompiles.apply_precompile(&ZONE_TX_CONTEXT_ADDRESS, |_| { + Some(tx_context::create_precompile()) + }); + + let tip403_env = l1_env.clone(); + precompiles.apply_precompile(&ZONE_TIP403_PROXY_ADDRESS, move |_| { + 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/outbox/dispatch.rs b/crates/precompiles/src/outbox/dispatch.rs new file mode 100644 index 000000000..47818a9a6 --- /dev/null +++ b/crates/precompiles/src/outbox/dispatch.rs @@ -0,0 +1,64 @@ +//! ABI dispatch for the [`ZoneOutbox`] precompile. + +use alloy_primitives::{Address, U256}; +use revm::precompile::PrecompileResult; +use tempo_precompiles::{Precompile, charge_input_cost, dispatch, storage::Handler}; +use tempo_zone_contracts::{ILegacyZoneOutbox, IZoneOutbox}; +use zone_primitives::constants::{MAX_WITHDRAWAL_GAS_LIMIT, ZONE_CONFIG_ADDRESS}; + +use crate::{ + dispatch::{metadata, mutate, mutate_void, view}, + ecies::{AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, COMPRESSED_PUBLIC_KEY_SIZE}, + tx_context, +}; + +use super::{MAX_CALLBACK_DATA_SIZE, MAX_GAS_FEE_RATE, WITHDRAWAL_BASE_GAS, ZoneOutbox}; + +impl Precompile for ZoneOutbox { + fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult { + if let Some(err) = charge_input_cost(&mut self.storage, calldata) { + return err; + } + + dispatch!(calldata, |call| match call { + IZoneOutbox::IZoneOutboxCalls { + config(_) => metadata::(|| Ok(ZONE_CONFIG_ADDRESS)), + tempoGasRate(_) => metadata::(|| self.tempo_gas_rate.read()), + maxWithdrawalsPerBlock(_) => metadata::(|| self.max_withdrawals_per_block.read()), + lastBatch(_) => metadata::(|| self.last_batch()), + withdrawalBatchIndex(_) => metadata::(|| self.withdrawal_batch_index.read()), + lastFinalizedTimestamp(_) => metadata::(|| self.last_finalized_timestamp.read()), + nextWithdrawalIndex(_) => metadata::(|| self.next_withdrawal_index.read()), + lastFallbackNonce(_) => metadata::(|| self.last_fallback_nonce.read()), + pendingWithdrawalsCount(_) => metadata::(|| self.pending_withdrawals_count()), + getPendingWithdrawals(_) => metadata::(|| self.get_pending_withdrawals()), + calculateWithdrawalFee(call) => view(call, |call| self.calculate_withdrawal_fee(call.gasLimit)), + MAX_CALLBACK_DATA_SIZE(_) => metadata::(|| Ok(U256::from(MAX_CALLBACK_DATA_SIZE))), + MAX_WITHDRAWAL_GAS_LIMIT(_) => metadata::(|| Ok(MAX_WITHDRAWAL_GAS_LIMIT)), + MAX_GAS_FEE_RATE(_) => metadata::(|| Ok(MAX_GAS_FEE_RATE)), + WITHDRAWAL_BASE_GAS(_) => metadata::(|| Ok(WITHDRAWAL_BASE_GAS)), + REVEAL_TO_KEY_LENGTH(_) => metadata::(|| Ok(U256::from(COMPRESSED_PUBLIC_KEY_SIZE))), + AUTHENTICATED_WITHDRAWAL_CIPHERTEXT_LENGTH(_) => metadata::(|| Ok(U256::from(AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE))), + setTempoGasRate(call) => mutate_void(call, msg_sender, |_, call| self.set_tempo_gas_rate(call)), + setMaxWithdrawalsPerBlock(call) => mutate_void(call, msg_sender, |_, call| self.set_max_withdrawals_per_block(call)), + requestWithdrawal(call) => mutate_void(call, msg_sender, |sender, call| { + self.request_withdrawal( + sender, + tx_context::current_tx_hash().unwrap_or_default(), + call, + ) + }), + enqueueDepositBounceBack(call) => mutate_void(call, msg_sender, |sender, call| self.enqueue_deposit_bounce_back(sender, call)), + consumeFallbackRecipient(call) => mutate(call, msg_sender, |sender, call| self.consume_fallback_recipient(sender, call.fallbackNonce)), + finalizeWithdrawalBatch(call) => mutate(call, msg_sender, |_, call| self.finalize_withdrawal_batch(call)), + } + ILegacyZoneOutbox::ILegacyZoneOutboxCalls { + requestWithdrawal(call) => mutate_void(call, msg_sender, |sender, call| self.request_withdrawal( + sender, + tx_context::current_tx_hash().unwrap_or_default(), + call.into(), + )), + } + }) + } +} diff --git a/crates/precompiles/src/outbox/mod.rs b/crates/precompiles/src/outbox/mod.rs new file mode 100644 index 000000000..0939f81ec --- /dev/null +++ b/crates/precompiles/src/outbox/mod.rs @@ -0,0 +1,492 @@ +//! Native `ZoneOutbox` precompile. +//! +mod dispatch; +#[cfg(test)] +mod tests; + +use alloc::vec::Vec; + +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_sol_types::SolCall; +use revm::interpreter::instructions::utility::IntoAddress; +use tempo_precompiles::{ + Result as TempoResult, + error::TempoPrecompileError, + storage::{Handler, Mapping, StorageCtx}, + tip20::{ITIP20, TIP20Token}, +}; +use tempo_precompiles_macros::{Storable, contract}; +use tempo_zone_contracts::{ + ILegacyZoneOutbox, IZoneOutbox, Withdrawal, ZoneOutboxError, ZoneOutboxEvent, ZonePortalError, + portal_token_config_slot, +}; +use zone_primitives::constants::{ + MAX_WITHDRAWAL_GAS_LIMIT, PORTAL_SEQUENCER_SLOT, ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, +}; + +use crate::{ + ZoneResult, + ecies::{AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE, decode_compressed_public_key}, + execution::{CallCheck, CallRules, ZoneCall}, +}; + +const MAX_CALLBACK_DATA_SIZE: usize = 1024; +const MAX_GAS_FEE_RATE: u128 = 1_000_000_000_000_000_000; +const WITHDRAWAL_BASE_GAS: u64 = 50_000; + +const SEQUENCER_SELECTORS: &[[u8; 4]] = &[ + IZoneOutbox::setTempoGasRateCall::SELECTOR, + IZoneOutbox::setMaxWithdrawalsPerBlockCall::SELECTOR, + IZoneOutbox::finalizeWithdrawalBatchCall::SELECTOR, +]; +const WITHDRAWAL_SELECTORS: &[[u8; 4]] = &[ + IZoneOutbox::requestWithdrawalCall::SELECTOR, + ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR, +]; + +/// Admission checks that require the finalized `ZonePortal` state. +pub(crate) struct ZoneOutboxRules { + portal: Address, +} + +impl ZoneOutboxRules { + pub(crate) fn new(portal: Address) -> Self { + Self { portal } + } +} + +impl CallRules for ZoneOutboxRules { + fn requires_l1(&self, selector: Option<[u8; 4]>) -> bool { + selector.is_some_and(|selector| { + SEQUENCER_SELECTORS.contains(&selector) || WITHDRAWAL_SELECTORS.contains(&selector) + }) + } + + fn check_with_local_state(&self, call: ZoneCall<'_>) -> CallCheck { + if call.is_static + && call.selector().is_some_and(|selector| { + self.requires_l1(Some(selector)) + || matches!( + selector, + IZoneOutbox::enqueueDepositBounceBackCall::SELECTOR + | IZoneOutbox::consumeFallbackRecipientCall::SELECTOR + ) + }) + { + return CallCheck::from_error(ZoneOutboxError::static_call_not_allowed()); + } + + let Some(withdrawal) = decode_withdrawal(call) else { + return CallCheck::Continue; + }; + match check_withdrawal_request(&withdrawal) { + Ok(()) => CallCheck::Continue, + Err(err) => CallCheck::from_error(err), + } + } + + fn check_with_l1_backed_state(&self, call: ZoneCall<'_>) -> CallCheck { + let Some(selector) = call.selector() else { + return CallCheck::Continue; + }; + if SEQUENCER_SELECTORS.contains(&selector) && call.caller != Address::ZERO { + let sequencer = + match StorageCtx::default().sload(self.portal, PORTAL_SEQUENCER_SLOT.into()) { + Ok(value) => value.into_address(), + Err(err) => return CallCheck::from_error(err), + }; + if sequencer != call.caller { + return CallCheck::from_error(ZoneOutboxError::only_sequencer()); + } + } + + if let Some(withdrawal) = decode_withdrawal(call) { + let slot = portal_token_config_slot(withdrawal.token).into(); + match StorageCtx::default().sload(self.portal, slot) { + Ok(value) if value.byte(0) != 0 => {} + Ok(_) => return CallCheck::from_error(ZonePortalError::token_not_enabled()), + Err(err) => return CallCheck::from_error(err), + } + } + CallCheck::Continue + } +} + +#[contract(addr = ZONE_OUTBOX_ADDRESS)] +pub struct ZoneOutbox { + tempo_gas_rate: u128, + next_withdrawal_index: u64, + withdrawal_queue_hash: B256, + withdrawal_batch_index: u64, + max_withdrawals_per_block: u32, + withdrawals_this_block: u32, + current_block_number: u64, + last_finalized_timestamp: u64, + pending_withdrawals: Vec, + last_fallback_nonce: u64, + fallback_recipients: Mapping, +} + +impl ZoneOutbox { + /// Initializes the precompile account code. + pub fn initialize(&mut self) -> TempoResult<()> { + self.__initialize() + } + + fn calculate_fee_unchecked(&self, gas_limit: u64) -> TempoResult { + let gas = u128::from(WITHDRAWAL_BASE_GAS) + u128::from(gas_limit); + gas.checked_mul(self.tempo_gas_rate.read()?) + .ok_or_else(TempoPrecompileError::under_overflow) + } + + fn calculate_withdrawal_fee(&self, gas_limit: u64) -> ZoneResult { + validate_gas_limit(gas_limit)?; + self.calculate_fee_unchecked(gas_limit).map_err(Into::into) + } + + fn enforce_withdrawal_block_cap(&mut self) -> ZoneResult<()> { + let max = self.max_withdrawals_per_block.read()?; + if max == 0 { + return Ok(()); + } + + let block_number = self.storage.block_number(); + if block_number != self.current_block_number.read()? { + self.current_block_number.write(block_number)?; + self.withdrawals_this_block.write(0)?; + } + + let withdrawals = self.withdrawals_this_block.read()?; + if withdrawals >= max { + return Err(ZoneOutboxError::too_many_withdrawals_this_block().into()); + } + self.withdrawals_this_block.write( + withdrawals + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?, + )?; + Ok(()) + } + + fn enqueue(&mut self, pending: PendingWithdrawal) -> ZoneResult<()> { + let index = self.next_withdrawal_index.read()?; + self.emit_event(pending.requested_event(index))?; + + self.pending_withdrawals.push(pending)?; + self.next_withdrawal_index.write( + index + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?, + )?; + Ok(()) + } + + fn request_withdrawal( + &mut self, + caller: Address, + current_tx_hash: B256, + call: IZoneOutbox::requestWithdrawalCall, + ) -> ZoneResult<()> { + if current_tx_hash.is_zero() { + return Err(ZoneOutboxError::invalid_current_tx_hash().into()); + } + check_withdrawal_request(&call)?; + self.enforce_withdrawal_block_cap()?; + + // If necessary, validate reveal + if !call.revealTo.is_empty() && decode_compressed_public_key(&call.revealTo).is_none() { + return Err(ZoneOutboxError::invalid_reveal_to().into()); + } + + let fee = self.calculate_fee_unchecked(call.gasLimit)?; + let total_burn = call + .amount + .checked_add(fee) + .ok_or_else(TempoPrecompileError::under_overflow)?; + let mut zone_token = TIP20Token::from_address(call.token)?; + let amount = U256::from(total_burn); + if !zone_token.transfer_from( + self.address, + ITIP20::transferFromCall { + from: caller, + to: self.address, + amount, + }, + )? { + return Err(ZoneOutboxError::transfer_failed().into()); + } + zone_token.burn(self.address, ITIP20::burnCall { amount })?; + + let fallback_nonce = self + .last_fallback_nonce + .read()? + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?; + self.last_fallback_nonce.write(fallback_nonce)?; + self.fallback_recipients[fallback_nonce].write(call.fallbackRecipient)?; + self.enqueue(PendingWithdrawal::from_request( + caller, + current_tx_hash, + fee, + fallback_nonce, + call, + )) + } + + fn enqueue_deposit_bounce_back( + &mut self, + caller: Address, + call: IZoneOutbox::enqueueDepositBounceBackCall, + ) -> ZoneResult<()> { + if caller != ZONE_INBOX_ADDRESS { + return Err(ZoneOutboxError::only_zone_inbox().into()); + } + + self.enqueue(PendingWithdrawal::from_bounce_back(call)) + } + + fn consume_fallback_recipient(&mut self, caller: Address, nonce: u64) -> ZoneResult

{ + if caller != ZONE_INBOX_ADDRESS { + return Err(ZoneOutboxError::only_zone_inbox().into()); + } + let recipient = self.fallback_recipients[nonce].read()?; + if recipient.is_zero() { + return Err(ZoneOutboxError::invalid_fallback_recipient().into()); + } + self.fallback_recipients[nonce].delete()?; + Ok(recipient) + } + + fn finalize_withdrawal_batch( + &mut self, + call: IZoneOutbox::finalizeWithdrawalBatchCall, + ) -> ZoneResult { + if call.blockNumber != self.storage.block_number() { + return Err(ZoneOutboxError::invalid_block_number().into()); + } + + let count = self.pending_withdrawals.len()?; + if call.count != U256::from(count) { + return Err( + ZoneOutboxError::invalid_withdrawal_count(call.count, U256::from(count)).into(), + ); + } + if call.encryptedSenders.len() != count { + return Err(ZoneOutboxError::invalid_encrypted_sender_count( + U256::from(call.encryptedSenders.len()), + U256::from(count), + ) + .into()); + } + + let mut withdrawal_queue_hash = B256::ZERO; + if count > 0 { + withdrawal_queue_hash = zone_primitives::constants::EMPTY_SENTINEL; + for (index, encrypted_sender) in call.encryptedSenders.into_iter().enumerate().rev() { + let pending = self.pending_withdrawals[index].read()?; + let withdrawal = pending.into_withdrawal(encrypted_sender)?; + withdrawal_queue_hash = withdrawal.hash_with_tail(withdrawal_queue_hash); + } + self.pending_withdrawals.delete()?; + } + + let next_batch_index = self + .withdrawal_batch_index + .read()? + .checked_add(1) + .ok_or_else(TempoPrecompileError::under_overflow)?; + self.withdrawal_batch_index.write(next_batch_index)?; + self.withdrawal_queue_hash.write(withdrawal_queue_hash)?; + self.last_finalized_timestamp + .write(self.storage.timestamp().to::())?; + self.emit_event(ZoneOutboxEvent::batch_finalized( + withdrawal_queue_hash, + next_batch_index, + ))?; + Ok(withdrawal_queue_hash) + } + + fn set_tempo_gas_rate(&mut self, call: IZoneOutbox::setTempoGasRateCall) -> ZoneResult<()> { + if call._tempoGasRate > MAX_GAS_FEE_RATE { + return Err(ZoneOutboxError::gas_fee_rate_too_high().into()); + } + self.tempo_gas_rate.write(call._tempoGasRate)?; + self.emit_event(ZoneOutboxEvent::tempo_gas_rate_updated(call._tempoGasRate))?; + Ok(()) + } + + fn set_max_withdrawals_per_block( + &mut self, + call: IZoneOutbox::setMaxWithdrawalsPerBlockCall, + ) -> TempoResult<()> { + self.max_withdrawals_per_block + .write(call._maxWithdrawalsPerBlock)?; + self.emit_event(ZoneOutboxEvent::max_withdrawals_per_block_updated( + call._maxWithdrawalsPerBlock, + ))?; + Ok(()) + } + + fn pending_withdrawals_count(&self) -> TempoResult { + self.pending_withdrawals.len().map(|val| U256::from(val)) + } + + fn get_pending_withdrawals(&self) -> TempoResult> { + let len = self.pending_withdrawals.len()?; + let mut pending = Vec::with_capacity(len); + for index in 0..len { + pending.push(self.pending_withdrawals[index].read()?.into()); + } + Ok(pending) + } + + fn last_batch(&self) -> TempoResult { + Ok(IZoneOutbox::LastBatch { + withdrawalQueueHash: self.withdrawal_queue_hash.read()?, + withdrawalBatchIndex: self.withdrawal_batch_index.read()?, + }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Storable)] +struct PendingWithdrawal { + token: Address, + sender: Address, + tx_hash: B256, + to: Address, + amount: u128, + fee: u128, + memo: B256, + gas_limit: u64, + fallback_nonce: u64, + callback_data: Bytes, + reveal_to: Bytes, +} + +impl PendingWithdrawal { + fn from_request( + sender: Address, + tx_hash: B256, + fee: u128, + fallback_nonce: u64, + call: IZoneOutbox::requestWithdrawalCall, + ) -> Self { + Self { + token: call.token, + sender, + tx_hash, + to: call.to, + amount: call.amount, + fee, + memo: call.memo, + gas_limit: call.gasLimit, + fallback_nonce, + callback_data: call.data, + reveal_to: call.revealTo, + } + } + + fn from_bounce_back(call: IZoneOutbox::enqueueDepositBounceBackCall) -> Self { + Self { + token: call.token, + to: call.bouncebackRecipient, + amount: call.amount, + ..Default::default() + } + } + + fn requested_event(&self, index: u64) -> ZoneOutboxEvent { + ZoneOutboxEvent::withdrawal_requested( + index, + self.sender, + self.token, + self.to, + self.amount, + self.fee, + self.memo, + self.gas_limit, + self.fallback_nonce, + self.callback_data.clone(), + self.reveal_to.clone(), + ) + } + + fn into_withdrawal(self, encrypted_sender: Bytes) -> ZoneResult { + let expected = if self.reveal_to.is_empty() { + 0 + } else { + AUTHENTICATED_WITHDRAWAL_ENCRYPTED_SIZE + }; + if encrypted_sender.len() != expected { + return Err(ZoneOutboxError::invalid_encrypted_sender_length( + U256::from(encrypted_sender.len()), + U256::from(expected), + ) + .into()); + } + + let sender_tag = Withdrawal::sender_tag(self.sender, self.tx_hash); + Ok(Withdrawal { + token: self.token, + senderTag: sender_tag, + to: self.to, + amount: self.amount, + fee: self.fee, + memo: self.memo, + gasLimit: self.gas_limit, + fallbackNonce: self.fallback_nonce, + callbackData: self.callback_data, + encryptedSender: encrypted_sender, + }) + } +} + +impl From for IZoneOutbox::PendingWithdrawal { + fn from(pending: PendingWithdrawal) -> Self { + Self { + token: pending.token, + sender: pending.sender, + txHash: pending.tx_hash, + to: pending.to, + amount: pending.amount, + fee: pending.fee, + memo: pending.memo, + gasLimit: pending.gas_limit, + fallbackNonce: pending.fallback_nonce, + callbackData: pending.callback_data, + revealTo: pending.reveal_to, + } + } +} + +fn decode_withdrawal(call: ZoneCall<'_>) -> Option { + match call.selector()? { + IZoneOutbox::requestWithdrawalCall::SELECTOR => { + IZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]).ok() + } + ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR => { + ILegacyZoneOutbox::requestWithdrawalCall::abi_decode_raw_validate(&call.data[4..]) + .ok() + .map(Into::into) + } + _ => None, + } +} + +fn validate_gas_limit(gas_limit: u64) -> ZoneResult<()> { + if gas_limit > MAX_WITHDRAWAL_GAS_LIMIT { + return Err(ZoneOutboxError::gas_limit_too_high().into()); + } + Ok(()) +} + +fn check_withdrawal_request(call: &IZoneOutbox::requestWithdrawalCall) -> ZoneResult<()> { + if call.fallbackRecipient.is_zero() { + return Err(ZoneOutboxError::invalid_fallback_recipient().into()); + } + validate_gas_limit(call.gasLimit)?; + if call.data.len() > MAX_CALLBACK_DATA_SIZE { + return Err(ZoneOutboxError::callback_data_too_large().into()); + } + Ok(()) +} diff --git a/crates/precompiles/src/outbox/tests.rs b/crates/precompiles/src/outbox/tests.rs new file mode 100644 index 000000000..914e018a6 --- /dev/null +++ b/crates/precompiles/src/outbox/tests.rs @@ -0,0 +1,668 @@ +use super::*; + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::{Bytes, address}; +use alloy_sol_types::{SolCall, SolInterface}; +use revm::precompile::PrecompileResult; +use tempo_precompiles::{Precompile as _, storage::FromWord, test_util::TIP20Setup}; +use tempo_zone_contracts::{IZoneOutbox as ZoneOutboxAbi, portal_token_config_slot}; + +use crate::{ + L1StorageReader, TempoState, execution, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, + test_storage_provider, + }, + tx_context, +}; + +const ANCHOR: u64 = 7; +const GAS: u64 = 10_000_000; +const TX_HASH: B256 = B256::repeat_byte(0x42); +const ALICE: Address = address!("0x00000000000000000000000000000000000000a1"); +const BOB: Address = address!("0x00000000000000000000000000000000000000b2"); +const SEQUENCER: Address = address!("0x00000000000000000000000000000000000000c3"); + +struct Harness { + ctx: TestContext, + l1: MockL1Reader, + precompile: DynPrecompile, + token: Address, +} + +impl Harness { + fn new() -> eyre::Result { + let mut ctx = test_context(); + let l1 = MockL1Reader::allow_all(); + let portal = l1.portal_address(); + let token = tempo_precompiles::PATH_USD_ADDRESS; + + l1.set_u256( + portal, + PORTAL_SEQUENCER_SLOT.into(), + ANCHOR, + SEQUENCER.to_word(), + ); + l1.set_u256( + portal, + portal_token_config_slot(token).into(), + ANCHOR, + U256::ONE, + ); + l1.seed_transfer_policy_id(token, ANCHOR); + + { + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + TempoState::new().tempo_block_number.write(ANCHOR)?; + + ZoneOutbox::new().initialize()?; + TIP20Setup::path_usd(ALICE) + .with_issuer(ALICE) + .with_issuer(ZONE_OUTBOX_ADDRESS) + .with_mint(ALICE, U256::from(1_000_000u64)) + .with_approval(ALICE, ZONE_OUTBOX_ADDRESS, U256::MAX) + .apply()?; + Ok(()) + })?; + } + + let env = test_l1_env(&ctx, l1.clone()); + let precompile = execution::create_l1_backed_precompile( + "ZoneOutboxTest", + env, + ZoneOutboxRules::new(portal), + |data, caller| ZoneOutbox::new().call(data, caller), + ); + + Ok(Self { + ctx, + l1, + precompile, + token, + }) + } + + #[rustfmt::skip] + fn call_inner(&mut self, caller: Address, data: impl AsRef<[u8]>, with_hash: bool, is_static: bool) -> PrecompileResult { + let mut call = || { call_precompile( + &mut self.ctx, &self.precompile, caller, data.as_ref(), GAS, is_static, ZONE_OUTBOX_ADDRESS, ZONE_OUTBOX_ADDRESS + )}; + + if with_hash { + let _guard = tx_context::set_current_tx_hash(TX_HASH); + call() + } else { + call() + } + } + + fn call_without_hash(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + self.call_inner(caller, data, false, false) + } + + fn call(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + self.call_inner(caller, data, true, false) + } + + fn call_static(&mut self, caller: Address, data: impl AsRef<[u8]>) -> PrecompileResult { + self.call_inner(caller, data, false, true) + } + + fn pending(&mut self) -> eyre::Result> { + let output = self.call( + Address::ZERO, + ZoneOutboxAbi::getPendingWithdrawalsCall {}.abi_encode(), + )?; + Ok(ZoneOutboxAbi::getPendingWithdrawalsCall::abi_decode_returns(&output.bytes)?) + } + + fn last_fallback_nonce(&mut self) -> eyre::Result { + let output = self.call( + Address::ZERO, + ZoneOutboxAbi::lastFallbackNonceCall {}.abi_encode(), + )?; + Ok(ZoneOutboxAbi::lastFallbackNonceCall::abi_decode_returns( + &output.bytes, + )?) + } + + fn request(&mut self, amount: u128, to: Address, memo: B256) -> PrecompileResult { + self.request_custom(ZoneOutboxAbi::requestWithdrawalCall { + token: self.token, + to, + amount, + memo, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + }) + } + + fn request_custom(&mut self, call: ZoneOutboxAbi::requestWithdrawalCall) -> PrecompileResult { + self.call(ALICE, call.abi_encode()) + } + + fn set_gas_rate(&mut self, rate: u128) -> PrecompileResult { + self.call( + SEQUENCER, + ZoneOutboxAbi::setTempoGasRateCall { + _tempoGasRate: rate, + } + .abi_encode(), + ) + } + + fn set_max_withdrawals(&mut self, max: u32) -> PrecompileResult { + self.call( + SEQUENCER, + ZoneOutboxAbi::setMaxWithdrawalsPerBlockCall { + _maxWithdrawalsPerBlock: max, + } + .abi_encode(), + ) + } + + fn balance_of(&mut self, account: Address) -> eyre::Result { + let mut storage = test_storage_provider(&mut self.ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || { + let token = TIP20Token::from_address(self.token).expect("initialized token"); + Ok(token.balance_of(ITIP20::balanceOfCall { account })?) + }) + } + + fn finalize(&mut self, count: usize) -> PrecompileResult { + self.call( + SEQUENCER, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::from(count), + blockNumber: 0, + encryptedSenders: vec![Bytes::new(); count], + } + .abi_encode(), + ) + } +} + +fn assert_revert(result: PrecompileResult, error: impl SolInterface) { + let output = result.expect("precompile error"); + assert!(output.is_revert()); + assert_eq!(output.bytes, error.abi_encode()); +} + +#[test] +fn request_withdrawal_stores_fields_and_fifo_order() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(500, ALICE, B256::repeat_byte(1))?; + harness.request(300, BOB, B256::repeat_byte(2))?; + + let pending = harness.pending()?; + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].sender, ALICE); + assert_eq!(pending[0].txHash, TX_HASH); + assert_eq!(pending[0].to, ALICE); + assert_eq!(pending[0].amount, 500); + assert_eq!(pending[1].to, BOB); + assert_eq!(pending[1].amount, 300); + Ok(()) +} + +#[test] +fn request_withdrawal_rejects_disabled_token() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let portal = harness.l1.portal_address(); + harness.l1.set_u256( + portal, + portal_token_config_slot(harness.token).into(), + ANCHOR, + U256::ZERO, + ); + let result = harness.request(1, BOB, B256::ZERO); + assert_revert(result, ZonePortalError::token_not_enabled()); + Ok(()) +} + +#[test] +fn request_withdrawal_rejects_missing_transaction_hash() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + let result = harness.call_without_hash( + ALICE, + ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + } + .abi_encode(), + ); + assert_revert(result, ZoneOutboxError::invalid_current_tx_hash()); + Ok(()) +} + +#[test] +fn enqueue_bounce_back_is_inbox_only() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let call = ZoneOutboxAbi::enqueueDepositBounceBackCall { + token: harness.token, + amount: 100, + bouncebackRecipient: BOB, + } + .abi_encode(); + + assert_revert( + harness.call(ALICE, &call), + ZoneOutboxError::only_zone_inbox(), + ); + harness.call(ZONE_INBOX_ADDRESS, call)?; + let pending = harness.pending()?; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].sender, Address::ZERO); + assert_eq!(pending[0].fee, 0); + assert_eq!(pending[0].fallbackNonce, 0); + assert_eq!(harness.last_fallback_nonce()?, 0); + + harness.request(1, BOB, B256::ZERO)?; + assert_eq!(harness.pending()?[1].fallbackNonce, 1); + assert_eq!(harness.last_fallback_nonce()?, 1); + Ok(()) +} + +#[test] +fn finalize_empty_queue_returns_zero() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let output = harness.finalize(0)?; + assert_eq!( + ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_decode_returns(&output.bytes)?, + B256::ZERO + ); + Ok(()) +} + +#[test] +fn finalize_single_and_multiple_withdrawals_match_canonical_queue_hash() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(100, ALICE, B256::repeat_byte(1))?; + harness.request(200, BOB, B256::repeat_byte(2))?; + let pending = harness.pending()?; + let expected: Vec = pending + .iter() + .map(|pending| Withdrawal { + token: pending.token, + senderTag: Withdrawal::sender_tag(pending.sender, pending.txHash), + to: pending.to, + amount: pending.amount, + fee: pending.fee, + memo: pending.memo, + gasLimit: pending.gasLimit, + fallbackNonce: pending.fallbackNonce, + callbackData: pending.callbackData.clone(), + encryptedSender: Bytes::new(), + }) + .collect(); + + let output = harness.finalize(2)?; + assert_eq!( + ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_decode_returns(&output.bytes)?, + Withdrawal::queue_hash(&expected) + ); + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn finalize_rejects_wrong_count_and_non_sequencer() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(100, ALICE, B256::ZERO)?; + assert_revert( + harness.finalize(0), + ZoneOutboxError::invalid_withdrawal_count(U256::ZERO, U256::ONE), + ); + + let result = harness.call( + ALICE, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::ONE, + blockNumber: 0, + encryptedSenders: vec![Bytes::new()], + } + .abi_encode(), + ); + assert_revert(result, ZoneOutboxError::only_sequencer()); + Ok(()) +} + +#[test] +fn fee_rate_and_gas_limit_validation_match_reference() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.set_gas_rate(3)?; + + let output = harness.call( + ALICE, + ZoneOutboxAbi::calculateWithdrawalFeeCall { gasLimit: 7 }.abi_encode(), + )?; + assert_eq!( + ZoneOutboxAbi::calculateWithdrawalFeeCall::abi_decode_returns(&output.bytes)?, + u128::from(WITHDRAWAL_BASE_GAS + 7) * 3 + ); + + assert_revert( + harness.call( + ALICE, + ZoneOutboxAbi::calculateWithdrawalFeeCall { + gasLimit: MAX_WITHDRAWAL_GAS_LIMIT + 1, + } + .abi_encode(), + ), + ZoneOutboxError::gas_limit_too_high(), + ); + assert_revert( + harness.set_gas_rate(MAX_GAS_FEE_RATE + 1), + ZoneOutboxError::gas_fee_rate_too_high(), + ); + Ok(()) +} + +#[test] +fn callback_and_reveal_boundaries_are_enforced() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + let base = |data: Bytes, reveal_to: Bytes| ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: ALICE, + data, + revealTo: reveal_to, + }; + + harness.request_custom(base( + Bytes::from(vec![0; MAX_CALLBACK_DATA_SIZE]), + Bytes::new(), + ))?; + assert_revert( + harness.request_custom(base( + Bytes::from(vec![0; MAX_CALLBACK_DATA_SIZE + 1]), + Bytes::new(), + )), + ZoneOutboxError::callback_data_too_large(), + ); + assert_revert( + harness.request_custom(base(Bytes::new(), Bytes::from(vec![2; 32]))), + ZoneOutboxError::invalid_reveal_to(), + ); + assert_revert( + harness.request_custom(base(Bytes::new(), Bytes::from(vec![4; 33]))), + ZoneOutboxError::invalid_reveal_to(), + ); + + let valid = Bytes::copy_from_slice(&alloy_primitives::hex!( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + )); + harness.request_custom(base(Bytes::new(), valid))?; + Ok(()) +} + +#[test] +fn fallback_recipient_and_zero_amount_semantics_match_reference() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + assert_revert( + harness.request_custom(ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: Address::ZERO, + data: Bytes::new(), + revealTo: Bytes::new(), + }), + ZoneOutboxError::invalid_fallback_recipient(), + ); + harness.request(0, BOB, B256::ZERO)?; + assert_eq!(harness.pending()?[0].amount, 0); + Ok(()) +} + +#[test] +fn request_burns_amount_plus_fee_and_rejects_insufficient_funds() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.set_gas_rate(2)?; + let before = harness.balance_of(ALICE)?; + let fee = u128::from(WITHDRAWAL_BASE_GAS) * 2; + harness.request(100, BOB, B256::ZERO)?; + assert_eq!(harness.balance_of(ALICE)?, before - U256::from(100 + fee)); + + let result = harness.request(u128::MAX, BOB, B256::ZERO); + assert!(result.expect("precompile result").is_revert()); + Ok(()) +} + +#[test] +fn encrypted_sender_count_and_length_are_validated() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(1, BOB, B256::ZERO)?; + assert_revert( + harness.call( + SEQUENCER, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::ONE, + blockNumber: 0, + encryptedSenders: Vec::new(), + } + .abi_encode(), + ), + ZoneOutboxError::invalid_encrypted_sender_count(U256::ZERO, U256::ONE), + ); + assert_revert( + harness.call( + SEQUENCER, + ZoneOutboxAbi::finalizeWithdrawalBatchCall { + count: U256::ONE, + blockNumber: 0, + encryptedSenders: vec![Bytes::from(vec![1])], + } + .abi_encode(), + ), + ZoneOutboxError::invalid_encrypted_sender_length(U256::ONE, U256::ZERO), + ); + Ok(()) +} + +#[test] +fn indices_last_batch_and_timestamp_advance_across_batches() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.ctx.block.timestamp = U256::from(123); + harness.request(1, BOB, B256::ZERO)?; + harness.finalize(1)?; + harness.request(2, BOB, B256::ZERO)?; + let second_hash = ZoneOutboxAbi::finalizeWithdrawalBatchCall::abi_decode_returns( + &harness.finalize(1)?.bytes, + )?; + + let next = harness.call( + Address::ZERO, + ZoneOutboxAbi::nextWithdrawalIndexCall {}.abi_encode(), + )?; + assert_eq!( + ZoneOutboxAbi::nextWithdrawalIndexCall::abi_decode_returns(&next.bytes)?, + 2 + ); + let batch = harness.call(Address::ZERO, ZoneOutboxAbi::lastBatchCall {}.abi_encode())?; + let batch = ZoneOutboxAbi::lastBatchCall::abi_decode_returns(&batch.bytes)?; + assert_eq!(batch.withdrawalBatchIndex, 2); + assert_eq!(batch.withdrawalQueueHash, second_hash); + let timestamp = harness.call( + Address::ZERO, + ZoneOutboxAbi::lastFinalizedTimestampCall {}.abi_encode(), + )?; + assert_eq!( + ZoneOutboxAbi::lastFinalizedTimestampCall::abi_decode_returns(×tamp.bytes)?, + 123 + ); + Ok(()) +} + +#[test] +fn per_block_cap_is_unlimited_resettable_and_updateable() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.set_max_withdrawals(0)?; + for _ in 0..3 { + harness.request(1, BOB, B256::ZERO)?; + } + + harness.set_max_withdrawals(1)?; + harness.request(1, BOB, B256::ZERO)?; + assert_revert( + harness.request(1, BOB, B256::ZERO), + ZoneOutboxError::too_many_withdrawals_this_block(), + ); + harness.ctx.block.number = U256::ONE; + harness.request(1, BOB, B256::ZERO)?; + assert_revert( + harness.request(1, BOB, B256::ZERO), + ZoneOutboxError::too_many_withdrawals_this_block(), + ); + harness.set_max_withdrawals(2)?; + harness.request(1, BOB, B256::ZERO)?; + Ok(()) +} + +#[test] +fn many_withdrawals_finalize_and_clear_pending_state() -> eyre::Result<()> { + let mut harness = Harness::new()?; + for i in 0..20u64 { + harness.request(1, BOB, B256::from(U256::from(i)))?; + } + harness.finalize(20)?; + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn legacy_withdrawal_matches_current_overload_and_defaults_reveal_to() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + let memo = B256::repeat_byte(0x11); + let data = Bytes::from_static(b"callback"); + + harness.request_custom(ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 123, + memo, + gasLimit: 7, + fallbackRecipient: ALICE, + data: data.clone(), + revealTo: Bytes::new(), + })?; + harness.call( + ALICE, + ILegacyZoneOutbox::requestWithdrawalCall { + token, + to: BOB, + amount: 123, + memo, + gasLimit: 7, + fallbackRecipient: ALICE, + data, + } + .abi_encode(), + )?; + + let pending = harness.pending()?; + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].fallbackNonce, 1); + assert_eq!(pending[1].fallbackNonce, 2); + let mut legacy = pending[1].clone(); + legacy.fallbackNonce = pending[0].fallbackNonce; + assert_eq!(pending[0], legacy); + assert!(pending[1].revealTo.is_empty()); + Ok(()) +} + +#[test] +fn malformed_legacy_withdrawal_reverts_with_empty_data() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let output = harness.call(ALICE, ILegacyZoneOutbox::requestWithdrawalCall::SELECTOR)?; + assert!(output.is_revert()); + assert!(output.bytes.is_empty()); + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn static_mutation_reverts_with_static_call_not_allowed() -> eyre::Result<()> { + let mut harness = Harness::new()?; + let token = harness.token; + assert_revert( + harness.call_static( + ALICE, + ZoneOutboxAbi::requestWithdrawalCall { + token, + to: BOB, + amount: 1, + memo: B256::ZERO, + gasLimit: 0, + fallbackRecipient: ALICE, + data: Bytes::new(), + revealTo: Bytes::new(), + } + .abi_encode(), + ), + ZoneOutboxError::static_call_not_allowed(), + ); + assert!(harness.pending()?.is_empty()); + Ok(()) +} + +#[test] +fn fallback_recipient_nonce_is_private_and_consumed_once_by_inbox() -> eyre::Result<()> { + let mut harness = Harness::new()?; + harness.request(1, BOB, B256::ZERO)?; + let nonce = harness.pending()?[0].fallbackNonce; + assert_eq!(nonce, 1); + + let calldata = ZoneOutboxAbi::consumeFallbackRecipientCall { + fallbackNonce: nonce, + } + .abi_encode(); + assert_revert( + harness.call(ALICE, &calldata), + ZoneOutboxError::only_zone_inbox(), + ); + assert_revert( + harness.call( + ZONE_INBOX_ADDRESS, + ZoneOutboxAbi::consumeFallbackRecipientCall { + fallbackNonce: nonce + 1, + } + .abi_encode(), + ), + ZoneOutboxError::invalid_fallback_recipient(), + ); + assert_revert( + harness.call_static(ZONE_INBOX_ADDRESS, &calldata), + ZoneOutboxError::static_call_not_allowed(), + ); + + // The failed static call must not consume the mapping entry. + let output = harness.call(ZONE_INBOX_ADDRESS, &calldata)?; + assert_eq!( + ZoneOutboxAbi::consumeFallbackRecipientCall::abi_decode_returns(&output.bytes)?, + ALICE + ); + assert_revert( + harness.call(ZONE_INBOX_ADDRESS, calldata), + ZoneOutboxError::invalid_fallback_recipient(), + ); + Ok(()) +} 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..62d280ee5 --- /dev/null +++ b/crates/precompiles/src/storage.rs @@ -0,0 +1,548 @@ +//! 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 { + /// Zone portal account whose configuration is mirrored from Tempo L1. + fn portal_address(&self) -> Address; + + /// Read `account[slot]` at `block_number` on Tempo L1. + fn read_l1_storage( + &self, + 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 || address == self.l1.portal_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 + || address == self.l1.portal_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..015ca7b39 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, @@ -81,6 +67,11 @@ impl TempoState { ) } + /// Returns the finalized Tempo block number recorded in zone state. + pub fn current_tempo_block_number(&self) -> tempo_precompiles::Result { + self.tempo_block_number.read() + } + fn revert_error(&self, error: E) -> PrecompileResult { Ok(self.storage.revert_output(error.abi_encode().into())) } @@ -188,41 +179,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 +213,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 +228,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 +242,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 +295,7 @@ mod tests { precompile: &DynPrecompile, expected_hash: B256, expected_number: u64, - ) -> TestResult { + ) -> eyre::Result<()> { let block_hash = call( ctx, precompile, @@ -430,20 +323,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 +346,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 +362,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 +370,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 +386,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 +422,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 +438,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 +461,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 +471,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 +487,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 +495,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 +511,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 +519,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 +535,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 +568,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..c5166c2a7 100644 --- a/crates/precompiles/src/test_utils.rs +++ b/crates/precompiles/src/test_utils.rs @@ -1,18 +1,303 @@ //! 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::{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 tempo_primitives::TempoBlockEnv; 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 portal_address(&self) -> Address { + Address::repeat_byte(0x77) + } + + 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 = slot.into(); + 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(value.into()) + } + } +} + /// 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..6e4b23657 100644 --- a/crates/precompiles/src/tip403_proxy/mod.rs +++ b/crates/precompiles/src/tip403_proxy/mod.rs @@ -1,92 +1,329 @@ -//! 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 crate::execution::{CallCheck, CallRules, ZoneCall}; 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; +use tempo_contracts::precompiles::{ITIP403Registry, TIP403_REGISTRY_ADDRESS}; -use crate::policy::PolicyCheck; - -/// 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::from_error(ReadOnlyRegistry {}); + } + + 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 alloy_sol_types::SolError; + 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, + } + + 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, + ) + } + } + + 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 } - /// Resolve the `transferPolicyId` for a token. - pub fn resolve_transfer_policy_id(&self, token: Address) -> Result { - self.provider.resolve_transfer_policy_id(token) + 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)?) } - /// Check whether `user` is authorized under `policy_id` for the given `role`. - pub fn is_authorized( - &self, - policy_id: u64, - user: Address, - role: AuthRole, - ) -> Result { - self.provider.is_authorized(policy_id, user, role) + #[test] + fn authorization_and_read_methods_match_upstream_policy_semantics() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(seeded_reader()); + + for (policy_id, user, expected) in [ + (0, ALICE, false), + (1, ALICE, true), + (5, ALICE, true), + (5, BOB, false), + (6, BOB, false), + ] { + let call = ITIP403Registry::isAuthorizedCall { + policyId: policy_id, + user, + }; + assert_eq!(bool_result(&mut harness, &call)?, expected); + } + + assert!(bool_result( + &mut harness, + &ITIP403Registry::isAuthorizedSenderCall { + policyId: 10, + user: ALICE, + }, + )?); + assert!(!bool_result( + &mut harness, + &ITIP403Registry::isAuthorizedRecipientCall { + policyId: 10, + user: BOB, + }, + )?); + assert!(bool_result( + &mut harness, + &ITIP403Registry::isAuthorizedMintRecipientCall { + policyId: 10, + user: BOB, + }, + )?); + + let counter = harness.call( + &ITIP403Registry::policyIdCounterCall {}.abi_encode(), + u64::MAX, + )?; + assert_eq!( + ITIP403Registry::policyIdCounterCall::abi_decode_returns(&counter.bytes)?, + 11 + ); + + let exists = ITIP403Registry::policyExistsCall { policyId: 10 }; + assert!(bool_result(&mut harness, &exists)?); + + let policy_data = harness.call( + &ITIP403Registry::policyDataCall { policyId: 5 }.abi_encode(), + u64::MAX, + )?; + let policy_data = ITIP403Registry::policyDataCall::abi_decode_returns(&policy_data.bytes)?; + assert_eq!( + policy_data.policyType, + ITIP403Registry::PolicyType::WHITELIST + ); + assert_eq!(policy_data.admin, Address::ZERO); + + let compound = harness.call( + &ITIP403Registry::compoundPolicyDataCall { policyId: 10 }.abi_encode(), + u64::MAX, + )?; + let compound = + ITIP403Registry::compoundPolicyDataCall::abi_decode_returns(&compound.bytes)?; + assert_eq!(compound.senderPolicyId, 5); + assert_eq!(compound.recipientPolicyId, 6); + assert_eq!(compound.mintRecipientPolicyId, 1); + Ok(()) + } + + #[test] + fn mutations_revert_while_receive_policy_reads_use_upstream_dispatch() -> eyre::Result<()> { + let mut harness = RegistryHarness::new(MockL1Reader::default()); + let mutation = ITIP403Registry::createPolicyCall { + admin: CALLER, + policyType: ITIP403Registry::PolicyType::BLACKLIST, + } + .abi_encode(); + let output = harness.call(&mutation, u64::MAX)?; + assert!(output.is_revert()); + assert_eq!(output.bytes, Bytes::from(ReadOnlyRegistry {}.abi_encode())); + + let receive = harness.call( + &ITIP403Registry::receivePolicyCall { account: CALLER }.abi_encode(), + u64::MAX, + )?; + assert!(receive.is_success()); + let receive = ITIP403Registry::receivePolicyCall::abi_decode_returns(&receive.bytes)?; + assert!(!receive.hasReceivePolicy); + + let validation = harness.call( + &ITIP403Registry::validateReceivePolicyCall { + token: Address::repeat_byte(0x20), + sender: ALICE, + receiver: BOB, + } + .abi_encode(), + u64::MAX, + )?; + assert!(validation.is_success()); + let validation = + ITIP403Registry::validateReceivePolicyCall::abi_decode_returns(&validation.bytes)?; + assert!(validation.authorized); + assert_eq!( + validation.blockedReason, + ITIP403Registry::BlockedReason::NONE + ); + + let set_receive = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: 1, + tokenFilterId: 1, + recoveryAuthority: Address::ZERO, + } + .abi_encode(); + let output = harness.call(&set_receive, u64::MAX)?; + assert!(output.is_revert()); + assert_eq!(output.bytes, Bytes::from(ReadOnlyRegistry {}.abi_encode())); + Ok(()) + } + + #[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(()) } - /// Check sender + recipient authorization for a transfer. - /// - /// Short-circuits on sender failure (matching L1 T2 behavior). - pub fn is_transfer_authorized( - &self, - policy_id: u64, - from: Address, - to: Address, - ) -> Result { - if !self.is_authorized(policy_id, from, AuthRole::Sender)? { - return Ok(false); + #[test] + fn 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, } - self.is_authorized(policy_id, to, AuthRole::Recipient) + .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/tx_context.rs b/crates/precompiles/src/tx_context.rs new file mode 100644 index 000000000..9deb24d10 --- /dev/null +++ b/crates/precompiles/src/tx_context.rs @@ -0,0 +1,135 @@ +//! Transaction-hash execution context shared by the zone EVM and native precompiles. + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::{B256, Bytes}; +use alloy_sol_types::{SolCall, SolError}; +use revm::precompile::{PrecompileId, PrecompileOutput}; + +alloy_sol_types::sol! { + function currentTxHash() external returns (bytes32); + error DelegateCallNotAllowed(); +} + +#[cfg(feature = "std")] +use std::{cell::RefCell, thread_local}; + +#[cfg(feature = "std")] +thread_local! { + static CURRENT_TX_HASH: RefCell> = const { RefCell::new(None) }; +} + +/// Guard that clears the published transaction hash when dropped. +#[cfg(feature = "std")] +pub struct TxHashGuard; + +#[cfg(feature = "std")] +impl Drop for TxHashGuard { + fn drop(&mut self) { + CURRENT_TX_HASH.with(|slot| *slot.borrow_mut() = None); + } +} + +/// Publish the current transaction hash for the duration of EVM execution. +#[cfg(feature = "std")] +pub fn set_current_tx_hash(tx_hash: B256) -> TxHashGuard { + CURRENT_TX_HASH.with(|slot| *slot.borrow_mut() = Some(tx_hash)); + TxHashGuard +} + +/// Return the hash of the transaction currently executing, when supplied by the host. +#[cfg(feature = "std")] +pub fn current_tx_hash() -> Option { + CURRENT_TX_HASH.with(|slot| *slot.borrow()) +} + +/// Prover execution currently has no thread-local host context. +#[cfg(not(feature = "std"))] +pub fn current_tx_hash() -> Option { + None +} + +/// Create the transaction-context precompile. Calls without a transaction published by the +/// execution host revert rather than inventing an identifier. +pub(crate) fn create_precompile() -> DynPrecompile { + DynPrecompile::new_stateful(PrecompileId::Custom("ZoneTxContext".into()), |input| { + if !input.is_direct_call() { + return Ok(PrecompileOutput::revert( + 0, + DelegateCallNotAllowed {}.abi_encode().into(), + input.reservoir, + )); + } + if input.data.len() < 4 || input.data[..4] != currentTxHashCall::SELECTOR { + return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); + } + let Some(hash) = current_tx_hash() else { + return Ok(PrecompileOutput::revert(0, Bytes::new(), input.reservoir)); + }; + Ok(PrecompileOutput::new( + 20, + currentTxHashCall::abi_encode_returns(&hash).into(), + input.reservoir, + )) + }) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use alloy_evm::{ + EvmInternals, + precompiles::{Precompile, PrecompileInput}, + }; + use alloy_primitives::{Address, U256}; + use revm::{ + Context, + database::{CacheDB, EmptyDB}, + }; + use tempo_chainspec::hardfork::TempoHardfork; + + type TestContext = Context< + revm::context::BlockEnv, + revm::context::TxEnv, + revm::context::CfgEnv, + CacheDB, + >; + + fn call_with_tx_hash(tx_hash: Option) -> PrecompileOutput { + let _guard = tx_hash.map(set_current_tx_hash); + let mut ctx: TestContext = + Context::new(CacheDB::new(EmptyDB::new()), TempoHardfork::default()); + let calldata = currentTxHashCall {}.abi_encode(); + + create_precompile() + .call(PrecompileInput { + data: &calldata, + gas: u64::MAX, + reservoir: 0, + caller: Address::ZERO, + value: U256::ZERO, + target_address: Address::ZERO, + is_static: true, + bytecode_address: Address::ZERO, + internals: EvmInternals::from_context(&mut ctx), + }) + .expect("precompile call should not fail") + } + + #[test] + fn returns_current_transaction_hash() { + let tx_hash = B256::repeat_byte(0x42); + let output = call_with_tx_hash(Some(tx_hash)); + assert!(!output.is_revert()); + assert_eq!( + output.bytes, + currentTxHashCall::abi_encode_returns(&tx_hash) + ); + } + + #[test] + fn reverts_when_current_transaction_hash_is_not_set() { + let output = call_with_tx_hash(None); + assert!(output.is_revert()); + assert!(output.bytes.is_empty()); + } +} 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..50fce0fc9 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,25 @@ 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::{Handler, StorageCtx}, + test_util::TIP20Setup, + tip20::{IRolesAuth, ISSUER_ROLE, ITIP20, RolesAuthError, TIP20Token}, + }; + use tempo_zone_contracts::Unauthorized; + + use crate::{ + TempoState, + test_utils::{ + MockL1Reader, TestContext, call_precompile, test_context, test_l1_env, + test_storage_provider, + }, }; - - 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 - } - } #[derive(Clone, Copy)] struct MockSequencer { @@ -348,20 +205,15 @@ mod tests { bob: Address, spender: Address, sequencer: Address, - issuer: Address, precompile: DynPrecompile, } impl PrecompileHarness { - fn new(policy: MockPolicyProvider) -> TestResult { - Self::new_with_registry(Some(policy)) + fn new(l1_reader: MockL1Reader) -> eyre::Result { + Self::new_with_l1(l1_reader) } - fn new_without_registry() -> TestResult { - Self::new_with_registry(None) - } - - fn new_with_registry(policy: Option) -> TestResult { + fn new_with_l1(l1_reader: MockL1Reader) -> eyre::Result { let token = PATH_USD_ADDRESS; let admin = address!("0x00000000000000000000000000000000000000a1"); let alice = address!("0x00000000000000000000000000000000000000a2"); @@ -369,53 +221,31 @@ 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 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), - }, - )?; + let mut ctx = test_context(); + + { + let mut storage = test_storage_provider(&mut ctx, u64::MAX, false); + StorageCtx::enter(&mut storage, || -> eyre::Result<()> { + TempoState::new().tempo_block_number.write(7)?; + TIP20Setup::path_usd(admin) + .with_issuer(admin) + .with_issuer(issuer) + .with_issuer(ZONE_INBOX_ADDRESS) + .with_issuer(ZONE_OUTBOX_ADDRESS) + .with_mint(alice, U256::from(1_000_000u64)) + .with_mint(ZONE_OUTBOX_ADDRESS, U256::from(10_000u64)) + .with_approval(alice, spender, U256::from(300_000u64)) + .apply()?; Ok(()) - }) - })?; + })?; + } + + 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 +258,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 +269,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 +327,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 +362,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 +372,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, true, )?; assert!(private_balance.is_revert()); @@ -587,29 +389,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 +415,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 +436,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 +525,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 +540,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 +554,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 +568,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 +582,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 +597,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 +612,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 +628,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 +678,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 +688,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 +699,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(approve.is_success()); @@ -921,7 +716,7 @@ mod tests { } .abi_encode() .into(), - FIXED_TRANSFER_GAS, + TIP20_FIXED_TRANSFER_GAS, false, )?; assert!(transfer.is_success()); @@ -931,58 +726,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 +838,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 +848,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/crates/primitives/src/constants.rs b/crates/primitives/src/constants.rs index 57ef86d5b..46ef6205b 100644 --- a/crates/primitives/src/constants.rs +++ b/crates/primitives/src/constants.rs @@ -53,18 +53,13 @@ pub const ZONE_TOKEN_ADDRESS: Address = address!("0x20C0000000000000000000000000 pub const PORTAL_SEQUENCER_SLOT: B256 = B256::ZERO; /// ZonePortal storage slot 1: `admin` (address). -pub const PORTAL_ADMIN_SLOT: B256 = { - let mut bytes = [0u8; 32]; - bytes[31] = 1; - B256::new(bytes) -}; +pub const PORTAL_ADMIN_SLOT: B256 = B256::with_last_byte(1); /// ZonePortal storage slot 2: `pendingSequencer` (address). -pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { - let mut bytes = [0u8; 32]; - bytes[31] = 2; - B256::new(bytes) -}; +pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = B256::with_last_byte(2); + +/// ZonePortal storage slot 8: `tokenConfigs` mapping. +pub const PORTAL_TOKEN_CONFIGS_SLOT: B256 = B256::with_last_byte(8); // --------------------------------------------------------------------------- // Storage slot constants for the proof system @@ -73,23 +68,6 @@ pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { /// ZoneInbox storage slot 0: `processedDepositQueueHash` (bytes32). pub const ZONE_INBOX_PROCESSED_HASH_SLOT: U256 = U256::ZERO; -/// ZoneOutbox storage slot 1: `_lastBatch.withdrawalQueueHash` (bytes32). -/// -/// Slot 0 is packed `(tempoGasRate, nextWithdrawalIndex, withdrawalBatchIndex)`. -/// The `_lastBatch` struct starts at slot 1 with `withdrawalQueueHash` occupying the full slot. -pub const ZONE_OUTBOX_LAST_BATCH_HASH_SLOT: U256 = { - let mut le = [0u8; 32]; - le[0] = 1; - U256::from_le_bytes(le) -}; - -/// ZoneOutbox storage slot 2: `_lastBatch.withdrawalBatchIndex` (uint64, lower 8 bytes). -pub const ZONE_OUTBOX_LAST_BATCH_INDEX_SLOT: U256 = { - let mut le = [0u8; 32]; - le[0] = 2; - U256::from_le_bytes(le) -}; - /// Base offset for deriving **mainnet** zone chain IDs. /// /// Each zone gets a unique EIP-155 chain ID derived from its on-chain zone ID diff --git a/crates/sequencer/src/monitor.rs b/crates/sequencer/src/monitor.rs index 9135ee225..169dd534d 100644 --- a/crates/sequencer/src/monitor.rs +++ b/crates/sequencer/src/monitor.rs @@ -35,7 +35,7 @@ use tracing::{error, info, instrument, warn}; use alloy_sol_types::{ContractError, SolInterface as _}; use crate::{ - abi::{self, NO_QUEUE_INDEX, TempoState, ZoneInbox, ZoneOutbox, ZonePortal}, + abi::{self, IZoneOutbox, NO_QUEUE_INDEX, TempoState, ZoneInbox, ZonePortal}, rpc::rpc_connection_config, settlement::{ BatchAnchorConfig, BatchData, BatchSubmitter, ZoneBlockSnapshot, fetch_finalized_batch, @@ -98,7 +98,7 @@ pub struct ZoneMonitor { provider: DynProvider, /// ZoneOutbox contract on **Zone L2** — source of `WithdrawalRequested` and /// `BatchFinalized` events. - outbox: ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, /// ZoneInbox contract on **Zone L2** — queried for the processed deposit queue hash. inbox: ZoneInbox::ZoneInboxInstance, TempoNetwork>, /// TempoState predeploy on **Zone L2** — provides the latest Tempo L1 block number @@ -181,7 +181,7 @@ impl ZoneMonitor { repair_notify: Arc, ) -> Result { let metrics = crate::metrics::ZoneMonitorMetrics::default(); - let outbox = ZoneOutbox::new(config.outbox_address, provider.clone()); + let outbox = IZoneOutbox::new(config.outbox_address, provider.clone()); let inbox = ZoneInbox::new(config.inbox_address, provider.clone()); let tempo_state = TempoState::new(config.tempo_state_address, provider.clone()); @@ -1006,7 +1006,7 @@ mod tests { config, metrics: crate::metrics::ZoneMonitorMetrics::default(), provider: zone_provider.clone(), - outbox: ZoneOutbox::new(Address::repeat_byte(0x22), zone_provider.clone()), + outbox: IZoneOutbox::new(Address::repeat_byte(0x22), zone_provider.clone()), inbox: ZoneInbox::new(Address::repeat_byte(0x33), zone_provider.clone()), tempo_state: TempoState::new(Address::repeat_byte(0x44), zone_provider), withdrawal_store: SharedWithdrawalStore::new(), diff --git a/crates/sequencer/src/settlement.rs b/crates/sequencer/src/settlement.rs index 1c42cf93c..4affef861 100644 --- a/crates/sequencer/src/settlement.rs +++ b/crates/sequencer/src/settlement.rs @@ -25,7 +25,7 @@ use std::collections::BTreeMap; -use crate::abi::{self, BlockTransition, DepositQueueTransition, ZoneOutbox, ZonePortal}; +use crate::abi::{self, BlockTransition, DepositQueueTransition, IZoneOutbox, ZonePortal}; use alloy_consensus::Transaction; use alloy_network::ReceiptResponse; use alloy_primitives::{Address, B256, Bytes, U256}; @@ -591,7 +591,7 @@ impl BatchSubmitter { } // Step 4: fetch WithdrawalRequested events from zone L2 for each pending slot. - let outbox = ZoneOutbox::new(outbox_address, zone_provider.clone()); + let outbox = IZoneOutbox::new(outbox_address, zone_provider.clone()); let mut slot_withdrawals: BTreeMap> = BTreeMap::new(); for portal_slot in head..tail { if !events.contains_key(&portal_slot) { @@ -783,7 +783,7 @@ struct RequestedWithdrawalLog { tx_index: u64, log_index: u64, tx_hash: B256, - event: abi::ZoneOutbox::WithdrawalRequested, + event: abi::IZoneOutbox::WithdrawalRequested, } #[derive(Debug, Clone)] @@ -801,7 +801,7 @@ struct FinalizedBatchLog { /// This includes zero-withdrawal batches because they still advance the L2 /// withdrawal batch index and therefore require a matching L1 `submitBatch`. pub(crate) async fn fetch_finalized_batch_boundaries( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, from: u64, to: u64, ) -> Result> { @@ -834,7 +834,7 @@ pub(crate) async fn fetch_finalized_batch_boundaries( /// the immediately preceding batch boundary so the off-chain processor can /// service the portal queue. pub(crate) async fn fetch_finalized_batch( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, zone_provider: &DynProvider, from: u64, to: u64, @@ -890,7 +890,7 @@ pub(crate) async fn fetch_finalized_batch( ) })?; let encrypted_senders = - abi::ZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref()) + abi::IZoneOutbox::finalizeWithdrawalBatchCall::abi_decode(finalize_tx.input().as_ref()) .map_err(|err| { eyre::eyre!( "failed to decode finalizeWithdrawalBatch calldata for {}: {err}", @@ -935,7 +935,7 @@ pub(crate) async fn fetch_finalized_batch( /// Fetch `WithdrawalRequested` events for one portal queue slot. pub(crate) async fn fetch_slot_withdrawals( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, zone_provider: &DynProvider, from: u64, to: u64, @@ -946,7 +946,7 @@ pub(crate) async fn fetch_slot_withdrawals( } async fn fetch_requested_withdrawal_logs( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, from: u64, to: u64, ) -> Result> { @@ -978,7 +978,7 @@ async fn fetch_requested_withdrawal_logs( } async fn fetch_finalized_batch_logs( - outbox: &ZoneOutbox::ZoneOutboxInstance, TempoNetwork>, + outbox: &IZoneOutbox::IZoneOutboxInstance, TempoNetwork>, from: u64, to: u64, ) -> Result> { diff --git a/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.
diff --git a/xtask/src/demo_blacklist.rs b/xtask/src/demo_blacklist.rs index 9ae7df898..a8043df58 100644 --- a/xtask/src/demo_blacklist.rs +++ b/xtask/src/demo_blacklist.rs @@ -75,7 +75,7 @@ use tempo_precompiles::{ PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, tip20::ISSUER_ROLE, }; use tempo_zone_contracts::{ - DepositType, EncryptedDepositPayload, ZONE_OUTBOX_ADDRESS, ZoneInbox, ZoneOutbox, ZonePortal, + DepositType, EncryptedDepositPayload, IZoneOutbox, ZONE_OUTBOX_ADDRESS, ZoneInbox, ZonePortal, }; use zone_precompiles::ecies::encrypt_deposit; @@ -572,7 +572,7 @@ impl DemoBlacklist { if withdraw_amount == 0 { println!(" No balance to withdraw — skipping."); } else { - let outbox = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_target); + let outbox = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_target); let l1_block_before = l1.get_block_number().await?; let receipt = outbox diff --git a/xtask/src/demo_swap_and_deposit.rs b/xtask/src/demo_swap_and_deposit.rs index 60291e28e..acca1fa36 100644 --- a/xtask/src/demo_swap_and_deposit.rs +++ b/xtask/src/demo_swap_and_deposit.rs @@ -15,8 +15,8 @@ use tempo_contracts::precompiles::{ }; use tempo_precompiles::{PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS, tip20::ISSUER_ROLE}; use tempo_zone_contracts::{ - EncryptedDepositPayload, SwapAndDepositRouterEncryptedCallback, ZONE_OUTBOX_ADDRESS, - ZoneOutbox, ZonePortal, + EncryptedDepositPayload, IZoneOutbox, SwapAndDepositRouterEncryptedCallback, + ZONE_OUTBOX_ADDRESS, ZonePortal, }; use zone_precompiles::ecies::encrypt_deposit; @@ -170,7 +170,7 @@ impl DemoSwapAndDeposit { .call() .await .wrap_err("failed to fetch portal deposit fee")?; - let withdrawal_fee = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2) + let withdrawal_fee = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2) .calculateWithdrawalFee(ROUTER_CALLBACK_GAS_LIMIT) .call() .await @@ -357,7 +357,7 @@ impl DemoSwapAndDeposit { ) .await?; let l1_from_block = l1.get_block_number().await.unwrap_or(0); - let receipt = ZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_operator) + let receipt = IZoneOutbox::new(ZONE_OUTBOX_ADDRESS, &l2_operator) .requestWithdrawal( alpha, router,