diff --git a/Cargo.lock b/Cargo.lock index b495019d..11ed30c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1919,7 +1919,6 @@ dependencies = [ "reqwest", "revm", "revm-inspectors", - "salt", "serde", "serde_json", "stateless-common", diff --git a/bin/debug-trace-server/Cargo.toml b/bin/debug-trace-server/Cargo.toml index a56c552e..d0346a8f 100644 --- a/bin/debug-trace-server/Cargo.toml +++ b/bin/debug-trace-server/Cargo.toml @@ -19,7 +19,6 @@ alloy-rpc-types-trace.workspace = true # mega mega-evm.workspace = true -salt.workspace = true # op op-alloy-network.workspace = true diff --git a/bin/debug-trace-server/src/chain_sync.rs b/bin/debug-trace-server/src/chain_sync.rs index a1caf2ff..5329bbc7 100644 --- a/bin/debug-trace-server/src/chain_sync.rs +++ b/bin/debug-trace-server/src/chain_sync.rs @@ -19,8 +19,11 @@ use stateless_core::{ use crate::{metrics, response_cache::ResponseCache, server_db::BlockStore}; -/// Fetcher for the trace server: fetches blocks + witnesses, discards MPT witness, -/// converts SALT witness to [`LightWitness`]. +/// Fetcher for the trace server: fetches blocks + witnesses, discards MPT witness. +/// +/// Witnesses go through the zero-validation light decode (`get_witness_light`): +/// the server never verifies the proof, so the full decode's per-point +/// elliptic-curve work bought nothing. pub struct TraceFetcher { pub rpc_client: Arc, } @@ -35,11 +38,11 @@ impl BlockFetcher for TraceFetcher { // fetch instead of serializing all three round trips. let block_hash = self.rpc_client.get_block_hash(block_number).await; let (witness_res, block_res) = tokio::join!( - self.rpc_client.get_witness(block_number, block_hash), + self.rpc_client.get_witness_light(block_number, block_hash), self.rpc_client.get_block(BlockId::Number(block_number.into()), true), ); - let (salt, _mpt) = witness_res; - Ok((block_res, LightWitness::from(&salt))) + let (light, _mpt) = witness_res; + Ok((block_res, light)) } async fn latest_block_number(&self) -> Result { diff --git a/bin/debug-trace-server/src/data_provider.rs b/bin/debug-trace-server/src/data_provider.rs index b5d3bf27..7eeb6057 100644 --- a/bin/debug-trace-server/src/data_provider.rs +++ b/bin/debug-trace-server/src/data_provider.rs @@ -33,8 +33,7 @@ use dashmap::DashMap; use futures::{FutureExt, future::Shared}; use op_alloy_rpc_types::Transaction; use revm::state::Bytecode; -use salt::SaltWitness; -use stateless_common::{CodeFetchError, RpcClient, RpcDeadlineExceeded, estimate_witness_size}; +use stateless_common::{CodeFetchError, RpcClient, RpcDeadlineExceeded, WitnessSizeBreakdown}; use stateless_core::{ ContractStore, LightWitness, StoreResult, db::StoreError, withdrawals::MptWitness, }; @@ -592,7 +591,7 @@ fn shared_to_result( /// 2. Fetch witness and full block in parallel, each subject to the shared `deadline`. The witness /// stage also gets a sub-deadline: `min(deadline, now + witness_timeout)`, tightened further for /// old blocks (see `witness_deadline_for`). -/// 3. Convert SaltWitness to LightWitness. +/// 3. The witness arrives as a `LightWitness` already (zero-validation light decode). /// 4. Extract code hashes from witness and fetch contract bytecodes (shares `deadline`). async fn do_fetch_block_data( rpc_client: Arc, @@ -637,15 +636,11 @@ async fn do_fetch_block_data( let (block_result, block_elapsed) = block_timed; let fetch_witness_ms = witness_elapsed.as_millis(); - let (salt_witness, _mpt_witness) = witness_result?; + // Step 3: the light decode already produced a LightWitness — no conversion. + let (witness, _mpt_witness) = witness_result?; let block = block_result?; let fetch_full_block_ms = block_elapsed.as_millis(); - // Step 3: Convert SaltWitness to LightWitness. - let start = Instant::now(); - let witness = LightWitness::from(&salt_witness); - let convert_witness_ms = start.elapsed().as_millis(); - // Step 4: Extract code hashes and fetch contracts. let start = Instant::now(); let code_hashes = crate::tracing_executor::extract_code_hashes(&witness); @@ -658,7 +653,6 @@ async fn do_fetch_block_data( if fetch_header_ms >= SLOW_STAGE_THRESHOLD_MS || fetch_witness_ms >= SLOW_STAGE_THRESHOLD_MS || - convert_witness_ms >= SLOW_STAGE_THRESHOLD_MS || fetch_full_block_ms >= SLOW_STAGE_THRESHOLD_MS || fetch_contracts_ms >= SLOW_STAGE_THRESHOLD_MS { @@ -669,7 +663,6 @@ async fn do_fetch_block_data( num_contracts, fetch_header_ms = fetch_header_ms as u64, fetch_witness_ms = fetch_witness_ms as u64, - convert_witness_ms = convert_witness_ms as u64, fetch_full_block_ms = fetch_full_block_ms as u64, fetch_contracts_ms = fetch_contracts_ms as u64, total_ms = total_ms as u64, @@ -718,19 +711,25 @@ fn witness_deadline_for( /// Fetches witness data via the deadline-aware `RpcClient` API. The `deadline` is the /// witness stage's effective deadline (see [`witness_deadline_for`]). +/// +/// Uses the zero-validation light decode: the trace server never verifies the +/// witness proof, so the full decode's per-point elliptic-curve work bought +/// nothing. The recorded size is the light lower bound (excludes the +/// never-decoded parent commitments). async fn fetch_witness( rpc_client: &RpcClient, block_number: u64, block_hash: B256, deadline: Instant, -) -> DataProviderResult<(SaltWitness, MptWitness)> { +) -> DataProviderResult<(LightWitness, MptWitness)> { let wg_metrics = WitnessSourceMetrics::new_for_source("witness_generator"); let start = Instant::now(); - match rpc_client.get_witness_with_deadline(block_number, block_hash, Some(deadline)).await { + match rpc_client.get_witness_light_with_deadline(block_number, block_hash, Some(deadline)).await + { Ok(w) => { wg_metrics.record_request(true, start.elapsed().as_secs_f64()); - wg_metrics.record_size(estimate_witness_size(&w.0, &w.1)); + wg_metrics.record_size(WitnessSizeBreakdown::new_light(&w.0, &w.1).total()); DataSourceMetrics::new_for_source("witness_generator").record(); Ok(w) } diff --git a/bin/debug-trace-server/src/tracing_executor.rs b/bin/debug-trace-server/src/tracing_executor.rs index 9fb8ba6f..f8c56055 100644 --- a/bin/debug-trace-server/src/tracing_executor.rs +++ b/bin/debug-trace-server/src/tracing_executor.rs @@ -56,7 +56,6 @@ use revm_inspectors::tracing::{ }; use stateless_core::{ chain_spec::ChainSpec, - data_types::iter_code_hashes, evm_database::{WitnessDatabase, WitnessExternalEnv}, executor::{ValidationError, create_evm_env}, light_witness::{LightWitness, LightWitnessExecutor}, @@ -65,10 +64,7 @@ use tracing::{instrument, trace, warn}; /// Returns distinct contract code hashes referenced by the witness, sorted for stable ordering. pub fn extract_code_hashes(witness: &LightWitness) -> Vec { - let mut code_hashes: Vec = iter_code_hashes(&witness.kvs).collect(); - code_hashes.sort(); - code_hashes.dedup(); - code_hashes + stateless_core::collect_code_hashes(&witness.kvs) } // TracerKind - Unified enum for TracingInspector-based tracers diff --git a/crates/stateless-common/src/lib.rs b/crates/stateless-common/src/lib.rs index 582a7f60..1c3356fc 100644 --- a/crates/stateless-common/src/lib.rs +++ b/crates/stateless-common/src/lib.rs @@ -9,11 +9,12 @@ pub use rpc_client::{ pub mod witness_encoding; pub use witness_encoding::{ WITNESS_RESPONSE_VERSION_PREFIX, WITNESS_ZSTD_LEVEL, WitnessDecodingError, - WitnessEncodingError, decode_witness_payload, decode_witness_response, encode_witness_payload, + WitnessEncodingError, decode_witness_payload, decode_witness_payload_light, + decode_witness_response, decode_witness_response_light, encode_witness_payload, encode_witness_response, }; pub mod witness_size; -pub use witness_size::{WitnessSizeBreakdown, estimate_witness_size}; +pub use witness_size::WitnessSizeBreakdown; /// Default port for Prometheus metrics HTTP endpoint. pub const DEFAULT_METRICS_PORT: u16 = 9090; diff --git a/crates/stateless-common/src/rpc_client.rs b/crates/stateless-common/src/rpc_client.rs index 07c4575c..90124e45 100644 --- a/crates/stateless-common/src/rpc_client.rs +++ b/crates/stateless-common/src/rpc_client.rs @@ -45,13 +45,13 @@ use op_alloy_rpc_types::Transaction; use revm::state::Bytecode; use salt::SaltWitness; use serde::{Deserialize, Serialize}; -use stateless_core::withdrawals::MptWitness; +use stateless_core::{LightWitness, withdrawals::MptWitness}; use tokio::sync::Semaphore; use tracing::{trace, warn}; use crate::{ metrics::{RpcMethod, RpcMetrics}, - witness_encoding::decode_witness_response, + witness_encoding::{decode_witness_response, decode_witness_response_light}, witness_size::WitnessSizeBreakdown, }; @@ -569,25 +569,77 @@ impl RpcClient { hash: B256, deadline: Option, ) -> std::result::Result<(SaltWitness, MptWitness), RpcDeadlineExceeded> { - let witness = round_robin_with_backoff( + let witness = self + .witness_round_robin(number, hash, deadline, decode_witness_response, "Witness decoded") + .await?; + + if let Some(ref metrics) = self.config.metrics { + metrics.on_witness_fetch(WitnessSizeBreakdown::new(&witness.0, &witness.1)); + } + Ok(witness) + } + + /// Zero-validation counterpart of [`Self::get_witness`] for execution-only + /// consumers: decodes just the light witness (kvs + levels, no + /// elliptic-curve work — see `stateless_core::light_witness` for the + /// safety model). Consumers that later need the full witness (e.g. to + /// assemble test fixtures) re-fetch it via [`Self::get_witness`]. + /// + /// The `on_witness_fetch` size metric is not recorded here — the exact + /// breakdown needs the proof's commitment count. Callers that want a size + /// signal can record `WitnessSizeBreakdown::new_light` (a documented + /// lower bound) themselves. + pub async fn get_witness_light(&self, number: u64, hash: B256) -> (LightWitness, MptWitness) { + self.get_witness_light_with_deadline(number, hash, None) + .await + .expect("None deadline cannot time out") + } + + /// Deadline-aware counterpart of [`Self::get_witness_light`]. + pub async fn get_witness_light_with_deadline( + &self, + number: u64, + hash: B256, + deadline: Option, + ) -> std::result::Result<(LightWitness, MptWitness), RpcDeadlineExceeded> { + self.witness_round_robin( + number, + hash, + deadline, + decode_witness_response_light, + "Witness light-decoded", + ) + .await + } + + /// Shared `mega_getBlockWitness` retry loop: primary-failover rounds (always start from + /// provider 0 so the primary takes all traffic while healthy; backups are touched only + /// while it is failing), each attempt one RPC round trip followed by the caller-chosen + /// `decode` (see [`fetch_witness_with`]). + async fn witness_round_robin( + &self, + number: u64, + hash: B256, + deadline: Option, + decode: fn(&str) -> std::result::Result, + trace_msg: &'static str, + ) -> std::result::Result { + round_robin_with_backoff( &self.witness_providers, &self.witness_concurrency, &self.config.rpc_retry, self.config.per_attempt_timeout, - // Primary-failover: always start from provider 0 so the primary takes all traffic - // while healthy. Backup endpoints are touched only while the primary is failing. 0, RpcMethod::MegaGetBlockWitness, self.config.metrics.as_ref(), deadline, - |provider| Box::pin(async move { fetch_witness_raw(&provider, number, hash).await }), + |provider| { + Box::pin(async move { + fetch_witness_with(&provider, number, hash, decode, trace_msg).await + }) + }, ) - .await?; - - if let Some(ref metrics) = self.config.metrics { - metrics.on_witness_fetch(WitnessSizeBreakdown::new(&witness.0, &witness.1)); - } - Ok(witness) + .await } /// Reports a range of validated blocks via the dedicated report endpoint. @@ -1050,15 +1102,16 @@ async fn do_get_header( Ok(header) } -/// Fetches and decodes witness data from a single RPC provider (one attempt, no retry). -/// -/// Decodes the versioned `mega_getBlockWitness` response with -/// [`decode_witness_response`](crate::decode_witness_response). -async fn fetch_witness_raw( +/// Shared single-attempt `mega_getBlockWitness` fetch: one RPC round trip, +/// then the caller-chosen decoder on the blocking pool (zstd + bincode over a +/// multi-MB payload is CPU-bound). +async fn fetch_witness_with( provider: &RootProvider, number: u64, hash: B256, -) -> Result<(SaltWitness, MptWitness)> { + decode: fn(&str) -> std::result::Result, + trace_msg: &'static str, +) -> Result { let keys = WitnessRequestKeys { block_number: U64::from(number), block_hash: hash }; let encoded: String = provider .client() @@ -1067,22 +1120,20 @@ async fn fetch_witness_raw( .map_err(|e| eyre!("mega_getBlockWitness failed for block {number}: {e}"))?; let decode_start = Instant::now(); - let (salt_witness, mpt_witness) = - tokio::task::spawn_blocking(move || -> Result<(SaltWitness, MptWitness)> { - decode_witness_response(&encoded) - .map_err(|e| eyre!("failed to decode witness response: {e}")) - }) - .await - .context("decode task panicked")??; + let result = tokio::task::spawn_blocking(move || -> Result { + decode(&encoded).map_err(|e| eyre!("failed to decode witness response: {e}")) + }) + .await + .context("decode task panicked")??; trace!( block_number = number, %hash, decode_ms = decode_start.elapsed().as_millis(), - "Witness decoded", + "{trace_msg}", ); - Ok((salt_witness, mpt_witness)) + Ok(result) } /// Verifies structural integrity of a block fetched from RPC. diff --git a/crates/stateless-common/src/witness_encoding.rs b/crates/stateless-common/src/witness_encoding.rs index 64142cb9..b446429b 100644 --- a/crates/stateless-common/src/witness_encoding.rs +++ b/crates/stateless-common/src/witness_encoding.rs @@ -9,7 +9,7 @@ use std::io; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use salt::SaltWitness; -use stateless_core::withdrawals::MptWitness; +use stateless_core::{LightWitness, LightWitnessFromSalt, withdrawals::MptWitness}; /// Version prefix for the RPC response format: /// `"v0:" + base64(zstd(bincode-legacy((SaltWitness, MptWitness))))`. @@ -68,9 +68,28 @@ pub fn encode_witness_payload( pub fn decode_witness_payload( compressed: &[u8], ) -> Result<(SaltWitness, MptWitness), WitnessDecodingError> { + decode_payload_as(compressed) +} + +/// Zero-validation counterpart of [`decode_witness_payload`]: decodes only the +/// light witness (kvs + levels) from the same payload bytes, skipping all +/// elliptic-curve work (see `stateless_core::light_witness` for the safety +/// and performance model). +pub fn decode_witness_payload_light( + compressed: &[u8], +) -> Result<(LightWitness, MptWitness), WitnessDecodingError> { + let (light, mpt): (LightWitnessFromSalt, MptWitness) = decode_payload_as(compressed)?; + Ok((light.0, mpt)) +} + +/// Shared payload decode: zstd, then bincode-legacy into the caller-chosen target — the one +/// place that fixes the wire config for both the full and the light payload decode. +fn decode_payload_as( + compressed: &[u8], +) -> Result { let decompressed = zstd::decode_all(compressed)?; - let (witness, _) = bincode::serde::decode_from_slice(&decompressed, bincode::config::legacy())?; - Ok(witness) + let (value, _) = bincode::serde::decode_from_slice(&decompressed, bincode::config::legacy())?; + Ok(value) } /// Encodes the witness tuple as a versioned RPC response string. @@ -86,11 +105,29 @@ pub fn encode_witness_response( pub fn decode_witness_response( response: &str, ) -> Result<(SaltWitness, MptWitness), WitnessDecodingError> { + decode_response_with(response, decode_witness_payload) +} + +/// Zero-validation counterpart of [`decode_witness_response`]: decodes only +/// the light witness from a versioned RPC response. +pub fn decode_witness_response_light( + response: &str, +) -> Result<(LightWitness, MptWitness), WitnessDecodingError> { + decode_response_with(response, decode_witness_payload_light) +} + +/// Shared response prologue: strip the version prefix and base64-decode, then hand the +/// compressed payload to the caller-chosen decoder — the one place that fixes the response +/// framing for both the full and the light decode. +fn decode_response_with( + response: &str, + decode_payload: fn(&[u8]) -> Result, +) -> Result { let payload = response .strip_prefix(WITNESS_RESPONSE_VERSION_PREFIX) .ok_or(WitnessDecodingError::MissingPrefix)?; let compressed = BASE64.decode(payload)?; - decode_witness_payload(&compressed) + decode_payload(&compressed) } #[cfg(test)] @@ -138,6 +175,54 @@ mod tests { assert_eq!(decoded.1, mpt_witness); } + /// Same payload bytes, light decode: equal to the light parts of the full + /// decode, without touching any curve point. + #[test] + fn decode_witness_payload_light_matches_full() { + let (salt_witness, mpt_witness) = first_fixture_witness(); + let (_, compressed) = encode_witness_payload(&salt_witness, &mpt_witness) + .expect("compression should succeed"); + + let (light, mpt) = + decode_witness_payload_light(&compressed).expect("light decode should succeed"); + + assert_eq!(light, LightWitness::from(&salt_witness)); + assert_eq!(mpt, mpt_witness); + } + + /// Response-level light decode agrees with the full decode. + #[test] + fn decode_witness_response_light_matches_full() { + let (salt_witness, mpt_witness) = first_fixture_witness(); + let encoded = + encode_witness_response(&salt_witness, &mpt_witness).expect("encoding should succeed"); + + let (light, mpt) = + decode_witness_response_light(&encoded).expect("light decode should succeed"); + + assert_eq!(light, LightWitness::from(&salt_witness)); + assert_eq!(mpt, mpt_witness); + } + + /// The committed real-mainnet payload (block 6906405, ~6.3 MiB + /// uncompressed, 65k commitments) light-decodes to exactly the light + /// parts of its full decode — the end-to-end ".zst → light witness" lock. + #[test] + fn big_mainnet_zst_light_decodes() { + let path = + concat!(env!("CARGO_MANIFEST_DIR"), "/../../test_data/mainnet/bench/6906405.zst"); + let compressed = std::fs::read(path).expect("read committed bench payload"); + + let (full_salt, full_mpt) = + decode_witness_payload(&compressed).expect("full decode should succeed"); + let (light, mpt) = + decode_witness_payload_light(&compressed).expect("light decode should succeed"); + + assert_eq!(light, LightWitness::from(&full_salt)); + assert_eq!(mpt, full_mpt); + assert!(!light.kvs.is_empty()); + } + #[test] fn decode_witness_response_requires_prefix() { let err = decode_witness_response("not-versioned").expect_err("missing prefix should fail"); diff --git a/crates/stateless-common/src/witness_size.rs b/crates/stateless-common/src/witness_size.rs index 4d09b116..ec783071 100644 --- a/crates/stateless-common/src/witness_size.rs +++ b/crates/stateless-common/src/witness_size.rs @@ -5,7 +5,7 @@ //! (`on_witness_fetch`) and the trace server's data provider. use salt::SaltWitness; -use stateless_core::withdrawals::MptWitness; +use stateless_core::{LightWitness, withdrawals::MptWitness}; /// Per-entry size of a SALT key-value pair: `SaltKey` (8 bytes) plus /// `Option` (~95 bytes). @@ -42,11 +42,29 @@ pub struct WitnessSizeBreakdown { impl WitnessSizeBreakdown { /// Computes the breakdown for the given witness pair. pub fn new(salt: &SaltWitness, mpt: &MptWitness) -> Self { - let kvs_count = salt.kvs.len(); + Self::from_counts( + salt.kvs.len(), + salt.proof.parents_commitments.len(), + salt.proof.levels.len(), + mpt, + ) + } + + /// Computes the breakdown for a light-decoded witness. + /// + /// A [`LightWitness`] never materializes the parent commitments, so their + /// contribution is unknowable here and `salt_size` is a lower bound + /// (KVs + levels + the fixed IPA overhead). Use only for observability on + /// light-decode paths; full-decode paths should keep [`Self::new`]. + pub fn new_light(light: &LightWitness, mpt: &MptWitness) -> Self { + Self::from_counts(light.kvs.len(), 0, light.levels.len(), mpt) + } + + /// Shared assembly from entry counts plus the MPT byte sum. + fn from_counts(kvs_count: usize, commitments: usize, levels: usize, mpt: &MptWitness) -> Self { let salt_kvs_size = kvs_count * SALT_KV_BYTES; - let proof_size = salt.proof.parents_commitments.len() * SALT_COMMITMENT_BYTES + - SALT_IPA_PROOF_BYTES + - salt.proof.levels.len() * SALT_LEVEL_BYTES; + let proof_size = + commitments * SALT_COMMITMENT_BYTES + SALT_IPA_PROOF_BYTES + levels * SALT_LEVEL_BYTES; let salt_size = salt_kvs_size + proof_size; let mpt_size = MPT_STORAGE_ROOT_BYTES + mpt.state.iter().map(|b| b.len()).sum::(); Self { salt_size, kvs_count, salt_kvs_size, mpt_size } @@ -58,7 +76,35 @@ impl WitnessSizeBreakdown { } } -/// Convenience wrapper that returns just the total estimated size. -pub fn estimate_witness_size(salt: &SaltWitness, mpt: &MptWitness) -> usize { - WitnessSizeBreakdown::new(salt, mpt).total() +#[cfg(test)] +mod tests { + use stateless_test_utils::fixtures::TestFixtures; + + use super::*; + + /// `new_light` must agree with the full breakdown on everything except + /// the parent-commitments term it cannot know: same kv count and MPT + /// size, and a salt_size that is exactly the full figure minus the + /// commitments contribution. + #[test] + fn light_breakdown_is_the_documented_lower_bound() { + let fixtures = TestFixtures::mainnet_shared(); + let (_, hash) = fixtures.paired_blocks().into_iter().next().expect("paired fixture"); + let salt = &fixtures.salt_witnesses[&hash]; + let mpt: MptWitness = fixtures.mpt_witness(&hash); + let light = LightWitness::from(salt); + + let full = WitnessSizeBreakdown::new(salt, &mpt); + let lower = WitnessSizeBreakdown::new_light(&light, &mpt); + + assert_eq!(lower.kvs_count, full.kvs_count); + assert_eq!(lower.salt_kvs_size, full.salt_kvs_size); + assert_eq!(lower.mpt_size, full.mpt_size); + assert_eq!( + full.salt_size - lower.salt_size, + salt.proof.parents_commitments.len() * SALT_COMMITMENT_BYTES, + "the gap must be exactly the commitments term" + ); + assert!(lower.total() <= full.total()); + } } diff --git a/crates/stateless-core/src/data_types.rs b/crates/stateless-core/src/data_types.rs index 6476086e..47905aa3 100644 --- a/crates/stateless-core/src/data_types.rs +++ b/crates/stateless-core/src/data_types.rs @@ -208,6 +208,15 @@ pub fn iter_code_hashes( }) } +/// [`iter_code_hashes`], deduplicated and sorted for stable ordering — the +/// form witness fetchers want (e.g. the trace server). +pub fn collect_code_hashes(kvs: &BTreeMap>) -> Vec { + let mut hashes: Vec = iter_code_hashes(kvs).collect(); + hashes.sort_unstable(); + hashes.dedup(); + hashes +} + #[cfg(test)] mod tests { use std::vec; @@ -313,4 +322,18 @@ mod tests { fn test_iter_code_hashes_empty() { assert_eq!(iter_code_hashes(&BTreeMap::new()).count(), 0); } + + #[test] + fn test_collect_code_hashes_dedups_and_sorts() { + let hi = B256::from([0xEE; 32]); + let lo = B256::from([0x11; 32]); + // Iteration order yields [hi, lo, hi]: unsorted and with a duplicate. + let map = kvs(vec![ + Some(account_kv(1, Some(hi))), + Some(account_kv(2, Some(lo))), + Some(account_kv(3, Some(hi))), + ]); + assert_eq!(iter_code_hashes(&map).collect::>(), vec![hi, lo, hi]); + assert_eq!(collect_code_hashes(&map), vec![lo, hi]); + } } diff --git a/crates/stateless-core/src/lib.rs b/crates/stateless-core/src/lib.rs index b407a4c1..37deadd7 100644 --- a/crates/stateless-core/src/lib.rs +++ b/crates/stateless-core/src/lib.rs @@ -23,7 +23,7 @@ extern crate alloc as std; pub mod chain_spec; pub mod light_witness; -pub use light_witness::{LightWitness, LightWitnessExecutor}; +pub use light_witness::{LightWitness, LightWitnessExecutor, LightWitnessFromSalt}; pub mod evm_database; pub use evm_database::{WitnessDatabase, WitnessDatabaseError, WitnessExternalEnv}; pub mod db; @@ -31,7 +31,7 @@ pub use db::{ BlockMeta, ChainStore, ContractStore, MissingDataKind, StoreError, StoreResult, StoreResultExt, }; pub mod data_types; -pub use data_types::{PlainKey, PlainValue, iter_code_hashes}; +pub use data_types::{PlainKey, PlainValue, collect_code_hashes, iter_code_hashes}; pub mod executor; pub use executor::{BlockInput, ValidationError, ValidationStats, replay_block, validate_block}; #[cfg(feature = "std")] diff --git a/crates/stateless-core/src/light_witness.rs b/crates/stateless-core/src/light_witness.rs index 95c7e371..035e200f 100644 --- a/crates/stateless-core/src/light_witness.rs +++ b/crates/stateless-core/src/light_witness.rs @@ -1,34 +1,49 @@ //! Light witness deserialization for tracing/execution. //! -//! This module provides a fast witness type that skips expensive cryptographic -//! point validation during deserialization. The standard `SaltWitness` type -//! deserializes `SerdeCommitment` which calls `Element::from_bytes()` for -//! elliptic curve point validation - this is slow (~240ms for large witnesses). +//! Execution-only consumers (e.g. debug-trace-server) read +//! state from a witness but never verify its cryptographic proof. This module +//! provides [`LightWitness`] — just the witnessed key-values and bucket +//! subtree levels — plus two ways to obtain it cheaply: //! -//! For debug-trace-server, we only need the state data (`kvs`) and bucket levels -//! (`proof.levels`) for execution. We don't need the cryptographic proofs since -//! we trust our own database. +//! - [`LightWitness::from`] an already-decoded `SaltWitness` (copies only the light parts), and +//! - [`LightWitnessFromSalt`], a serde adapter that decodes the light parts **directly from full +//! `SaltWitness` bytes**: the proof material is parsed structurally (so the stream stays in sync) +//! but read as raw bytes and discarded — no curve point is ever constructed or validated. //! //! ## Performance //! -//! - Standard `SaltWitness` deserialization: ~240ms (due to EC point validation) -//! - `LightWitness` deserialization: ~10-20ms (skips EC point validation) +//! The full `SaltWitness` decode runs one `Element::from_bytes` (modular sqrt + subgroup check) +//! per parent commitment; on large witnesses that elliptic-curve work dominates the decode even +//! though salt parallelizes it across cores. The light decode skips all of it and is orders of +//! magnitude cheaper, single-threaded. +//! +//! ## Safety model +//! +//! The zero-validation path performs no cryptographic checks: corrupt or +//! malicious proof bytes decode successfully. Only use it where witness +//! integrity is guaranteed elsewhere (trusted local storage, or a stream a +//! validator has already verified). Never use it on the proof-verification +//! path. -use core::ops::RangeInclusive; +use core::{fmt, ops::RangeInclusive}; use std::{collections::BTreeMap, vec::Vec}; use hashbrown::HashMap; use rustc_hash::FxBuildHasher; -use salt::{BucketId, BucketMeta, SaltKey, SaltValue, bucket_metadata_key, traits::StateReader}; -use serde::{Deserialize, Serialize}; +use salt::{ + BucketId, BucketMeta, NodeId, SaltKey, SaltValue, bucket_metadata_key, traits::StateReader, +}; +use serde::{Deserialize, Deserializer, Serialize}; type FxHashMap = HashMap; /// Light witness that only contains data needed for execution. /// -/// This struct mirrors `SaltWitness` but stores proof data as raw bytes -/// instead of deserializing the expensive `SerdeCommitment` types. -#[derive(Clone, Debug, Serialize, Deserialize)] +/// The derived `Serialize`/`Deserialize` round-trip this two-field struct in +/// its own compact layout (used for local storage, e.g. the trace server DB). +/// To decode from full `SaltWitness` bytes instead, use +/// [`LightWitnessFromSalt`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct LightWitness { /// All witnessed key-value pairs (same as SaltWitness.kvs) pub kvs: BTreeMap>, @@ -52,6 +67,91 @@ impl From<&salt::SaltWitness> for LightWitness { } } +/// Newtype adapter whose `Deserialize` impl consumes a full `SaltWitness` +/// stream and keeps only the light parts, skipping all elliptic-curve work +/// (see the module docs for the safety model). +/// +/// Use it positionally wherever full witness bytes are decoded, e.g. +/// `bincode::serde::decode_from_slice::<(LightWitnessFromSalt, MptWitness), _>(..)` +/// against bytes produced from `(SaltWitness, MptWitness)`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LightWitnessFromSalt(pub LightWitness); + +impl<'de> Deserialize<'de> for LightWitnessFromSalt { + fn deserialize>(deserializer: D) -> Result { + from_salt_witness::deserialize(deserializer).map(Self) + } +} + +/// Decoding of a [`LightWitness`] from a full-`SaltWitness` serde stream — +/// the implementation behind [`LightWitnessFromSalt`] (the only public +/// surface; make this module public if a `#[serde(deserialize_with = ...)]` +/// consumer ever appears). +/// +/// The mirror types below must stay field-for-field congruent with +/// `salt::SaltWitness` / `salt::SaltProof` (same field names, order, and wire +/// shapes); the fixture tests in this module lock that in against real +/// mainnet witnesses. +mod from_salt_witness { + use serde::de::{MapAccess, Visitor}; + + use super::*; + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let mirror = WitnessMirror::deserialize(d)?; + Ok(LightWitness { kvs: mirror.kvs, levels: mirror.proof.levels }) + } + + /// Serde-layout mirror of `salt::SaltWitness`. + #[derive(Deserialize)] + struct WitnessMirror { + kvs: BTreeMap>, + proof: ProofMirror, + } + + /// Serde-layout mirror of `salt::SaltProof`. Proof material is consumed as + /// raw bytes and dropped; only `levels` is materialized. + #[derive(Deserialize)] + struct ProofMirror { + #[serde(deserialize_with = "discard_parents_commitments")] + #[allow(dead_code)] + parents_commitments: (), + #[serde(deserialize_with = "discard_ipa_proof_bytes")] + #[allow(dead_code)] + proof: (), + #[serde(with = "salt::fx_hashmap_serde")] + levels: FxHashMap, + } + + /// Consumes the `NodeId -> [u8; 32]` commitments map without building + /// anything: no `BTreeMap`, no `Element::from_bytes`, no subgroup checks. + fn discard_parents_commitments<'de, D: Deserializer<'de>>(d: D) -> Result<(), D::Error> { + struct DiscardMap; + + impl<'de> Visitor<'de> for DiscardMap { + type Value = (); + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a map of NodeId to 32-byte compressed commitments") + } + + fn visit_map>(self, mut access: A) -> Result<(), A::Error> { + while access.next_entry::()?.is_some() {} + Ok(()) + } + } + + d.deserialize_map(DiscardMap) + } + + /// Consumes the IPA proof exactly as it was written (`SerdeMultiPointProof` + /// serializes its `to_bytes()` output as a `Vec`) without calling + /// `MultiPointProof::from_bytes`. + fn discard_ipa_proof_bytes<'de, D: Deserializer<'de>>(d: D) -> Result<(), D::Error> { + Vec::::deserialize(d).map(drop) + } +} + /// Error type for LightWitness StateReader operations #[derive(Debug, Clone, thiserror::Error)] #[error("{message}")] @@ -82,7 +182,14 @@ impl StateReader for LightWitness { match self.kvs.get(&metadata_key) { Some(Some(salt_value)) => BucketMeta::try_from(salt_value.clone()) .map_err(|_| LightWitnessError { message: "Failed to decode metadata" }), - Some(None) => unreachable!("Metadata should never be stored as None in witness"), + // A well-formed witness never maps a metadata key to a deletion, + // but witness bytes are network input (and the light decode + // validates nothing) — this must be an error, not a panic: a + // panic here takes down the whole consumer (e.g. an RPC handler + // task) on one corrupt response. + Some(None) => { + Err(LightWitnessError { message: "Corrupt witness: metadata key maps to None" }) + } None => Err(LightWitnessError { message: "Metadata not in witness" }), } } @@ -195,6 +302,10 @@ impl LightWitnessExecutor { #[cfg(test)] mod tests { + // `std` is the `alloc` alias in no_std builds, where the prelude carries + // no `vec!` — import it explicitly (same as chain_spec.rs). + use std::vec; + use super::*; #[test] @@ -204,6 +315,23 @@ mod tests { assert!(fast.levels.is_empty()); } + /// A corrupt witness that maps a bucket's metadata key to `None` must + /// surface as a `StateReader` error, not a panic: witness bytes are + /// unvalidated network input on the light path, and a panic here kills + /// the whole consumer (e.g. an RPC handler) instead of failing one + /// request. + #[test] + fn metadata_key_mapped_to_none_is_an_error_not_a_panic() { + // First valid data-bucket id (bucket_metadata_key asserts the range). + let bucket: BucketId = 65536; + let mut kvs: BTreeMap> = BTreeMap::new(); + kvs.insert(bucket_metadata_key(bucket), None); + let witness = LightWitness { kvs, levels: FxHashMap::default() }; + + let err = witness.metadata(bucket).expect_err("must not panic"); + assert!(err.message.contains("Corrupt witness"), "got: {err}"); + } + /// Round-trip a populated `LightWitness` through bincode to confirm the /// `#[serde(with = "salt::fx_hashmap_serde")]` wiring on the `levels` /// field actually works end-to-end. The adapter itself is covered by @@ -226,4 +354,81 @@ mod tests { assert_eq!(decoded.levels.get(k), Some(v)); } } + + /// Every real mainnet fixture witness light-decodes from the exact bytes + /// of its full encoding (wire config, bincode legacy), consuming the + /// stream to the last byte. This is the layout-congruence lock for the + /// mirror types in [`from_salt_witness`]. + #[test] + fn light_decodes_from_full_witness_bytes() { + let fixtures = stateless_test_utils::fixtures::TestFixtures::mainnet_shared(); + assert!(!fixtures.salt_witnesses.is_empty(), "no fixture witnesses"); + + for (hash, witness) in &fixtures.salt_witnesses { + let bytes = bincode::serde::encode_to_vec(witness, bincode::config::legacy()).unwrap(); + let (light, consumed): (LightWitnessFromSalt, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()) + .unwrap_or_else(|e| panic!("light decode {hash}: {e}")); + + assert_eq!(consumed, bytes.len(), "{hash} light decode left trailing bytes"); + assert_eq!(light.0, LightWitness::from(witness), "{hash} light parts mismatch"); + assert!(!light.0.kvs.is_empty(), "{hash} decoded no kvs"); + } + } + + /// The layout mirror is bincode-config-agnostic (varint vs fixint). + #[test] + fn light_decode_is_config_agnostic() { + let fixtures = stateless_test_utils::fixtures::TestFixtures::mainnet_shared(); + let witness = fixtures.salt_witnesses.values().next().unwrap(); + + let bytes = bincode::serde::encode_to_vec(witness, bincode::config::standard()).unwrap(); + let (light, _): (LightWitnessFromSalt, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap(); + + assert_eq!(light.0, LightWitness::from(witness)); + } + + /// The point of the light path: proof bytes are NOT validated. A stream + /// whose commitments are not valid curve points fails the full decode but + /// light-decodes fine. + #[test] + fn light_decode_skips_ec_validation() { + #[derive(Serialize)] + struct RawWitness { + kvs: BTreeMap>, + proof: RawProof, + } + #[derive(Serialize)] + struct RawProof { + parents_commitments: BTreeMap, + proof: Vec, + #[serde(with = "salt::fx_hashmap_serde")] + levels: FxHashMap, + } + + let fixtures = stateless_test_utils::fixtures::TestFixtures::mainnet_shared(); + let real = fixtures.salt_witnesses.values().next().unwrap(); + let raw = RawWitness { + kvs: real.kvs.clone(), + proof: RawProof { + // 0xFF..FF is not a valid compressed banderwagon point. + parents_commitments: [(7u64, [0xFF; 32]), (9u64, [0xFF; 32])].into(), + proof: vec![0xAB; 64], + levels: real.proof.levels.clone(), + }, + }; + let bytes = bincode::serde::encode_to_vec(&raw, bincode::config::legacy()).unwrap(); + + // Full decode rejects the garbage point... + let full: Result<(salt::SaltWitness, usize), _> = + bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()); + assert!(full.is_err(), "full decode must validate curve points"); + + // ...the light decode never looks at it. + let (light, _): (LightWitnessFromSalt, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()).unwrap(); + assert_eq!(light.0.kvs, real.kvs); + assert_eq!(light.0.levels, real.proof.levels); + } }