From 2db6d313bde791cc8fa88304e59bb7c95dd9ffb7 Mon Sep 17 00:00:00 2001 From: Roman Krasiuk Date: Thu, 16 Jul 2026 18:59:40 +0200 Subject: [PATCH] feat: spf --- Cargo.lock | 26 + Cargo.toml | 8 + .../contracts/src/precompiles/zone_portal.rs | 2 + crates/primitives/src/constants.rs | 7 + crates/primitives/src/header.rs | 2 +- crates/spf/Cargo.toml | 46 + crates/spf/src/execution/database.rs | 275 +++++ crates/spf/src/execution/evm.rs | 365 ++++++ crates/spf/src/execution/mod.rs | 4 + crates/spf/src/lib.rs | 1022 +++++++++++++++++ crates/spf/src/mpt.rs | 315 +++++ crates/spf/src/types.rs | 149 +++ 12 files changed, 2220 insertions(+), 1 deletion(-) create mode 100644 crates/spf/Cargo.toml create mode 100644 crates/spf/src/execution/database.rs create mode 100644 crates/spf/src/execution/evm.rs create mode 100644 crates/spf/src/execution/mod.rs create mode 100644 crates/spf/src/lib.rs create mode 100644 crates/spf/src/mpt.rs create mode 100644 crates/spf/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 6cdc450e9..2fefe1ff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13709,6 +13709,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "zone-spf" +version = "0.1.0" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", + "reth-trie-common", + "reth-trie-sparse", + "revm", + "serde", + "tempo-chainspec", + "tempo-evm", + "tempo-primitives", + "tempo-revm", + "tempo-zone-contracts", + "thiserror 2.0.18", + "zone-chainspec", + "zone-evm", + "zone-precompiles", + "zone-primitives", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 6854d960f..f0ff90493 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/primitives", "crates/rpc", "crates/sequencer", + "crates/spf", "xtask", ] @@ -126,6 +127,7 @@ zone-precompiles = { path = "crates/precompiles", default-features = false } zone-primitives = { path = "crates/primitives", default-features = false } zone-rpc = { path = "crates/rpc" } zone-sequencer = { path = "crates/sequencer" } +zone-spf = { path = "crates/spf" } # reth reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384" } @@ -159,6 +161,12 @@ reth-storage-api = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384 reth-tasks = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384" } reth-tracing = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384" } reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384" } +reth-trie-common = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384", default-features = false, features = [ + "std", +] } +reth-trie-sparse = { git = "https://github.com/paradigmxyz/reth", rev = "1bf2384", default-features = false, features = [ + "std", +] } revm = { version = "41.0.0", features = [ "optional_fee_charge", ], default-features = false } diff --git a/crates/contracts/src/precompiles/zone_portal.rs b/crates/contracts/src/precompiles/zone_portal.rs index 812b1c4aa..81ca8fd81 100644 --- a/crates/contracts/src/precompiles/zone_portal.rs +++ b/crates/contracts/src/precompiles/zone_portal.rs @@ -46,11 +46,13 @@ crate::sol! { EncryptedDepositPayload encrypted; } + #[derive(PartialEq, Eq)] struct BlockTransition { bytes32 prevBlockHash; bytes32 nextBlockHash; } + #[derive(PartialEq, Eq)] struct DepositQueueTransition { bytes32 prevProcessedHash; bytes32 nextProcessedHash; diff --git a/crates/primitives/src/constants.rs b/crates/primitives/src/constants.rs index 57ef86d5b..60921aa25 100644 --- a/crates/primitives/src/constants.rs +++ b/crates/primitives/src/constants.rs @@ -73,6 +73,13 @@ pub const PORTAL_PENDING_SEQUENCER_SLOT: B256 = { /// ZoneInbox storage slot 0: `processedDepositQueueHash` (bytes32). pub const ZONE_INBOX_PROCESSED_HASH_SLOT: U256 = U256::ZERO; +/// ZoneInbox storage slot 1: `processedDepositNumber` (uint64, lower 8 bytes). +pub const ZONE_INBOX_PROCESSED_NUMBER_SLOT: U256 = { + let mut le = [0u8; 32]; + le[0] = 1; + U256::from_le_bytes(le) +}; + /// ZoneOutbox storage slot 1: `_lastBatch.withdrawalQueueHash` (bytes32). /// /// Slot 0 is packed `(tempoGasRate, nextWithdrawalIndex, withdrawalBatchIndex)`. diff --git a/crates/primitives/src/header.rs b/crates/primitives/src/header.rs index 28f5af2a2..a50328e4e 100644 --- a/crates/primitives/src/header.rs +++ b/crates/primitives/src/header.rs @@ -7,7 +7,7 @@ use alloy_rlp::Encodable as _; /// Simplified zone block header for hash computation. /// /// The zone block hash is `keccak256(rlp_encode(header))`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ZoneHeader { pub parent_hash: B256, diff --git a/crates/spf/Cargo.toml b/crates/spf/Cargo.toml new file mode 100644 index 000000000..9e4d8c6b8 --- /dev/null +++ b/crates/spf/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "zone-spf" +description = "Stateless proof function for Tempo Zones" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +tempo-zone-contracts = { workspace = true, features = ["std"] } +zone-chainspec.workspace = true +zone-evm.workspace = true +zone-precompiles = { workspace = true, features = ["std"] } +zone-primitives = { workspace = true, features = ["std"] } + +# Tempo +tempo-chainspec.workspace = true +tempo-evm.workspace = true +tempo-primitives.workspace = true +tempo-revm.workspace = true + +# Alloy / REVM +alloy-consensus.workspace = true +alloy-eips.workspace = true +alloy-evm.workspace = true +alloy-primitives.workspace = true +alloy-rlp.workspace = true +alloy-sol-types.workspace = true +reth-trie-common.workspace = true +reth-trie-sparse.workspace = true +revm.workspace = true +serde = { workspace = true, optional = true } +thiserror = "2" + +[features] +default = ["serde"] +serde = [ + "dep:serde", + "alloy-primitives/serde", + "tempo-zone-contracts/serde", + "zone-primitives/serde", +] diff --git a/crates/spf/src/execution/database.rs b/crates/spf/src/execution/database.rs new file mode 100644 index 000000000..a5c971e30 --- /dev/null +++ b/crates/spf/src/execution/database.rs @@ -0,0 +1,275 @@ +//! REVM database adapters backed by stateless trie witnesses. + +use std::sync::Arc; + +use alloy_consensus::BlockHeader as _; +use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; +use alloy_rlp::Decodable as _; +use revm::{ + Database, + database::states::bundle_state::BundleState, + precompile::PrecompileError, + primitives::{AddressMap, B256Map, U256Map}, + state::{AccountInfo, Bytecode}, +}; +use tempo_primitives::TempoHeader; +use zone_precompiles::{L1StorageReader, SequencerExt}; + +use crate::{ + Error, StatelessSparseTrieError, TempoStateWitness, ZoneStateWitness, mpt::StatelessSparseTrie, +}; + +/// Errors emitted while resolving an execution read against a witness. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum WitnessDatabaseError { + /// A state path could not be resolved from the Zone or Tempo node pool. + #[error(transparent)] + Mpt(#[from] StatelessSparseTrieError), + /// REVM requested bytecode not supplied by the Zone witness. + #[error("missing bytecode in witness: {code_hash:?}")] + MissingCode { code_hash: B256 }, + /// The Zone witness supplied the same bytecode preimage more than once. + #[error("duplicate bytecode hash in Zone state witness: {code_hash:?}")] + DuplicateBytecodeHash { code_hash: B256 }, + /// REVM requested a block hash not supplied by the witness. + #[error("missing block hash in witness: {number}")] + MissingBlockHash { number: u64 }, + /// The execution inputs assigned two different hashes to one Zone block number. + #[error("conflicting block hash for {number}: expected {expected:?}, got {actual:?}")] + ConflictingBlockHash { + number: u64, + expected: B256, + actual: B256, + }, + /// The initial Tempo header is not a complete RLP-encoded Tempo header. + #[error("invalid initial Tempo header in witness")] + InvalidTempoHeader, +} + +impl revm::database_interface::DBErrorMarker for WitnessDatabaseError {} + +/// REVM database backed by a root-bound, fully revealed Zone state trie. +#[derive(Debug)] +pub struct WitnessDatabase { + state: StatelessSparseTrie, + pre_state_root: B256, + node_pool: Vec, + accounts: AddressMap>, + storage: AddressMap>, + code_by_hash: B256Map, +} + +impl WitnessDatabase { + /// Create a Zone execution database rooted at the parent Zone header's + /// state root. + /// + /// The node pool is fully revealed and checked against `state_root` before + /// this returns. Bytecode is looked up by the code hash proven in an + /// account leaf. + pub fn from_zone_state_witness( + witness: ZoneStateWitness, + state_root: B256, + ) -> Result { + let ZoneStateWitness { + node_pool, + bytecodes, + } = witness; + let state = StatelessSparseTrie::new(state_root, &node_pool)?; + let mut code_by_hash = B256Map::default(); + for code in bytecodes { + let code_hash = keccak256(&code); + if code_by_hash + .insert(code_hash, Bytecode::new_raw(code)) + .is_some() + { + return Err(WitnessDatabaseError::DuplicateBytecodeHash { code_hash }.into()); + } + } + + Ok(Self { + state, + pre_state_root: state_root, + node_pool, + accounts: AddressMap::default(), + storage: AddressMap::default(), + code_by_hash, + }) + } + + /// Calculate the post-state root from the initial witness and cumulative + /// in-memory execution changes. + pub(crate) fn state_root( + &self, + bundle_state: &BundleState, + ) -> Result { + let mut trie = StatelessSparseTrie::new(self.pre_state_root, &self.node_pool)?; + let state = reth_trie_common::HashedPostState::from_bundle_state::< + reth_trie_common::KeccakKeyHasher, + >(bundle_state.state()); + trie.calculate_state_root(state) + } +} + +impl Database for WitnessDatabase { + type Error = WitnessDatabaseError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + if let Some(account) = self.accounts.get(&address) { + return Ok(account.clone()); + } + + let account = self.state.account(address)?.map(|account| AccountInfo { + balance: account.balance, + nonce: account.nonce, + code_hash: account.code_hash, + account_id: None, + code: None, + }); + self.accounts.insert(address, account.clone()); + Ok(account) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.code_by_hash + .get(&code_hash) + .cloned() + .ok_or(WitnessDatabaseError::MissingCode { code_hash }) + } + + fn storage(&mut self, address: Address, slot: U256) -> Result { + if let Some(value) = self + .storage + .get(&address) + .and_then(|slots| slots.get(&slot)) + { + return Ok(*value); + } + + let value = self.state.storage(address, slot)?; + self.storage.entry(address).or_default().insert(slot, value); + Ok(value) + } + + fn block_hash(&mut self, number: u64) -> Result { + Err(WitnessDatabaseError::MissingBlockHash { number }) + } +} + +/// Tempo state reader for the checkpoint header supplied in the witness. +/// +/// When the shared node pool includes that checkpoint's root, it owns a fully +/// revealed immutable sparse trie. Otherwise it remains inactive and rejects +/// any Tempo storage read as an incomplete witness. +#[derive(Clone, Debug)] +pub struct TempoWitnessDatabase { + state: Option>, + tempo_block_hash: B256, + tempo_block_number: u64, + node_pool: Arc>, + sequencer: Option
, +} + +impl TempoWitnessDatabase { + /// Construct the reader for the initial Tempo checkpoint. + pub fn from_tempo_state_witness(witness: TempoStateWitness) -> Result { + let node_pool = Arc::new(witness.node_pool); + let (state, tempo_block_hash, tempo_block_number) = + checkpoint_state(&witness.initial_tempo_header_rlp, node_pool.as_ref())?; + + Ok(Self { + state, + tempo_block_hash, + tempo_block_number, + node_pool, + sequencer: None, + }) + } + + /// Return a reader rooted at a Tempo header imported by the current Zone + /// block. `ZoneInbox.advanceTempo` validates that the header is the next + /// checkpoint before any Tempo-dependent system work executes. + pub(crate) fn with_imported_checkpoint( + &self, + header_rlp: &alloy_primitives::Bytes, + ) -> Result { + let (state, tempo_block_hash, tempo_block_number) = + checkpoint_state(header_rlp, self.node_pool.as_ref())?; + + Ok(Self { + state, + tempo_block_hash, + tempo_block_number, + node_pool: self.node_pool.clone(), + sequencer: self.sequencer, + }) + } + + /// Attach the Zone's registered sequencer while creating one EVM instance. + pub(crate) fn for_sequencer(&self, sequencer: Address) -> Self { + Self { + sequencer: Some(sequencer), + ..self.clone() + } + } + + /// Returns the checkpoint committed by the decoded initial Tempo header. + pub(crate) fn checkpoint(&self) -> (u64, B256) { + (self.tempo_block_number, self.tempo_block_hash) + } +} + +fn checkpoint_state( + header_rlp: &[u8], + node_pool: &[Bytes], +) -> Result<(Option>, B256, u64), Error> { + let mut encoded_header = header_rlp; + let header = TempoHeader::decode(&mut encoded_header) + .map_err(|_| WitnessDatabaseError::InvalidTempoHeader)?; + if !encoded_header.is_empty() { + return Err(WitnessDatabaseError::InvalidTempoHeader.into()); + } + + let state_root = header.state_root(); + let state = match StatelessSparseTrie::new(state_root, node_pool) { + Ok(state) => Some(Arc::new(state)), + Err(StatelessSparseTrieError::MissingStateRootNode { .. }) => None, + Err(error) => return Err(error.into()), + }; + + Ok((state, keccak256(header_rlp), header.number())) +} + +impl L1StorageReader for TempoWitnessDatabase { + fn read_l1_storage( + &self, + account: Address, + slot: B256, + tempo_block_number: u64, + ) -> Result { + if tempo_block_number != self.tempo_block_number { + return Err(PrecompileError::Fatal(format!( + "Tempo witness has no root for checkpoint {tempo_block_number}" + ))); + } + + let state = self.state.as_ref().ok_or_else(|| { + PrecompileError::Fatal(format!( + "Tempo witness has no root for checkpoint {tempo_block_number}" + )) + })?; + let value = state + .storage(account, U256::from_be_bytes(slot.0)) + .map_err(|error| { + PrecompileError::Fatal(format!( + "invalid or incomplete Tempo witness for account {account:?} at slot {slot:?}: {error}" + )) + })?; + Ok(B256::from(value.to_be_bytes::<32>())) + } +} + +impl SequencerExt for TempoWitnessDatabase { + fn latest_sequencer(&self) -> Option
{ + self.sequencer + } +} diff --git a/crates/spf/src/execution/evm.rs b/crates/spf/src/execution/evm.rs new file mode 100644 index 000000000..98f8567ad --- /dev/null +++ b/crates/spf/src/execution/evm.rs @@ -0,0 +1,365 @@ +//! Tempo EVM setup and Zone-block execution. + +use alloy_consensus::{ + Signed, TxLegacy, + transaction::{Recovered, SignerRecoverable as _}, +}; +use alloy_eips::eip2718::Decodable2718 as _; +use alloy_evm::{EvmEnv, EvmFactory as _, block::BlockExecutor as _, eth::EthBlockExecutionCtx}; +use alloy_primitives::{Address, Bytes, U256}; +use alloy_sol_types::SolCall as _; +use revm::{ + context::{BlockEnv, CfgEnv}, + database::{State, states::bundle_state::BundleRetention}, + database_interface::bal::EvmDatabaseError, +}; +use tempo_chainspec::{TempoHardforks, hardfork::TempoHardfork}; +use tempo_evm::{TempoBlockEnv, TempoBlockExecutionCtx}; +use tempo_primitives::{ + TempoReceipt, TempoTxEnvelope, + transaction::envelope::{TEMPO_SYSTEM_TX_SENDER, TEMPO_SYSTEM_TX_SIGNATURE}, +}; +use tempo_zone_contracts::{ZONE_INBOX_ADDRESS, ZONE_OUTBOX_ADDRESS, ZoneInbox, ZoneOutbox}; +use zone_evm::{ZoneBlockExecutor, ZoneEvmFactory}; +use zone_primitives::constants::zone_chain_id; + +use crate::{ + Error, SpfConfig, ZoneBlock, + execution::database::{TempoWitnessDatabase, WitnessDatabase}, +}; + +type ZoneState = State; + +/// Execution artifacts committed by one Zone block header. +#[derive(Debug)] +pub(crate) struct ExecutedZoneBlock { + pub(crate) transactions: Vec, + pub(crate) receipts: Vec, +} + +/// REVM configuration and block environment prepared for one Zone block. +#[derive(Debug, Clone)] +pub(crate) struct ZoneEvmEnv { + pub(crate) cfg: CfgEnv, + pub(crate) block: TempoBlockEnv, +} + +impl ZoneEvmEnv { + /// Construct the Tempo execution environment for one Zone block. + /// + /// The Zone EVM uses the parent Tempo fork schedule at the Zone block + /// timestamp and has no protocol base fee. This matches + /// [`ZoneEvmConfig::next_evm_env`] in the block builder. + /// + /// The simplified Zone header does not commit a gas limit, so SPF receives + /// the fixed network value through [`SpfConfig`]. + pub(crate) fn new(config: &SpfConfig, zone_id: u32, block: &ZoneBlock) -> Self { + let mut cfg = + CfgEnv::new_with_spec(config.zone_chain_spec.tempo_hardfork_at(block.timestamp)); + cfg.chain_id = zone_chain_id(zone_id); + + Self { + cfg, + block: TempoBlockEnv { + inner: BlockEnv { + number: U256::from(block.number), + beneficiary: block.beneficiary, + timestamp: U256::from(block.timestamp), + gas_limit: config.block_gas_limit, + basefee: 0, + ..Default::default() + }, + ..Default::default() + }, + } + } + + /// Build the production block-execution context for this Zone block. + fn execution_context(&self, block: &ZoneBlock) -> TempoBlockExecutionCtx<'static> { + TempoBlockExecutionCtx { + inner: EthBlockExecutionCtx { + parent_hash: block.parent_hash, + parent_beacon_block_root: None, + ommers: &[], + withdrawals: None, + extra_data: Bytes::new(), + tx_count_hint: None, + slot_number: None, + }, + general_gas_limit: 0, + shared_gas_limit: self.block.inner.gas_limit, + validator_set: None, + consensus_context: None, + subblock_fee_recipients: Default::default(), + } + } +} + +/// Execute a complete Zone block in system-then-user order. +/// +/// When a Tempo header is present, `ZoneInbox.advanceTempo` executes first. +/// That call invokes `TempoState.finalizeTempo`, then processes deposits and +/// enabled tokens. User transactions run only after that system transition. +pub(crate) fn execute_zone_block( + env: &ZoneEvmEnv, + config: &SpfConfig, + zone_state: &mut ZoneState, + tempo_database: &TempoWitnessDatabase, + zone_block_index: usize, + sequencer: Address, + block: &ZoneBlock, +) -> Result { + let user_transactions = decode_user_transactions(zone_block_index, &block.transactions)?; + let mut transactions = Vec::with_capacity( + user_transactions.len() + + usize::from(block.tempo_header_rlp.is_some()) + + usize::from(block.finalize_withdrawal_batch_count.is_some()), + ); + let parent_number = block + .number + .checked_sub(1) + .ok_or(Error::BlockNumberOverflow)?; + if let Some(existing) = zone_state.block_hashes.get(parent_number) + && existing != block.parent_hash + { + return Err(crate::WitnessDatabaseError::ConflictingBlockHash { + number: parent_number, + expected: existing, + actual: block.parent_hash, + } + .into()); + } + zone_state + .block_hashes + .insert(parent_number, block.parent_hash); + + let tempo_reader = tempo_database.for_sequencer(sequencer); + let factory = ZoneEvmFactory::new(tempo_reader); + let evm = factory.create_evm( + &mut *zone_state, + EvmEnv::new(env.cfg.clone(), env.block.clone()), + ); + let mut executor = ZoneBlockExecutor::new( + evm, + env.execution_context(block), + config.zone_chain_spec.as_ref(), + ); + + executor.apply_pre_execution_changes().map_err(|error| { + map_block_execution_error( + error, + Error::BlockPreExecution { + block_index: zone_block_index, + }, + ) + })?; + + if let Some(header) = &block.tempo_header_rlp { + transactions.push(execute_advance_tempo( + &mut executor, + header, + block, + zone_block_index, + )?); + } + transactions.extend(execute_user_transactions( + &mut executor, + zone_block_index, + user_transactions, + )?); + if let Some(count) = block.finalize_withdrawal_batch_count { + transactions.push(execute_finalize_withdrawal_batch( + &mut executor, + count, + block.number, + block.finalize_withdrawal_batch_encrypted_senders.clone(), + zone_block_index, + )?); + } + + let (_, output) = executor.finish().map_err(|error| { + map_block_execution_error( + error, + Error::BlockPostExecution { + block_index: zone_block_index, + }, + ) + })?; + zone_state.merge_transitions(BundleRetention::Reverts); + + Ok(ExecutedZoneBlock { + transactions, + receipts: output.receipts, + }) +} + +fn execute_advance_tempo<'a, 'db, I>( + executor: &mut ZoneBlockExecutor<'a, &'db mut ZoneState, I>, + header: &Bytes, + block: &ZoneBlock, + block_index: usize, +) -> Result +where + I: alloy_evm::revm::Inspector>, +{ + let calldata = ZoneInbox::advanceTempoCall { + header: header.clone(), + deposits: block.deposits.clone(), + decryptions: block.decryptions.clone(), + enabledTokens: block.enabled_tokens.clone(), + } + .abi_encode(); + let transaction = TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 0, + gas_limit: 0, + to: ZONE_INBOX_ADDRESS.into(), + value: U256::ZERO, + input: calldata.into(), + }; + let transaction = + TempoTxEnvelope::Legacy(Signed::new_unhashed(transaction, TEMPO_SYSTEM_TX_SIGNATURE)); + let recovered = Recovered::new_unchecked(transaction.clone(), TEMPO_SYSTEM_TX_SENDER); + + execute_recovered_transaction( + executor, + recovered, + Error::AdvanceTempoExecution { block_index }, + true, + )?; + Ok(transaction) +} + +fn execute_finalize_withdrawal_batch<'a, 'db, I>( + executor: &mut ZoneBlockExecutor<'a, &'db mut ZoneState, I>, + count: U256, + block_number: u64, + encrypted_senders: Vec, + block_index: usize, +) -> Result +where + I: alloy_evm::revm::Inspector>, +{ + let calldata = ZoneOutbox::finalizeWithdrawalBatchCall { + count, + blockNumber: block_number, + encryptedSenders: encrypted_senders, + } + .abi_encode(); + let transaction = TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 0, + gas_limit: 0, + to: ZONE_OUTBOX_ADDRESS.into(), + value: U256::ZERO, + input: calldata.into(), + }; + let transaction = + TempoTxEnvelope::Legacy(Signed::new_unhashed(transaction, TEMPO_SYSTEM_TX_SIGNATURE)); + let recovered = Recovered::new_unchecked(transaction.clone(), TEMPO_SYSTEM_TX_SENDER); + + execute_recovered_transaction( + executor, + recovered, + Error::FinalizeWithdrawalBatchExecution { block_index }, + true, + )?; + Ok(transaction) +} + +fn decode_user_transactions( + block_index: usize, + transactions: &[Bytes], +) -> Result>, Error> { + let mut decoded = Vec::with_capacity(transactions.len()); + for (transaction_index, encoded_transaction) in transactions.iter().enumerate() { + let transaction = + TempoTxEnvelope::decode_2718_exact(encoded_transaction).map_err(|_| { + Error::TransactionDecoding { + block_index, + transaction_index, + } + })?; + if transaction.is_system_tx() { + return Err(Error::SystemTransactionInUserList { + block_index, + transaction_index, + }); + } + let signer = transaction + .recover_signer() + .map_err(|_| Error::TransactionSignature { + block_index, + transaction_index, + })?; + decoded.push(Recovered::new_unchecked(transaction, signer)); + } + Ok(decoded) +} + +fn execute_user_transactions<'a, 'db, I>( + executor: &mut ZoneBlockExecutor<'a, &'db mut ZoneState, I>, + block_index: usize, + transactions: Vec>, +) -> Result, Error> +where + I: alloy_evm::revm::Inspector>, +{ + let mut executed = Vec::with_capacity(transactions.len()); + for (transaction_index, transaction) in transactions.into_iter().enumerate() { + let envelope = transaction.clone_inner(); + execute_recovered_transaction( + executor, + transaction, + Error::TransactionExecution { + block_index, + transaction_index, + }, + false, + )?; + executed.push(envelope); + } + + Ok(executed) +} + +fn execute_recovered_transaction<'a, 'db, I>( + executor: &mut ZoneBlockExecutor<'a, &'db mut ZoneState, I>, + transaction: Recovered, + execution_error: Error, + require_success: bool, +) -> Result<(), Error> +where + I: alloy_evm::revm::Inspector>, +{ + let result = executor + .execute_transaction_without_commit(transaction) + .map_err(|error| map_block_execution_error(error, execution_error))?; + if require_success && !result.result.result.is_success() { + return Err(execution_error); + } + executor.commit_transaction(result); + Ok(()) +} + +fn map_block_execution_error( + error: alloy_evm::block::BlockExecutionError, + execution_error: Error, +) -> Error { + type WitnessEvmError = revm::context::result::EVMError< + EvmDatabaseError, + tempo_evm::TempoInvalidTransaction, + >; + + if let Some(revm::context::result::EVMError::Database(EvmDatabaseError::Database(error))) = + error + .as_internal() + .and_then(|error| error.downcast_evm::()) + { + return (*error).into(); + } + + execution_error +} diff --git a/crates/spf/src/execution/mod.rs b/crates/spf/src/execution/mod.rs new file mode 100644 index 000000000..4aad83fa2 --- /dev/null +++ b/crates/spf/src/execution/mod.rs @@ -0,0 +1,4 @@ +//! Execution-specific components for the Zone state transition function. + +pub(crate) mod database; +pub(crate) mod evm; diff --git a/crates/spf/src/lib.rs b/crates/spf/src/lib.rs new file mode 100644 index 000000000..2a94cadcb --- /dev/null +++ b/crates/spf/src/lib.rs @@ -0,0 +1,1022 @@ +//! Stateless state transition function for Tempo Zones. +//! +//! The implementation is built incrementally around strict witness-backed +//! execution. It is presently a normal Rust verifier rather than a `no_std` +//! proving guest. + +use alloy_consensus::{ + BlockHeader as _, TxReceipt, + proofs::{calculate_receipt_root, calculate_transaction_root}, +}; +use alloy_primitives::{B256, U256, keccak256}; +use alloy_rlp::Decodable as _; +use revm::{Database as _, database::State, database_interface::bal::EvmDatabaseError}; +use tempo_primitives::TempoHeader; +use zone_precompiles::tempo_state::slots as tempo_state_slots; +use zone_primitives::constants::{ + TEMPO_STATE_ADDRESS, ZONE_INBOX_ADDRESS, ZONE_INBOX_PROCESSED_HASH_SLOT, + ZONE_INBOX_PROCESSED_NUMBER_SLOT, ZONE_OUTBOX_ADDRESS, ZONE_OUTBOX_LAST_BATCH_HASH_SLOT, + ZONE_OUTBOX_LAST_BATCH_INDEX_SLOT, +}; + +mod execution; +mod mpt; +mod types; + +pub use execution::database::{TempoWitnessDatabase, WitnessDatabase, WitnessDatabaseError}; +pub use mpt::StatelessSparseTrieError; +pub use types::*; + +/// Execute a Zone batch witness and return its public commitments. +/// +/// `config` is trusted network configuration chosen by the verifier. Every +/// other value is prover supplied and must be validated against witness-backed +/// execution. +pub fn prove_zone_batch(config: &SpfConfig, witness: BatchWitness) -> Result { + // The parent header is the committed starting point for this batch. Its + // hash binds the witness to the previously submitted Zone block, and its + // state root selects the initial Zone state. + let previous_header_hash = witness.parent_header.hash(); + if previous_header_hash != witness.public_inputs.prev_block_hash { + return Err(Error::PreviousBlockHashMismatch { + expected: witness.public_inputs.prev_block_hash, + actual: previous_header_hash, + }); + } + if witness.zone_blocks.is_empty() { + return Err(Error::EmptyZoneBatch); + } + // The Zone database is backed by the parent state root and the supplied + // trie nodes. Reads performed during execution are therefore limited to + // state proven by the witness, while writes remain in REVM's overlay. + let zone_database = WitnessDatabase::from_zone_state_witness( + witness.zone_state_witness, + witness.parent_header.state_root, + )?; + let mut zone_state = State::builder() + .with_database(zone_database) + .with_bundle_update() + .build(); + + // Capture the pre-batch deposit state from the parent Zone state. The + // transition output commits to this exact pair. + let previous_processed_hash = B256::from( + read_zone_storage( + &mut zone_state, + ZONE_INBOX_ADDRESS, + ZONE_INBOX_PROCESSED_HASH_SLOT, + )? + .to_be_bytes::<32>(), + ); + let previous_processed_number = read_zone_storage( + &mut zone_state, + ZONE_INBOX_ADDRESS, + ZONE_INBOX_PROCESSED_NUMBER_SLOT, + )? + .to::(); + + // The initial Tempo header supplies the root for Tempo-side reads. The + // Zone's own TempoState must already contain the same number and hash; + // otherwise the witness would describe a different L1 checkpoint. + let mut tempo_database = + TempoWitnessDatabase::from_tempo_state_witness(witness.tempo_state_witness)?; + let (witnessed_tempo_number, witnessed_tempo_hash) = tempo_database.checkpoint(); + let zone_tempo_hash = B256::from( + read_zone_storage( + &mut zone_state, + TEMPO_STATE_ADDRESS, + U256::from(tempo_state_slots::TEMPO_BLOCK_HASH), + )? + .to_be_bytes::<32>(), + ); + let zone_tempo_number = read_zone_storage( + &mut zone_state, + TEMPO_STATE_ADDRESS, + U256::from(tempo_state_slots::TEMPO_BLOCK_NUMBER), + )? + .to::(); + if (zone_tempo_number, zone_tempo_hash) != (witnessed_tempo_number, witnessed_tempo_hash) { + return Err(Error::InitialTempoCheckpointMismatch { + expected_number: zone_tempo_number, + expected_hash: zone_tempo_hash, + actual_number: witnessed_tempo_number, + actual_hash: witnessed_tempo_hash, + }); + } + + // Each block is checked against the header produced by its predecessor. + // After execution, the post-state, transaction, and receipt roots form a + // new simplified Zone header that becomes the next block's parent. + let mut previous_header = witness.parent_header.clone(); + let block_count = witness.zone_blocks.len(); + for (block_index, block) in witness.zone_blocks.iter().enumerate() { + let expected_parent_hash = previous_header.hash(); + if block.parent_hash != expected_parent_hash { + return Err(Error::BlockParentHashMismatch { + expected: expected_parent_hash, + actual: block.parent_hash, + }); + } + + let expected_number = previous_header + .number + .checked_add(1) + .ok_or(Error::BlockNumberOverflow)?; + if block.number != expected_number { + return Err(Error::BlockNumberMismatch { + expected: expected_number, + actual: block.number, + }); + } + if block.timestamp < previous_header.timestamp { + return Err(Error::BlockTimestampRegression { + previous: previous_header.timestamp, + actual: block.timestamp, + }); + } + if block.beneficiary != witness.public_inputs.sequencer { + return Err(Error::BlockBeneficiaryMismatch { + expected: witness.public_inputs.sequencer, + actual: block.beneficiary, + }); + } + validate_system_inputs(block, block_index, block_index + 1 == block_count)?; + + // The EVM environment uses the verifier-selected fork schedule at this + // block's timestamp. An imported Tempo header changes the L1 reader + // used by the subsequent system and user execution in this block. + let env = execution::evm::ZoneEvmEnv::new(config, witness.public_inputs.zone_id, block); + if let Some(header) = &block.tempo_header_rlp { + tempo_database = tempo_database.with_imported_checkpoint(header)?; + } + let executed_block = execution::evm::execute_zone_block( + &env, + config, + &mut zone_state, + &tempo_database, + block_index, + witness.public_inputs.sequencer, + block, + )?; + + let state_root = zone_state.database.state_root(&zone_state.bundle_state)?; + let transactions_root = calculate_transaction_root(&executed_block.transactions); + let receipts_with_bloom = executed_block + .receipts + .iter() + .map(TxReceipt::with_bloom_ref) + .collect::>(); + let receipts_root = calculate_receipt_root(&receipts_with_bloom); + previous_header = ZoneHeader { + parent_hash: expected_parent_hash, + beneficiary: block.beneficiary, + state_root, + transactions_root, + receipts_root, + number: block.number, + timestamp: block.timestamp, + protocol_version: block.protocol_version, + }; + } + + // These reads see the final execution overlay rather than just the parent + // witness. They are the contract state values committed by the batch + // output: inbox progress, the finalized withdrawal batch, and TempoState. + let next_processed_hash = B256::from( + read_zone_storage( + &mut zone_state, + ZONE_INBOX_ADDRESS, + ZONE_INBOX_PROCESSED_HASH_SLOT, + )? + .to_be_bytes::<32>(), + ); + let next_processed_number = read_zone_storage( + &mut zone_state, + ZONE_INBOX_ADDRESS, + ZONE_INBOX_PROCESSED_NUMBER_SLOT, + )? + .to::(); + let withdrawal_queue_hash = B256::from( + read_zone_storage( + &mut zone_state, + ZONE_OUTBOX_ADDRESS, + ZONE_OUTBOX_LAST_BATCH_HASH_SLOT, + )? + .to_be_bytes::<32>(), + ); + let withdrawal_batch_index = read_zone_storage( + &mut zone_state, + ZONE_OUTBOX_ADDRESS, + ZONE_OUTBOX_LAST_BATCH_INDEX_SLOT, + )? + .to::(); + let final_tempo_hash = B256::from( + read_zone_storage( + &mut zone_state, + TEMPO_STATE_ADDRESS, + U256::from(tempo_state_slots::TEMPO_BLOCK_HASH), + )? + .to_be_bytes::<32>(), + ); + let final_tempo_number = read_zone_storage( + &mut zone_state, + TEMPO_STATE_ADDRESS, + U256::from(tempo_state_slots::TEMPO_BLOCK_NUMBER), + )? + .to::(); + + // The final Tempo checkpoint must be the publicly declared target. When + // the anchor is newer, the supplied headers extend that checkpoint to the + // public anchor hash. + if final_tempo_number != witness.public_inputs.tempo_block_number { + return Err(Error::FinalTempoBlockNumberMismatch { + expected: witness.public_inputs.tempo_block_number, + actual: final_tempo_number, + }); + } + validate_tempo_anchor( + final_tempo_number, + final_tempo_hash, + &witness.public_inputs, + &witness.tempo_ancestry_headers, + )?; + + // The result links the public parent hash to the final carried header and + // exposes the state transitions the portal will commit on successful proof + // verification. + if withdrawal_batch_index != witness.public_inputs.expected_withdrawal_batch_index { + return Err(Error::WithdrawalBatchIndexMismatch { + expected: witness.public_inputs.expected_withdrawal_batch_index, + actual: withdrawal_batch_index, + }); + } + Ok(BatchOutput { + block_transition: BlockTransition { + prevBlockHash: witness.public_inputs.prev_block_hash, + nextBlockHash: previous_header.hash(), + }, + deposit_queue_transition: DepositQueueTransition { + prevProcessedHash: previous_processed_hash, + nextProcessedHash: next_processed_hash, + prevDepositNumber: previous_processed_number, + nextDepositNumber: next_processed_number, + }, + withdrawal_queue_hash, + last_batch_commitment: LastBatchCommitment { + withdrawal_batch_index, + }, + }) +} + +fn read_zone_storage( + zone_state: &mut State, + address: alloy_primitives::Address, + slot: U256, +) -> Result { + match zone_state.storage(address, slot) { + Ok(value) => Ok(value), + Err(EvmDatabaseError::Database(error)) => Err(error.into()), + Err(EvmDatabaseError::Bal(_)) => Err(Error::UnexpectedBalancedAccess { address, slot }), + } +} + +fn validate_tempo_anchor( + tempo_block_number: u64, + tempo_block_hash: B256, + public_inputs: &PublicInputs, + ancestry_headers: &[alloy_primitives::Bytes], +) -> Result<(), Error> { + if public_inputs.anchor_block_number < tempo_block_number { + return Err(Error::AnchorBlockNumberBeforeTempo { + tempo_block_number, + anchor_block_number: public_inputs.anchor_block_number, + }); + } + + if public_inputs.anchor_block_number == tempo_block_number { + if !ancestry_headers.is_empty() { + return Err(Error::UnexpectedTempoAncestryHeaders); + } + if tempo_block_hash != public_inputs.anchor_block_hash { + return Err(Error::TempoAnchorHashMismatch { + expected: public_inputs.anchor_block_hash, + actual: tempo_block_hash, + }); + } + return Ok(()); + } + + let expected_len = (public_inputs.anchor_block_number - tempo_block_number) as usize; + if ancestry_headers.len() != expected_len { + return Err(Error::TempoAncestryLengthMismatch { + expected: expected_len, + actual: ancestry_headers.len(), + }); + } + + let mut previous_number = tempo_block_number; + let mut previous_hash = tempo_block_hash; + for (index, encoded_header) in ancestry_headers.iter().enumerate() { + let mut encoded = encoded_header.as_ref(); + let header = TempoHeader::decode(&mut encoded) + .map_err(|_| Error::TempoAncestryHeaderDecoding { index })?; + if !encoded.is_empty() { + return Err(Error::TempoAncestryHeaderDecoding { index }); + } + let expected_number = previous_number + .checked_add(1) + .ok_or(Error::TempoAncestryBlockNumberOverflow)?; + if header.number() != expected_number { + return Err(Error::TempoAncestryHeaderNumberMismatch { + index, + expected: expected_number, + actual: header.number(), + }); + } + if header.parent_hash() != previous_hash { + return Err(Error::TempoAncestryParentHashMismatch { + index, + expected: previous_hash, + actual: header.parent_hash(), + }); + } + previous_number = header.number(); + previous_hash = keccak256(encoded_header); + } + + if previous_hash != public_inputs.anchor_block_hash { + return Err(Error::TempoAnchorHashMismatch { + expected: public_inputs.anchor_block_hash, + actual: previous_hash, + }); + } + Ok(()) +} + +fn validate_system_inputs(block: &ZoneBlock, index: usize, is_final: bool) -> Result<(), Error> { + if block.tempo_header_rlp.is_none() + && (!block.deposits.is_empty() + || !block.decryptions.is_empty() + || !block.enabled_tokens.is_empty()) + { + return Err(Error::TempoInputsWithoutHeader { block_index: index }); + } + + match (is_final, block.finalize_withdrawal_batch_count) { + (true, None) => return Err(Error::MissingFinalization { block_index: index }), + (false, Some(_)) => return Err(Error::UnexpectedFinalization { block_index: index }), + _ => {} + } + + match block.finalize_withdrawal_batch_count { + Some(count) + if count != U256::from(block.finalize_withdrawal_batch_encrypted_senders.len()) => + { + return Err(Error::FinalizationEncryptedSenderCountMismatch { + block_index: index, + expected: count, + actual: block.finalize_withdrawal_batch_encrypted_senders.len(), + }); + } + None if !block.finalize_withdrawal_batch_encrypted_senders.is_empty() => { + return Err(Error::FinalizationEncryptedSendersWithoutCount { block_index: index }); + } + _ => {} + } + + Ok(()) +} + +/// Errors emitted by the stateless state transition function. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// The Zone MPT witness did not prove one of its supplied reads. + #[error(transparent)] + MptValidation(#[from] StatelessSparseTrieError), + /// A read against the provided state witness failed. + #[error(transparent)] + WitnessDatabase(#[from] WitnessDatabaseError), + /// The previous header does not hash to the committed previous block hash. + #[error("previous block hash mismatch: expected {expected:?}, got {actual:?}")] + PreviousBlockHashMismatch { expected: B256, actual: B256 }, + /// A batch must execute at least one Zone block. + #[error("zone batch contains no blocks")] + EmptyZoneBatch, + /// The initial Tempo witness header is not the checkpoint stored in the + /// parent Zone state. + #[error( + "initial Tempo checkpoint mismatch: expected ({expected_number}, {expected_hash:?}), got ({actual_number}, {actual_hash:?})" + )] + InitialTempoCheckpointMismatch { + expected_number: u64, + expected_hash: B256, + actual_number: u64, + actual_hash: B256, + }, + /// The first Zone block is not chained to the previous committed header. + #[error("zone block parent hash mismatch: expected {expected:?}, got {actual:?}")] + BlockParentHashMismatch { expected: B256, actual: B256 }, + /// A Zone block number does not increment by one. + #[error("zone block number mismatch: expected {expected}, got {actual}")] + BlockNumberMismatch { expected: u64, actual: u64 }, + /// Zone block numbering cannot advance past `u64::MAX`. + #[error("zone block number overflow")] + BlockNumberOverflow, + /// A Zone block timestamp regressed from its predecessor. + #[error("zone block timestamp regressed: previous {previous}, got {actual}")] + BlockTimestampRegression { previous: u64, actual: u64 }, + /// A Zone block beneficiary is not the public sequencer. + #[error("zone block beneficiary mismatch: expected {expected:?}, got {actual:?}")] + BlockBeneficiaryMismatch { + expected: alloy_primitives::Address, + actual: alloy_primitives::Address, + }, + /// Tempo-dependent inputs appeared without a Tempo header import. + #[error("zone block {block_index} has Tempo inputs without a Tempo header")] + TempoInputsWithoutHeader { block_index: usize }, + /// An intermediate Zone block attempted withdrawal finalization. + #[error("intermediate zone block {block_index} attempted withdrawal finalization")] + UnexpectedFinalization { block_index: usize }, + /// The final Zone block omitted withdrawal finalization. + #[error("final zone block {block_index} omitted withdrawal finalization")] + MissingFinalization { block_index: usize }, + /// Finalization sender data was supplied without a finalization count. + #[error("zone block {block_index} has finalization senders without a count")] + FinalizationEncryptedSendersWithoutCount { block_index: usize }, + /// Finalization sender data has a different length than its declared count. + #[error( + "zone block {block_index} finalization sender count mismatch: expected {expected}, got {actual}" + )] + FinalizationEncryptedSenderCountMismatch { + block_index: usize, + expected: alloy_primitives::U256, + actual: usize, + }, + /// A raw user transaction was not a complete Tempo EIP-2718 envelope. + #[error("failed to decode transaction {transaction_index} in zone block {block_index}")] + TransactionDecoding { + block_index: usize, + transaction_index: usize, + }, + /// A system transaction appeared in the user-transaction list. + #[error( + "system transaction {transaction_index} appeared in zone block {block_index} user transactions" + )] + SystemTransactionInUserList { + block_index: usize, + transaction_index: usize, + }, + /// Production block pre-execution changes could not be applied. + #[error("failed to apply pre-execution changes in zone block {block_index}")] + BlockPreExecution { block_index: usize }, + /// The ZoneInbox system transaction failed while advancing Tempo. + #[error("failed to execute advanceTempo in zone block {block_index}")] + AdvanceTempoExecution { block_index: usize }, + /// The ZoneOutbox system transaction failed while finalizing withdrawals. + #[error("failed to execute finalizeWithdrawalBatch in zone block {block_index}")] + FinalizeWithdrawalBatchExecution { block_index: usize }, + /// A decoded user transaction had an invalid sender signature. + #[error("invalid signature for transaction {transaction_index} in zone block {block_index}")] + TransactionSignature { + block_index: usize, + transaction_index: usize, + }, + /// The Tempo EVM rejected or failed to execute a user transaction. + #[error("failed to execute transaction {transaction_index} in zone block {block_index}")] + TransactionExecution { + block_index: usize, + transaction_index: usize, + }, + /// Production block post-execution changes could not be finalized. + #[error("failed to finalize execution of zone block {block_index}")] + BlockPostExecution { block_index: usize }, + /// An internal post-execution state read unexpectedly hit BAL state. + #[error("unexpected balanced access while reading {address:?} slot {slot:?}")] + UnexpectedBalancedAccess { + address: alloy_primitives::Address, + slot: U256, + }, + /// The final Tempo checkpoint number differs from the public value. + #[error("final Tempo block number mismatch: expected {expected}, got {actual}")] + FinalTempoBlockNumberMismatch { expected: u64, actual: u64 }, + /// The requested anchor predates the proven Tempo checkpoint. + #[error("Tempo anchor block {anchor_block_number} precedes checkpoint {tempo_block_number}")] + AnchorBlockNumberBeforeTempo { + tempo_block_number: u64, + anchor_block_number: u64, + }, + /// Direct Tempo anchoring must not include ancestry headers. + #[error("direct Tempo anchor included ancestry headers")] + UnexpectedTempoAncestryHeaders, + /// The supplied ancestry chain has the wrong number of headers. + #[error("Tempo ancestry length mismatch: expected {expected}, got {actual}")] + TempoAncestryLengthMismatch { expected: usize, actual: usize }, + /// An ancestry header is not complete Tempo header RLP. + #[error("invalid Tempo ancestry header at index {index}")] + TempoAncestryHeaderDecoding { index: usize }, + /// An ancestry header number is not consecutive. + #[error("Tempo ancestry header {index} number mismatch: expected {expected}, got {actual}")] + TempoAncestryHeaderNumberMismatch { + index: usize, + expected: u64, + actual: u64, + }, + /// Tempo block numbering overflowed while validating ancestry. + #[error("Tempo ancestry block number overflow")] + TempoAncestryBlockNumberOverflow, + /// An ancestry header does not point to the preceding Tempo block. + #[error( + "Tempo ancestry header {index} parent hash mismatch: expected {expected:?}, got {actual:?}" + )] + TempoAncestryParentHashMismatch { + index: usize, + expected: B256, + actual: B256, + }, + /// The direct Tempo checkpoint or ancestry chain did not reach the anchor. + #[error("Tempo anchor hash mismatch: expected {expected:?}, got {actual:?}")] + TempoAnchorHashMismatch { expected: B256, actual: B256 }, + /// The final withdrawal batch index does not match the public value. + #[error("withdrawal batch index mismatch: expected {expected}, got {actual}")] + WithdrawalBatchIndexMismatch { expected: u64, actual: u64 }, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Header; + use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; + use reth_trie_common::{EMPTY_ROOT_HASH, LeafNode, Nibbles, TrieAccount, TrieNode}; + use revm::{ + DatabaseCommit as _, + database::{State, states::bundle_state::BundleRetention}, + }; + use std::sync::Arc; + use tempo_primitives::TempoHeader; + use zone_precompiles::L1StorageReader as _; + + fn test_config() -> SpfConfig { + SpfConfig::new( + Arc::new(zone_chainspec::ZoneChainSpec::from( + tempo_chainspec::spec::MODERATO.clone(), + )), + 30_000_000, + ) + } + + fn minimal_batch_witness() -> BatchWitness { + let parent_header = ZoneHeader { + parent_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: EMPTY_ROOT_HASH, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, + number: 0, + timestamp: 0, + protocol_version: 0, + }; + + BatchWitness { + public_inputs: PublicInputs { + zone_id: 1, + prev_block_hash: parent_header.hash(), + tempo_block_number: 2, + anchor_block_number: 2, + anchor_block_hash: B256::ZERO, + expected_withdrawal_batch_index: 3, + sequencer: Address::ZERO, + }, + parent_header, + zone_blocks: Vec::new(), + zone_state_witness: ZoneStateWitness { + node_pool: Vec::new(), + bytecodes: Vec::new(), + }, + tempo_state_witness: empty_tempo_witness(2), + tempo_ancestry_headers: Vec::new(), + } + } + + fn empty_tempo_witness(number: u64) -> TempoStateWitness { + let header = TempoHeader { + inner: Header { + number, + state_root: EMPTY_ROOT_HASH, + ..Default::default() + }, + ..Default::default() + }; + + TempoStateWitness { + initial_tempo_header_rlp: Bytes::from(alloy_rlp::encode(header)), + node_pool: Vec::new(), + } + } + + fn witnessed_account_state( + address: Address, + nonce: u64, + balance: U256, + code_hash: B256, + bytecodes: Vec, + storage: Option<(U256, U256)>, + ) -> (B256, ZoneStateWitness) { + let mut node_pool = Vec::new(); + let storage_root = match storage { + Some((slot, value)) => { + let storage_node = TrieNode::Leaf(LeafNode::new( + Nibbles::unpack(keccak256(slot.to_be_bytes::<32>())), + alloy_rlp::encode(value), + )); + let encoded = alloy_rlp::encode(&storage_node); + let root = keccak256(&encoded); + node_pool.push(Bytes::from(encoded)); + root + } + None => EMPTY_ROOT_HASH, + }; + let account = TrieAccount { + nonce, + balance, + storage_root, + code_hash, + }; + let account_node = TrieNode::Leaf(LeafNode::new( + Nibbles::unpack(keccak256(address)), + alloy_rlp::encode(account), + )); + let encoded = alloy_rlp::encode(&account_node); + let state_root = keccak256(&encoded); + node_pool.push(Bytes::from(encoded)); + + ( + state_root, + ZoneStateWitness { + node_pool, + bytecodes, + }, + ) + } + + #[test] + fn constructs_a_minimal_batch_witness() { + let witness = minimal_batch_witness(); + + assert_eq!(witness.public_inputs.zone_id, 1); + assert!(witness.zone_blocks.is_empty()); + } + + #[test] + fn rejects_an_empty_zone_batch() { + let witness = minimal_batch_witness(); + + assert_eq!( + prove_zone_batch(&test_config(), witness), + Err(Error::EmptyZoneBatch) + ); + } + + #[test] + fn rejects_a_previous_header_not_bound_to_public_inputs() { + let mut witness = minimal_batch_witness(); + witness.public_inputs.prev_block_hash = B256::ZERO; + assert_ne!(witness.parent_header.hash(), B256::ZERO); + + assert_eq!( + prove_zone_batch(&test_config(), witness), + Err(Error::PreviousBlockHashMismatch { + expected: B256::ZERO, + actual: minimal_batch_witness().parent_header.hash(), + }) + ); + } + + #[test] + fn rejects_a_zone_witness_without_the_parent_state_root_node() { + let database = WitnessDatabase::from_zone_state_witness( + ZoneStateWitness { + node_pool: Vec::new(), + bytecodes: Vec::new(), + }, + B256::repeat_byte(1), + ); + + assert!(matches!( + database, + Err(Error::MptValidation( + StatelessSparseTrieError::MissingStateRootNode { state_root } + )) if state_root == B256::repeat_byte(1) + )); + } + + #[test] + fn resolves_zone_state_reads_from_trie_leaves() { + let account = Address::repeat_byte(0x11); + let code = Bytes::from([0x60, 0x00]); + let code_hash = keccak256(&code); + let (state_root, witness) = witnessed_account_state( + account, + 7, + U256::from(42), + code_hash, + vec![code.clone()], + Some((U256::from(3), U256::from(9))), + ); + let mut database = WitnessDatabase::from_zone_state_witness(witness, state_root).unwrap(); + + let info = database.basic(account).unwrap().unwrap(); + assert_eq!(info.nonce, 7); + assert_eq!(info.balance, U256::from(42)); + assert_eq!( + database + .code_by_hash(code_hash) + .unwrap() + .original_byte_slice(), + code.as_ref() + ); + assert_eq!( + database.storage(account, U256::from(3)).unwrap(), + U256::from(9) + ); + } + + #[test] + fn keeps_evm_state_in_the_bundle_overlay() { + let address = Address::repeat_byte(0x22); + let database = WitnessDatabase::from_zone_state_witness( + ZoneStateWitness { + node_pool: Vec::new(), + bytecodes: Vec::new(), + }, + EMPTY_ROOT_HASH, + ) + .unwrap(); + let mut state = State::builder() + .with_database(database) + .with_bundle_update() + .build(); + let mut changes = revm::primitives::AddressMap::default(); + let mut account = revm::state::Account::default(); + account.info.balance = U256::from(42); + account.mark_touch(); + changes.insert(address, account); + + state.commit(changes); + state.merge_transitions(BundleRetention::PlainState); + + assert_eq!( + state.basic(address).unwrap().unwrap().balance, + U256::from(42) + ); + assert_eq!(state.database.basic(address).unwrap(), None); + assert!(state.bundle_state.state.contains_key(&address)); + + let expected_account = TrieAccount { + nonce: 0, + balance: U256::from(42), + storage_root: EMPTY_ROOT_HASH, + code_hash: alloy_consensus::constants::KECCAK_EMPTY, + }; + let expected_root = keccak256(alloy_rlp::encode(TrieNode::Leaf(LeafNode::new( + Nibbles::unpack(keccak256(address)), + alloy_rlp::encode(expected_account), + )))); + assert_eq!( + state.database.state_root(&state.bundle_state).unwrap(), + expected_root + ); + } + + #[test] + fn ignores_stale_nodes_outside_the_bound_state_root() { + let address = Address::repeat_byte(0x23); + let stale_node = TrieNode::Leaf(LeafNode::new( + Nibbles::unpack(keccak256(address)), + alloy_rlp::encode(TrieAccount::default()), + )); + let mut database = WitnessDatabase::from_zone_state_witness( + ZoneStateWitness { + node_pool: vec![Bytes::from(alloy_rlp::encode(stale_node))], + bytecodes: Vec::new(), + }, + EMPTY_ROOT_HASH, + ) + .unwrap(); + + assert_eq!(database.basic(address).unwrap(), None); + } + + #[test] + fn rejects_zone_bytecode_absent_from_the_bytecode_pool() { + let account = Address::repeat_byte(0x44); + let code_hash = keccak256([0x60, 0x00]); + let (state_root, witness) = + witnessed_account_state(account, 1, U256::from(2), code_hash, Vec::new(), None); + let mut database = WitnessDatabase::from_zone_state_witness(witness, state_root).unwrap(); + + assert_eq!( + database.code_by_hash(code_hash), + Err(WitnessDatabaseError::MissingCode { code_hash }) + ); + } + + #[test] + fn resolves_tempo_state_from_its_initial_header() { + let account = Address::repeat_byte(0x33); + let slot = B256::repeat_byte(0x07); + let database = + TempoWitnessDatabase::from_tempo_state_witness(empty_tempo_witness(9)).unwrap(); + + assert_eq!( + database + .for_sequencer(Address::ZERO) + .read_l1_storage(account, slot, 9) + .unwrap(), + B256::ZERO + ); + assert!( + database + .for_sequencer(Address::ZERO) + .read_l1_storage(account, slot, 8) + .is_err() + ); + } + + #[test] + fn fully_reveals_the_active_tempo_checkpoint() { + let account = Address::repeat_byte(0x34); + let slot = U256::from(7); + let value = U256::from(11); + let (state_root, zone_witness) = witnessed_account_state( + account, + 0, + U256::ZERO, + keccak256([]), + Vec::new(), + Some((slot, value)), + ); + let header = TempoHeader { + inner: Header { + number: 9, + state_root, + ..Default::default() + }, + ..Default::default() + }; + let database = TempoWitnessDatabase::from_tempo_state_witness(TempoStateWitness { + initial_tempo_header_rlp: Bytes::from(alloy_rlp::encode(header)), + node_pool: zone_witness.node_pool, + }) + .unwrap(); + + assert_eq!( + database + .read_l1_storage(account, B256::from(slot.to_be_bytes::<32>()), 9) + .unwrap(), + B256::from(value.to_be_bytes::<32>()) + ); + } + + #[test] + fn prepares_a_zone_block_execution_environment() { + let witness = minimal_batch_witness(); + let block = ZoneBlock { + number: 1, + parent_hash: witness.parent_header.hash(), + timestamp: 0, + beneficiary: witness.public_inputs.sequencer, + protocol_version: 0, + tempo_header_rlp: None, + deposits: Vec::new(), + decryptions: Vec::new(), + enabled_tokens: Vec::new(), + finalize_withdrawal_batch_count: Some(U256::ZERO), + finalize_withdrawal_batch_encrypted_senders: Vec::new(), + transactions: Vec::new(), + }; + + let env = + execution::evm::ZoneEvmEnv::new(&test_config(), witness.public_inputs.zone_id, &block); + + assert_eq!( + env.cfg.chain_id, + zone_primitives::constants::zone_chain_id(1) + ); + assert_eq!(env.block.number, U256::from(1)); + assert_eq!(env.block.beneficiary, Address::ZERO); + assert_eq!(env.block.timestamp, U256::ZERO); + } + + #[test] + fn rejects_a_final_block_without_finalization() { + let witness = minimal_batch_witness(); + let block = ZoneBlock { + number: 1, + parent_hash: witness.parent_header.hash(), + timestamp: 0, + beneficiary: witness.public_inputs.sequencer, + protocol_version: 0, + tempo_header_rlp: None, + deposits: Vec::new(), + decryptions: Vec::new(), + enabled_tokens: Vec::new(), + finalize_withdrawal_batch_count: None, + finalize_withdrawal_batch_encrypted_senders: Vec::new(), + transactions: Vec::new(), + }; + + assert_eq!( + validate_system_inputs(&block, 0, true), + Err(Error::MissingFinalization { block_index: 0 }) + ); + } + + #[test] + fn binds_the_initial_tempo_checkpoint_before_block_execution() { + let mut witness = minimal_batch_witness(); + witness.zone_blocks.push(ZoneBlock { + number: 1, + parent_hash: witness.parent_header.hash(), + timestamp: 0, + beneficiary: witness.public_inputs.sequencer, + protocol_version: 0, + tempo_header_rlp: None, + deposits: Vec::new(), + decryptions: Vec::new(), + enabled_tokens: Vec::new(), + finalize_withdrawal_batch_count: Some(U256::ZERO), + finalize_withdrawal_batch_encrypted_senders: Vec::new(), + transactions: vec![Bytes::from([0x01])], + }); + + assert!(matches!( + prove_zone_batch(&test_config(), witness), + Err(Error::InitialTempoCheckpointMismatch { + expected_number: 0, + expected_hash: B256::ZERO, + actual_number: 2, + .. + }) + )); + } + + #[test] + fn initial_tempo_checkpoint_binding_precedes_header_import_validation() { + let mut witness = minimal_batch_witness(); + witness.zone_blocks.push(ZoneBlock { + number: 1, + parent_hash: witness.parent_header.hash(), + timestamp: 0, + beneficiary: witness.public_inputs.sequencer, + protocol_version: 0, + tempo_header_rlp: Some(Bytes::from([0x01])), + deposits: Vec::new(), + decryptions: Vec::new(), + enabled_tokens: Vec::new(), + finalize_withdrawal_batch_count: Some(U256::ZERO), + finalize_withdrawal_batch_encrypted_senders: Vec::new(), + transactions: vec![Bytes::from([0x01])], + }); + + assert!(matches!( + prove_zone_batch(&test_config(), witness), + Err(Error::InitialTempoCheckpointMismatch { + expected_number: 0, + expected_hash: B256::ZERO, + actual_number: 2, + .. + }) + )); + } + + #[test] + fn validates_a_tempo_ancestry_anchor() { + let checkpoint = TempoHeader { + inner: Header { + number: 7, + ..Default::default() + }, + ..Default::default() + }; + let checkpoint_hash = keccak256(alloy_rlp::encode(checkpoint)); + let anchor = TempoHeader { + inner: Header { + parent_hash: checkpoint_hash, + number: 8, + ..Default::default() + }, + ..Default::default() + }; + let anchor_rlp = Bytes::from(alloy_rlp::encode(anchor)); + let anchor_hash = keccak256(&anchor_rlp); + let mut public_inputs = minimal_batch_witness().public_inputs; + public_inputs.tempo_block_number = 7; + public_inputs.anchor_block_number = 8; + public_inputs.anchor_block_hash = anchor_hash; + + assert_eq!( + validate_tempo_anchor(7, checkpoint_hash, &public_inputs, &[anchor_rlp]), + Ok(()) + ); + } +} diff --git a/crates/spf/src/mpt.rs b/crates/spf/src/mpt.rs new file mode 100644 index 000000000..4372cbcb8 --- /dev/null +++ b/crates/spf/src/mpt.rs @@ -0,0 +1,315 @@ +//! Merkle Patricia Trie witness validation. +//! +//! The construction and read pattern is adapted from +//! [`paradigmxyz/stateless`'s `StatelessSparseTrie`](https://github.com/paradigmxyz/stateless/blob/3d2fc174df31f5b0d5d4d831dc7e1607ea541531/crates/tries/src/default.rs). +//! A flat witness is indexed by node hash, revealed into Reth's +//! [`reth_trie_sparse::SparseStateTrie`], and checked against the committed +//! pre-state root before it can serve reads. + +use alloy_primitives::{Address, B256, Bytes, U256, keccak256, map::B256Map}; +use alloy_rlp::Decodable; +use reth_trie_common::{DecodedMultiProofV2, EMPTY_ROOT_HASH, HashedPostState, TrieAccount}; +use reth_trie_sparse::{LeafUpdate, RevealableSparseTrie, SparseStateTrie}; + +/// Fully revealed, root-bound stateless trie. +#[derive(Debug)] +pub(crate) struct StatelessSparseTrie { + inner: SparseStateTrie, +} + +impl StatelessSparseTrie { + /// Construct and validate a sparse trie from a flat witness node pool. + pub(crate) fn new( + state_root: B256, + node_pool: &[Bytes], + ) -> Result { + // This is the flat-witness indexing step from `StatelessSparseTrie`. + let mut nodes = B256Map::default(); + + for node in node_pool { + let node_hash = keccak256(node); + if nodes.insert(node_hash, node.clone()).is_some() { + return Err(StatelessSparseTrieError::DuplicateNodeHash { node_hash }); + } + } + + let mut inner = SparseStateTrie::new(); + if state_root == EMPTY_ROOT_HASH { + inner.set_accounts_trie(RevealableSparseTrie::revealed_empty()); + return Ok(Self { inner }); + } + if !nodes.contains_key(&state_root) { + return Err(StatelessSparseTrieError::MissingStateRootNode { state_root }); + } + + guarded(|| { + let multiproof = DecodedMultiProofV2::from_witness(state_root, &nodes) + .map_err(|_| StatelessSparseTrieError::InvalidNodeEncoding)?; + inner + .reveal_decoded_multiproof_v2(multiproof) + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie)?; + + let actual_root = inner + .root() + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie)?; + if actual_root != state_root { + return Err(StatelessSparseTrieError::StateRootMismatch { + expected: state_root, + actual: actual_root, + }); + } + + Ok(Self { inner }) + }) + } + + /// Return the proven account, or `None` for a complete non-membership + /// proof. + pub(crate) fn account( + &self, + address: Address, + ) -> Result, StatelessSparseTrieError> { + guarded(|| { + let hashed_address = keccak256(address); + if let Some(value) = self.inner.get_account_value(&hashed_address) { + return decode_account(value, address).map(Some); + } + if !self.inner.is_account_revealed(hashed_address) { + return Err(StatelessSparseTrieError::IncompleteAccountProof { account: address }); + } + Ok(None) + }) + } + + /// Return the proven storage value, or zero for a complete non-membership + /// proof or an empty account storage trie. + pub(crate) fn storage( + &self, + address: Address, + slot: U256, + ) -> Result { + guarded(|| { + let hashed_address = keccak256(address); + let hashed_slot = keccak256(slot.to_be_bytes::<32>()); + if let Some(value) = self + .inner + .get_storage_slot_value(&hashed_address, &hashed_slot) + { + return decode_storage_value(value, address, slot); + } + + let Some(account) = self.account(address)? else { + return Ok(U256::ZERO); + }; + if account.storage_root != EMPTY_ROOT_HASH + && !self + .inner + .check_valid_storage_witness(hashed_address, hashed_slot) + { + return Err(StatelessSparseTrieError::IncompleteStorageProof { + account: address, + slot, + }); + } + Ok(U256::ZERO) + }) + } + + /// Apply the cumulative execution changes to this revealed pre-state trie + /// and calculate the resulting state root. + pub(crate) fn calculate_state_root( + &mut self, + state: HashedPostState, + ) -> Result { + guarded(|| { + let HashedPostState { accounts, storages } = state; + let mut storage_updates = storages.into_iter().collect::>(); + storage_updates.sort_unstable_by_key(|(address, _)| *address); + + let mut storage_roots = B256Map::default(); + for (hashed_address, storage) in storage_updates { + let current_account = self.trie_account(hashed_address)?; + let has_revealed_storage = self.inner.storage_trie_ref(&hashed_address).is_some(); + if !storage.wiped + && current_account + .as_ref() + .is_some_and(|account| account.storage_root != EMPTY_ROOT_HASH) + && !has_revealed_storage + { + return Err(StatelessSparseTrieError::IncompleteStateUpdate); + } + + let mut storage_trie = self + .inner + .take_storage_trie(&hashed_address) + .unwrap_or_else(RevealableSparseTrie::revealed_empty); + if storage.wiped { + storage_trie + .wipe() + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie)?; + } + + let mut updates = storage + .storage + .into_iter() + .map(|(slot, value)| { + let value = if value.is_zero() { + Vec::new() + } else { + alloy_rlp::encode_fixed_size(&value).to_vec() + }; + (slot, LeafUpdate::Changed(value)) + }) + .collect::>(); + storage_trie + .update_leaves(&mut updates, |_, _| {}) + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie)?; + if !updates.is_empty() { + return Err(StatelessSparseTrieError::IncompleteStateUpdate); + } + + let storage_root = storage_trie + .root() + .ok_or(StatelessSparseTrieError::InvalidSparseTrie)?; + self.inner.insert_storage_trie(hashed_address, storage_trie); + storage_roots.insert(hashed_address, storage_root); + } + + let mut account_updates = B256Map::default(); + for (hashed_address, account) in accounts { + let update = match account { + Some(account) => { + let storage_root = storage_roots.remove(&hashed_address).unwrap_or( + self.trie_account(hashed_address)? + .map(|account| account.storage_root) + .unwrap_or(EMPTY_ROOT_HASH), + ); + LeafUpdate::Changed(alloy_rlp::encode( + account.into_trie_account(storage_root), + )) + } + None => { + storage_roots.remove(&hashed_address); + LeafUpdate::Changed(Vec::new()) + } + }; + account_updates.insert(hashed_address, update); + } + + for (hashed_address, storage_root) in storage_roots { + let Some(mut account) = self.trie_account(hashed_address)? else { + return Err(StatelessSparseTrieError::IncompleteStateUpdate); + }; + account.storage_root = storage_root; + account_updates.insert( + hashed_address, + LeafUpdate::Changed(alloy_rlp::encode(account)), + ); + } + + self.inner + .trie_mut() + .update_leaves(&mut account_updates, |_, _| {}) + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie)?; + if !account_updates.is_empty() { + return Err(StatelessSparseTrieError::IncompleteStateUpdate); + } + + self.inner + .root() + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie) + }) + } + + fn trie_account( + &self, + hashed_address: B256, + ) -> Result, StatelessSparseTrieError> { + self.inner + .get_account_value(&hashed_address) + .map(|value| decode_hashed_account(value)) + .transpose() + } +} + +fn decode_account(value: &[u8], address: Address) -> Result { + let mut encoded = value; + let account = TrieAccount::decode(&mut encoded) + .map_err(|_| StatelessSparseTrieError::InvalidAccountValue { account: address })?; + if !encoded.is_empty() { + return Err(StatelessSparseTrieError::InvalidAccountValue { account: address }); + } + Ok(account) +} + +fn decode_storage_value( + value: &[u8], + account: Address, + slot: U256, +) -> Result { + let mut encoded = value; + let value = U256::decode(&mut encoded) + .map_err(|_| StatelessSparseTrieError::InvalidStorageValue { account, slot })?; + if !encoded.is_empty() { + return Err(StatelessSparseTrieError::InvalidStorageValue { account, slot }); + } + Ok(value) +} + +fn decode_hashed_account(value: &[u8]) -> Result { + let mut encoded = value; + let account = TrieAccount::decode(&mut encoded) + .map_err(|_| StatelessSparseTrieError::InvalidSparseTrie)?; + if !encoded.is_empty() { + return Err(StatelessSparseTrieError::InvalidSparseTrie); + } + Ok(account) +} + +/// The pinned Reth helper assumes internally consistent paths in a few places. +/// Convert any invariant panic caused by untrusted RLP into an ordinary witness +/// error. +fn guarded( + operation: impl FnOnce() -> Result, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation)) + .unwrap_or(Err(StatelessSparseTrieError::InvalidSparseTrie)) +} + +/// Errors emitted while constructing or reading a stateless sparse trie. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatelessSparseTrieError { + /// `node_pool` is not deduplicated by node hash. + #[error("duplicate trie node hash in witness: {node_hash:?}")] + DuplicateNodeHash { node_hash: B256 }, + /// A trie node needed to reconstruct the supplied proof is not valid RLP. + #[error("invalid RLP-encoded trie node in witness")] + InvalidNodeEncoding, + /// The node pool does not provide the node committed by the state root. + #[error("state root is absent from the node pool: {state_root:?}")] + MissingStateRootNode { state_root: B256 }, + /// Reth rejected the witness while reconstructing the sparse trie. + #[error("state witness is not a valid sparse trie proof")] + InvalidSparseTrie, + /// The reconstructed trie does not hash to the committed state root. + #[error("state root mismatch: expected {expected:?}, got {actual:?}")] + StateRootMismatch { expected: B256, actual: B256 }, + /// The witness does not reveal a complete account proof for an execution + /// read. + #[error("incomplete account proof for {account:?}")] + IncompleteAccountProof { account: Address }, + /// A revealed account leaf is not a canonical trie account value. + #[error("invalid account leaf for {account:?}")] + InvalidAccountValue { account: Address }, + /// The witness does not reveal a complete storage proof for an execution + /// read. + #[error("incomplete storage proof for {account:?} at {slot:?}")] + IncompleteStorageProof { account: Address, slot: U256 }, + /// A revealed storage leaf is not an RLP-encoded `U256`. + #[error("invalid storage leaf for {account:?} at {slot:?}")] + InvalidStorageValue { account: Address, slot: U256 }, + /// The execution changed an account or storage path not completely revealed + /// by the witness. + #[error("incomplete witness for an executed state update")] + IncompleteStateUpdate, +} diff --git a/crates/spf/src/types.rs b/crates/spf/src/types.rs new file mode 100644 index 000000000..2c2c387aa --- /dev/null +++ b/crates/spf/src/types.rs @@ -0,0 +1,149 @@ +//! Public witness and commitment types for the Zone SPF. + +use std::sync::Arc; + +use alloy_primitives::{Address, B256, Bytes, U256}; +use zone_chainspec::ZoneChainSpec; + +pub use tempo_zone_contracts::{ + BlockTransition, ChaumPedersenProof, DecryptionData, DepositQueueTransition, DepositType, + EnabledToken, QueuedDeposit, +}; +pub use zone_primitives::ZoneHeader; + +/// Trusted network configuration for Zone execution. +/// +/// This is deliberately separate from [`BatchWitness`]: it is selected by the +/// verifier for the network it serves, not supplied by the prover. The zone +/// chain specification provides the parent Tempo hard-fork schedule, while +/// `block_gas_limit` is the fixed Zone block limit. Simplified Zone headers do +/// not carry a gas limit, so it must be fixed by the network configuration. +#[derive(Debug, Clone)] +pub struct SpfConfig { + pub zone_chain_spec: Arc, + pub block_gas_limit: u64, +} + +impl SpfConfig { + pub fn new(zone_chain_spec: Arc, block_gas_limit: u64) -> Self { + Self { + zone_chain_spec, + block_gas_limit, + } + } +} + +/// Public values that the verifier binds to a submitted batch proof. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct PublicInputs { + /// Zone identifier from which the SPF derives the EVM chain ID. + pub zone_id: u32, + /// Block hash committed by the previous batch. + pub prev_block_hash: B256, + /// Tempo block number committed by the submitted batch. + pub tempo_block_number: u64, + /// Tempo block number used to anchor this batch. + pub anchor_block_number: u64, + /// Block hash for `anchor_block_number`. + pub anchor_block_hash: B256, + /// Withdrawal batch index expected by the portal. + pub expected_withdrawal_batch_index: u64, + /// Registered zone sequencer. + pub sequencer: Address, +} + +/// Complete prover input for one Zone batch. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct BatchWitness { + /// Values committed by the verifier. + pub public_inputs: PublicInputs, + /// Parent of the first Zone block. Its state root anchors the initial Zone + /// state witness. + pub parent_header: ZoneHeader, + /// Zone blocks in execution order. + pub zone_blocks: Vec, + /// Stateless witness for the Zone state at the start of the batch. + pub zone_state_witness: ZoneStateWitness, + /// Stateless witness for Tempo state reads performed during the batch. + pub tempo_state_witness: TempoStateWitness, + /// RLP-encoded headers ordered from `tempo_block_number + 1` through the + /// anchor block when ancestry verification is needed. + pub tempo_ancestry_headers: Vec, +} + +/// Zone block input, including its system-call inputs and raw user transactions. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct ZoneBlock { + pub number: u64, + pub parent_hash: B256, + pub timestamp: u64, + pub beneficiary: Address, + pub protocol_version: u64, + /// RLP-encoded Tempo header passed to `ZoneInbox.advanceTempo`, when the + /// block imports a new Tempo checkpoint. + pub tempo_header_rlp: Option, + /// Deposits processed by `ZoneInbox.advanceTempo`, in calldata order. + pub deposits: Vec, + /// Encrypted-deposit decryption data, in calldata order. + pub decryptions: Vec, + /// Tokens enabled by `ZoneInbox.advanceTempo`, in calldata order. + pub enabled_tokens: Vec, + /// Withdrawal count passed to finalization in the final block, if any. + pub finalize_withdrawal_batch_count: Option, + /// Encrypted sender payloads passed to withdrawal finalization. + pub finalize_withdrawal_batch_encrypted_senders: Vec, + /// Raw signed user transactions in execution order. + pub transactions: Vec, +} + +/// Stateless Zone state input. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct ZoneStateWitness { + /// Deduplicated RLP-encoded nodes used for Zone state reads. + pub node_pool: Vec, + /// Deduplicated bytecode preimages, indexed by `keccak256(bytecode)`. + pub bytecodes: Vec, +} + +/// Stateless Tempo state input. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct TempoStateWitness { + /// RLP-encoded header for the Tempo checkpoint bound in the initial Zone + /// state. Its decoded state root anchors initial Tempo reads. + pub initial_tempo_header_rlp: Bytes, + /// Deduplicated RLP-encoded MPT nodes used for Tempo state reads. + pub node_pool: Vec, +} + +/// Commitments returned by a successful Zone batch transition. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct BatchOutput { + /// Hash transition covering every Zone block in the batch. + pub block_transition: BlockTransition, + /// Progress of the ZoneInbox deposit queue during the batch. + pub deposit_queue_transition: DepositQueueTransition, + /// Hash chain created by finalizing the batch's withdrawals. + pub withdrawal_queue_hash: B256, + /// Batch index committed by `ZoneOutbox.lastBatch`. + pub last_batch_commitment: LastBatchCommitment, +} + +/// The portion of `ZoneOutbox.lastBatch` independently committed by the SPF. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct LastBatchCommitment { + pub withdrawal_batch_index: u64, +}