From b3fa877522869cd2fa6c6f09849fc510c78a465d Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 3 Sep 2026 22:55:49 +0200 Subject: [PATCH] perf(spf): index the Tempo node pool once per batch `prove_zone_batch` imports a Tempo checkpoint for every Zone block, and `with_imported_checkpoint` rebuilt the sparse trie from the raw node pool each time. Because `StatelessSparseTrie::new` starts by keccak-hashing and indexing every node in the pool, a batch of N blocks re-hashed the entire Tempo witness N times, making the Tempo side of replay quadratic in batch length even though the pool is fixed for the whole batch. The pool is now hashed and indexed once in `from_tempo_state_witness` and shared as an `Arc>`, so each checkpoint only walks the proof reachable from its own state root. A checkpoint whose state root equals the previous one also reuses the already revealed trie instead of revealing it again. Error behaviour is unchanged for the witnesses that can reach this code: the initial header is still decoded before the pool is indexed, so `InvalidTempoHeader` still precedes `DuplicateNodeHash`, and `MissingStateRootNode` still maps to an inactive reader. --- crates/spf/src/execution/database.rs | 65 +++++++++++++++++----------- crates/spf/src/mpt.rs | 38 +++++++++++----- 2 files changed, 67 insertions(+), 36 deletions(-) diff --git a/crates/spf/src/execution/database.rs b/crates/spf/src/execution/database.rs index 59133d91c..83fce86f2 100644 --- a/crates/spf/src/execution/database.rs +++ b/crates/spf/src/execution/database.rs @@ -16,7 +16,8 @@ use tempo_primitives::TempoHeader; use zone_precompiles::{L1StateError, L1StorageReader}; use crate::{ - Error, StatelessSparseTrieError, TempoStateWitness, ZoneStateWitness, mpt::StatelessSparseTrie, + Error, StatelessSparseTrieError, TempoStateWitness, ZoneStateWitness, + mpt::{StatelessSparseTrie, index_node_pool}, }; /// Errors emitted while resolving an execution read against a witness. @@ -177,9 +178,10 @@ impl Database for WitnessDatabase { #[derive(Clone, Debug)] pub struct TempoWitnessDatabase { state: Option>, + state_root: B256, tempo_block_hash: B256, tempo_block_number: u64, - node_pool: Arc>, + nodes: Arc>, missing_read: Arc>>, } @@ -193,15 +195,18 @@ pub(crate) struct MissingTempoStorageRead { 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())?; + let header = decode_checkpoint_header(&witness.initial_tempo_header_rlp)?; + // Every checkpoint imported by this batch resolves against the same + // pool, so it is hashed and indexed once here rather than per Zone block. + let nodes = Arc::new(index_node_pool(&witness.node_pool)?); + let state_root = header.state_root(); Ok(Self { - state, - tempo_block_hash, - tempo_block_number, - node_pool, + state: checkpoint_state(state_root, &nodes)?, + state_root, + tempo_block_hash: keccak256(&witness.initial_tempo_header_rlp), + tempo_block_number: header.number(), + nodes, missing_read: Arc::default(), }) } @@ -213,14 +218,22 @@ impl TempoWitnessDatabase { self, header_rlp: &alloy_primitives::Bytes, ) -> Result { - let (state, tempo_block_hash, tempo_block_number) = - checkpoint_state(header_rlp, self.node_pool.as_ref())?; + let header = decode_checkpoint_header(header_rlp)?; + let state_root = header.state_root(); + // A checkpoint that carries the previous state root resolves every read + // against the trie already revealed for it. + let state = if state_root == self.state_root { + self.state + } else { + checkpoint_state(state_root, &self.nodes)? + }; Ok(Self { state, - tempo_block_hash, - tempo_block_number, - node_pool: self.node_pool, + state_root, + tempo_block_hash: keccak256(header_rlp), + tempo_block_number: header.number(), + nodes: self.nodes, missing_read: self.missing_read, }) } @@ -250,25 +263,25 @@ impl TempoWitnessDatabase { } } -fn checkpoint_state( - header_rlp: &[u8], - node_pool: &[Bytes], -) -> Result<(Option>, B256, u64), Error> { +fn decode_checkpoint_header(header_rlp: &[u8]) -> Result { 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()); } + Ok(header) +} - 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())) +fn checkpoint_state( + state_root: B256, + nodes: &B256Map, +) -> Result>, Error> { + match StatelessSparseTrie::from_indexed_nodes(state_root, nodes) { + Ok(state) => Ok(Some(Arc::new(state))), + Err(StatelessSparseTrieError::MissingStateRootNode { .. }) => Ok(None), + Err(error) => Err(error.into()), + } } impl L1StorageReader for TempoWitnessDatabase { diff --git a/crates/spf/src/mpt.rs b/crates/spf/src/mpt.rs index 270605fda..0f78434c3 100644 --- a/crates/spf/src/mpt.rs +++ b/crates/spf/src/mpt.rs @@ -17,22 +17,40 @@ pub(crate) struct StatelessSparseTrie { inner: SparseStateTrie, } +/// Index a flat witness node pool by node hash. +/// +/// This is the flat-witness indexing step from `StatelessSparseTrie`. It is +/// separate from [`StatelessSparseTrie::new`] so that a pool shared by several +/// state roots is hashed once instead of once per root. +pub(crate) fn index_node_pool( + node_pool: &[Bytes], +) -> Result, StatelessSparseTrieError> { + 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 }); + } + } + + Ok(nodes) +} + 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 }); - } - } + Self::from_indexed_nodes(state_root, &index_node_pool(node_pool)?) + } + /// Construct and validate a sparse trie from an already indexed node pool. + pub(crate) fn from_indexed_nodes( + state_root: B256, + nodes: &B256Map, + ) -> Result { let mut inner = SparseStateTrie::new(); if state_root == EMPTY_ROOT_HASH { inner.set_accounts_trie(RevealableSparseTrie::revealed_empty()); @@ -43,7 +61,7 @@ impl StatelessSparseTrie { } guarded(|| { - let multiproof = DecodedMultiProofV2::from_witness(state_root, &nodes) + let multiproof = DecodedMultiProofV2::from_witness(state_root, nodes) .map_err(|_| StatelessSparseTrieError::InvalidNodeEncoding)?; inner .reveal_decoded_multiproof_v2(multiproof)