From 6eef0cee533495bebf7c43a188e3706ae0815159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:55:41 -0300 Subject: [PATCH 1/6] feat(shadow): dummy XMSS proofs + sim-cost sleeps behind shadow-integration Port zeam's Shadow-simulator fake-XMSS mode so the lean-shadow-fuzzer can run ethlambda without the multi-second leanVM aggregation prover/verifier. Everything is gated behind the shadow-integration feature; a stock build compiles none of it and behaves identically. crypto: new shadow_cost module (process-global atomics config for a fake toggle plus three rate knobs), rate-based sleep helpers (aggregate/verify/merge = n/rate seconds on Shadow's virtual clock), and a dependency-free deterministic dummy-proof fill (FNV-1a seed fold + SplitMix64). Fake/sleep interception in all 7 aggregation/verify functions; the fake path short-circuits before leanVM setup and returns a deterministic stub proof (provers) or Ok(()) (verifiers). cli: feature-gated ShadowOptions (4 flags) flattened into CliOptions; main wires shadow_cost::init once at startup. Matches zeam: split and Type-2 verify have no modeled sleep cost. --- Cargo.lock | 1 + bin/ethlambda/Cargo.toml | 3 +- bin/ethlambda/src/cli.rs | 33 +++ bin/ethlambda/src/main.rs | 18 ++ crates/common/crypto/Cargo.toml | 3 + crates/common/crypto/src/lib.rs | 253 ++++++++++++++++++++- crates/common/crypto/src/shadow_cost.rs | 284 ++++++++++++++++++++++++ 7 files changed, 590 insertions(+), 5 deletions(-) create mode 100644 crates/common/crypto/src/shadow_cost.rs diff --git a/Cargo.lock b/Cargo.lock index 5c0ed65f..c57423fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2011,6 +2011,7 @@ version = "0.1.0" dependencies = [ "clap", "ethlambda-blockchain", + "ethlambda-crypto", "ethlambda-network-api", "ethlambda-p2p", "ethlambda-rpc", diff --git a/bin/ethlambda/Cargo.toml b/bin/ethlambda/Cargo.toml index b7137565..3b9e5582 100644 --- a/bin/ethlambda/Cargo.toml +++ b/bin/ethlambda/Cargo.toml @@ -16,10 +16,11 @@ jemalloc = ["dep:tikv-jemallocator"] # Shadow simulator compatibility: single-threaded tokio runtime and no jemalloc. # The quinn-udp UDP fallback is a Cargo `[patch]` (which cannot be feature-gated), # injected at build time by `shadow/build.sh` / `make shadow-build`. -shadow-integration = [] +shadow-integration = ["ethlambda-crypto/shadow-integration"] [dependencies] ethlambda-blockchain.workspace = true +ethlambda-crypto.workspace = true ethlambda-network-api.workspace = true ethlambda-p2p.workspace = true ethlambda-types.workspace = true diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index c0f09ddf..a6997b6f 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -97,4 +97,37 @@ pub(crate) struct CliOptions { /// coverage. #[arg(long, default_value = "false")] pub(crate) enable_proposer_aggregation: bool, + /// Shadow-simulator sim-cost + fake-XMSS flags (only under the + /// `shadow-integration` feature). + #[cfg(feature = "shadow-integration")] + #[command(flatten)] + pub(crate) shadow: ShadowOptions, +} + +/// Shadow-simulator sim-cost + fake-XMSS flags. Compiled only under the +/// `shadow-integration` feature. +#[cfg(feature = "shadow-integration")] +#[derive(Debug, clap::Args)] +pub(crate) struct ShadowOptions { + /// Shadow sim only: replace the XMSS aggregation prover/verifier with a + /// deterministic stub (no leanVM proving/verifying). Off by default. + #[arg(long, default_value = "false")] + pub(crate) shadow_xmss_fake: bool, + + /// Shadow sim only: signatures aggregated per second. Injects a sleep of + /// n/rate seconds into aggregation so its CPU cost shows up on Shadow's + /// virtual clock. Unset or <= 0 disables. + #[arg(long)] + pub(crate) shadow_xmss_aggregate_signatures_rate: Option, + + /// Shadow sim only: signatures verified per aggregate per second; injects + /// a sleep of n/rate seconds into verification. Unset or <= 0 disables. + #[arg(long)] + pub(crate) shadow_xmss_verify_aggregated_signatures_rate: Option, + + /// Shadow sim only: Type-1 components merged into a Type-2 per second; + /// injects a sleep of n/rate seconds into the proposal Type-2 merge. + /// Unset or <= 0 disables. + #[arg(long)] + pub(crate) shadow_xmss_merge_rate: Option, } diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 591cdcfd..052b7a09 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -79,6 +79,24 @@ async fn main() -> eyre::Result<()> { let options = CliOptions::parse(); + #[cfg(feature = "shadow-integration")] + { + let shadow = &options.shadow; + info!( + fake = shadow.shadow_xmss_fake, + aggregate_rate = ?shadow.shadow_xmss_aggregate_signatures_rate, + verify_rate = ?shadow.shadow_xmss_verify_aggregated_signatures_rate, + merge_rate = ?shadow.shadow_xmss_merge_rate, + "Applying Shadow XMSS sim-cost / fake-XMSS config" + ); + ethlambda_crypto::shadow_cost::init( + shadow.shadow_xmss_fake, + shadow.shadow_xmss_aggregate_signatures_rate, + shadow.shadow_xmss_verify_aggregated_signatures_rate, + shadow.shadow_xmss_merge_rate, + ); + } + // Initialize metrics ethlambda_blockchain::metrics::init(); ethlambda_blockchain::metrics::set_node_info("ethlambda", version::CLIENT_VERSION); diff --git a/crates/common/crypto/Cargo.toml b/crates/common/crypto/Cargo.toml index 83f5c696..5ec86eca 100644 --- a/crates/common/crypto/Cargo.toml +++ b/crates/common/crypto/Cargo.toml @@ -21,5 +21,8 @@ leansig.workspace = true thiserror.workspace = true rand.workspace = true +[features] +shadow-integration = [] + [dev-dependencies] hex.workspace = true diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 1fae35b1..fb892a71 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -14,6 +14,9 @@ use lean_multisig::{ use leansig_wrapper::{XmssPublicKey as LeanSigPubKey, XmssSignature as LeanSigSignature}; use thiserror::Error; +#[cfg(feature = "shadow-integration")] +pub mod shadow_cost; + /// log(1/rate) for the WHIR commitment scheme used inside lean-multisig. const LOG_INV_RATE: usize = 2; @@ -163,6 +166,21 @@ pub fn aggregate_signatures( return Err(AggregationError::EmptyInput); } + #[cfg(feature = "shadow-integration")] + let agg_n = public_keys.len(); + + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + let count_bytes = public_keys.len().to_le_bytes(); + let slot_bytes = slot.to_le_bytes(); + let dummy = crate::shadow_cost::fill_fake_proof( + crate::shadow_cost::FAKE_PROOF_SIZE, + &[&message.0, &slot_bytes, &count_bytes], + ); + std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + return Ok(dummy); + } + ensure_prover_ready(); let raw_xmss: Vec<(LeanSigPubKey, LeanSigSignature)> = public_keys @@ -174,7 +192,10 @@ pub fn aggregate_signatures( let proof = aggregate_single_message_signatures(&[], raw_xmss, message.0, slot, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - compress_type1_to_byte_list(&proof) + let result = compress_type1_to_byte_list(&proof)?; + #[cfg(feature = "shadow-integration")] + std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + Ok(result) } /// Aggregate both existing Type-1 proofs (children) and raw XMSS signatures. @@ -201,6 +222,24 @@ pub fn aggregate_mixed( return Err(AggregationError::InsufficientChildren(children.len())); } + #[cfg(feature = "shadow-integration")] + let agg_n = raw_public_keys.len(); + + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + let count_bytes = raw_public_keys.len().to_le_bytes(); + let slot_bytes = slot.to_le_bytes(); + let mut parts: Vec<&[u8]> = vec![&message.0, &slot_bytes]; + for (_, proof) in &children { + parts.push(proof.iter().as_slice()); + } + parts.push(&count_bytes); + let dummy = + crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); + std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + return Ok(dummy); + } + ensure_prover_ready(); let children_native: Vec = children @@ -224,7 +263,10 @@ pub fn aggregate_mixed( ) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - compress_type1_to_byte_list(&proof) + let result = compress_type1_to_byte_list(&proof)?; + #[cfg(feature = "shadow-integration")] + std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + Ok(result) } /// Recursively aggregate two or more already-aggregated Type-1 proofs into one. @@ -240,6 +282,22 @@ pub fn aggregate_proofs( return Err(AggregationError::InsufficientChildren(children.len())); } + #[cfg(feature = "shadow-integration")] + let agg_n = children.len(); + + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + let slot_bytes = slot.to_le_bytes(); + let mut parts: Vec<&[u8]> = vec![&message.0, &slot_bytes]; + for (_, proof) in &children { + parts.push(proof.iter().as_slice()); + } + let dummy = + crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); + std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + return Ok(dummy); + } + ensure_prover_ready(); let children_native: Vec = children @@ -257,7 +315,10 @@ pub fn aggregate_proofs( ) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - compress_type1_to_byte_list(&proof) + let result = compress_type1_to_byte_list(&proof)?; + #[cfg(feature = "shadow-integration")] + std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + Ok(result) } /// Verify a Type-1 aggregated signature proof. @@ -272,6 +333,15 @@ pub fn verify_aggregated_signature( message: &H256, slot: u32, ) -> Result<(), VerificationError> { + #[cfg(feature = "shadow-integration")] + let verify_n = public_keys.len(); + + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + std::thread::sleep(crate::shadow_cost::verify_delay(verify_n)); + return Ok(()); + } + ensure_verifier_ready(); let lean_pubkeys = into_lean_pubkeys(public_keys); @@ -288,6 +358,8 @@ pub fn verify_aggregated_signature( } verify_single_message_aggregate(&sig)?; + #[cfg(feature = "shadow-integration")] + std::thread::sleep(crate::shadow_cost::verify_delay(verify_n)); Ok(()) } @@ -310,6 +382,23 @@ pub fn merge_type_1s_into_type_2( return Err(AggregationError::EmptyInput); } + #[cfg(feature = "shadow-integration")] + let merge_n = type_1s.len(); + + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + let count_bytes = type_1s.len().to_le_bytes(); + let mut parts: Vec<&[u8]> = Vec::with_capacity(type_1s.len() + 1); + for (_, proof) in &type_1s { + parts.push(proof.iter().as_slice()); + } + parts.push(&count_bytes); + let dummy = + crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); + std::thread::sleep(crate::shadow_cost::merge_delay(merge_n)); + return Ok(dummy); + } + ensure_prover_ready(); let type_1s_native: Vec = type_1s @@ -321,7 +410,10 @@ pub fn merge_type_1s_into_type_2( let merged = merge_single_message_aggregates(type_1s_native, LOG_INV_RATE) .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; - compress_type2_to_byte_list(&merged) + let result = compress_type2_to_byte_list(&merged)?; + #[cfg(feature = "shadow-integration")] + std::thread::sleep(crate::shadow_cost::merge_delay(merge_n)); + Ok(result) } /// Verify a Type-2 merged proof against the per-component expected bindings. @@ -341,6 +433,11 @@ pub fn verify_type_2_signature( }); } + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + return Ok(()); + } + ensure_verifier_ready(); let pubkeys_per_info: Vec> = pubkeys_per_component @@ -391,6 +488,14 @@ pub fn split_type_2_by_message( pubkeys_per_component: Vec>, message: &H256, ) -> Result { + #[cfg(feature = "shadow-integration")] + if crate::shadow_cost::fake_xmss() { + return Ok(crate::shadow_cost::fill_fake_proof( + crate::shadow_cost::FAKE_PROOF_SIZE, + &[proof_data, &message.0], + )); + } + ensure_prover_ready(); let pubkeys_per_info: Vec> = pubkeys_per_component @@ -606,3 +711,143 @@ mod tests { .expect("verify split"); } } + +/// Tests for the `shadow-integration` fake-XMSS interception paths in the 7 +/// aggregation/verify functions above. These never build real XMSS keys — +/// the fake path never dereferences pubkeys/sigs, only cheap dummy bytes. +/// +/// `aggregate_signatures` has no dedicated fake test: unlike the others it +/// takes `Vec`/`Vec` (opaque leansig +/// wrappers that `from_bytes` deserializes structurally, so they can't be +/// constructed from arbitrary bytes cheaply), and its non-empty guard is hit +/// before the fake branch. Its fake seed shape matches `aggregate_mixed`'s +/// with no children, so it is covered transitively. +#[cfg(all(test, feature = "shadow-integration"))] +mod fake_interception_tests { + use super::*; + use crate::shadow_cost::{FAKE_PROOF_SIZE, TEST_LOCK, init, reset_for_test}; + + fn dummy_proof(byte: u8) -> ByteList512KiB { + ByteList512KiB::try_from(vec![byte; 100]).unwrap() + } + + #[test] + fn aggregate_mixed_fake_returns_dummy_of_expected_size() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + init(true, None, None, None); + + let message = H256::from([1u8; 32]); + let children = vec![(vec![], dummy_proof(0xAA)), (vec![], dummy_proof(0xBB))]; + + let result = aggregate_mixed(children, vec![], vec![], &message, 7); + let proof = result.expect("fake aggregate_mixed should succeed"); + assert_eq!(proof.len(), FAKE_PROOF_SIZE); + assert!(proof.len() < 512 * 1024); + + reset_for_test(); + } + + #[test] + fn aggregate_mixed_fake_is_deterministic_and_message_sensitive() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + init(true, None, None, None); + + let message = H256::from([2u8; 32]); + let other_message = H256::from([3u8; 32]); + let children = || vec![(vec![], dummy_proof(0xAA)), (vec![], dummy_proof(0xBB))]; + + let a = aggregate_mixed(children(), vec![], vec![], &message, 7).unwrap(); + let b = aggregate_mixed(children(), vec![], vec![], &message, 7).unwrap(); + assert_eq!(a.iter().as_slice(), b.iter().as_slice()); + + let c = aggregate_mixed(children(), vec![], vec![], &other_message, 7).unwrap(); + assert_ne!(a.iter().as_slice(), c.iter().as_slice()); + + reset_for_test(); + } + + #[test] + fn aggregate_proofs_fake_returns_dummy_of_expected_size() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + init(true, None, None, None); + + let message = H256::from([4u8; 32]); + let children = vec![(vec![], dummy_proof(0xCC)), (vec![], dummy_proof(0xDD))]; + + let result = aggregate_proofs(children, &message, 9); + let proof = result.expect("fake aggregate_proofs should succeed"); + assert_eq!(proof.len(), FAKE_PROOF_SIZE); + assert!(proof.len() < 512 * 1024); + + reset_for_test(); + } + + #[test] + fn merge_and_verify_type_2_fake_round_trip() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + init(true, None, None, None); + + let a = dummy_proof(0x11); + let b = dummy_proof(0x22); + let type_1s = vec![(vec![], a), (vec![], b)]; + + let merged = merge_type_1s_into_type_2(type_1s).expect("fake merge should succeed"); + assert_eq!(merged.len(), FAKE_PROOF_SIZE); + assert!(merged.len() < 512 * 1024); + + let message = H256::from([5u8; 32]); + let slot = 12u32; + let verify_result = + verify_type_2_signature(merged.iter().as_slice(), vec![vec![]], &[(message, slot)]); + assert!( + verify_result.is_ok(), + "fake verify_type_2_signature should accept: {:?}", + verify_result.err() + ); + + reset_for_test(); + } + + #[test] + fn split_type_2_by_message_fake_returns_dummy_of_expected_size() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + init(true, None, None, None); + + let a = dummy_proof(0x33); + let b = dummy_proof(0x44); + let merged = merge_type_1s_into_type_2(vec![(vec![], a), (vec![], b)]) + .expect("fake merge should succeed"); + + let message = H256::from([6u8; 32]); + let split = split_type_2_by_message(merged.iter().as_slice(), vec![vec![]], &message) + .expect("fake split should succeed"); + assert_eq!(split.len(), FAKE_PROOF_SIZE); + assert!(split.len() < 512 * 1024); + + reset_for_test(); + } + + #[test] + fn verify_aggregated_signature_fake_accepts_dummy_input() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + init(true, None, None, None); + + let proof = dummy_proof(0x55); + let message = H256::from([7u8; 32]); + + let result = verify_aggregated_signature(&proof, vec![], &message, 3); + assert!( + result.is_ok(), + "fake verify_aggregated_signature should accept: {:?}", + result.err() + ); + + reset_for_test(); + } +} diff --git a/crates/common/crypto/src/shadow_cost.rs b/crates/common/crypto/src/shadow_cost.rs new file mode 100644 index 00000000..00da8659 --- /dev/null +++ b/crates/common/crypto/src/shadow_cost.rs @@ -0,0 +1,284 @@ +//! Shadow-simulator sim-cost + fake-proof backend. Compiled only under the +//! `shadow-integration` feature. Ported from zeam's shadow_cost.zig. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; + +use ethlambda_types::block::ByteList512KiB; + +// ===================================================================== +// Process-global config +// ===================================================================== +// +// Set once at startup via `init`, then read lock-free from every +// aggregation/verification call site. Rates are stored as the raw bits of +// an `f64` (via `to_bits`/`from_bits`) since `AtomicU64` has no `AtomicF64` +// counterpart; `0` bits encodes both `0.0` and "unset" (disabled). + +static FAKE_ENABLED: AtomicBool = AtomicBool::new(false); +static AGG_RATE: AtomicU64 = AtomicU64::new(0); +static VERIFY_RATE: AtomicU64 = AtomicU64::new(0); +static MERGE_RATE: AtomicU64 = AtomicU64::new(0); + +/// Convert an optional rate into the bit pattern stored in the atomic. +/// +/// Only finite, strictly positive rates are kept; anything else (`None`, +/// `NaN`, `Infinity`, zero, negative) collapses to `0`, which `compute_delay` +/// treats as "disabled". +fn rate_bits(v: Option) -> u64 { + match v { + Some(v) if v.is_finite() && v > 0.0 => v.to_bits(), + _ => 0, + } +} + +/// Configure the shadow sim-cost backend. +/// +/// Call exactly once at node startup, before any aggregation runs. +/// +/// `fake` switches the prover/verifier to the deterministic stub backend. +/// `agg`/`verify`/`merge` are the modeled operation rates (units per +/// second) used to compute sim-cost sleeps; `None` (or a non-finite / +/// non-positive value) disables the sleep for that operation. +pub fn init(fake: bool, agg: Option, verify: Option, merge: Option) { + FAKE_ENABLED.store(fake, Ordering::Relaxed); + AGG_RATE.store(rate_bits(agg), Ordering::Relaxed); + VERIFY_RATE.store(rate_bits(verify), Ordering::Relaxed); + MERGE_RATE.store(rate_bits(merge), Ordering::Relaxed); +} + +/// Whether the fake-XMSS stub backend is active. +pub fn fake_xmss() -> bool { + FAKE_ENABLED.load(Ordering::Relaxed) +} + +/// Compute the sim-cost delay for processing `n` units at the rate stored +/// in `rate` (units per second), or `Duration::ZERO` if the rate is unset +/// or there is nothing to process. +fn compute_delay(rate: &AtomicU64, n: usize) -> Duration { + let r = f64::from_bits(rate.load(Ordering::Relaxed)); + if r <= 0.0 || n == 0 { + return Duration::ZERO; + } + + let ns = (n as f64 / r) * 1e9; + if !ns.is_finite() || ns <= 0.0 { + return Duration::ZERO; + } + + // Clamp before the f64 -> u64 cast: an out-of-range cast is a saturating + // cast in Rust, but staying above `u64::MAX` risks precision surprises, + // so clamp explicitly for clarity. + Duration::from_nanos(ns.min(u64::MAX as f64) as u64) +} + +/// Nanoseconds to sleep to model aggregating `n` raw signatures. +pub fn aggregate_delay(n: usize) -> Duration { + compute_delay(&AGG_RATE, n) +} + +/// Nanoseconds to sleep to model verifying `n` signatures/proofs. +pub fn verify_delay(n: usize) -> Duration { + compute_delay(&VERIFY_RATE, n) +} + +/// Nanoseconds to sleep to model merging `n` proofs. +pub fn merge_delay(n: usize) -> Duration { + compute_delay(&MERGE_RATE, n) +} + +/// Fixed size of every fake stub proof; well under the 512 KiB +/// `ByteList512KiB` wire cap. +pub const FAKE_PROOF_SIZE: usize = 32 * 1024; + +/// Produce a deterministic `len`-byte stub proof derived only from +/// `seed_parts`. +/// +/// # Determinism contract +/// +/// Callers MUST seed this only from values the real FFI binds — the +/// message hash, slot, child-proof bytes, participant counts, and similar +/// — and MUST NEVER seed from a pointer/handle address or from randomness. +/// A stub proof carries no cryptographic strength; its only job is to let +/// every node compute the *same* bytes for the *same* logical inputs, so +/// fake proofs remain deterministic and consensus-safe across nodes. +/// +/// Uses a dependency-free FNV-1a fold of the seed bytes into a 64-bit seed, +/// then a SplitMix64 stream to fill the output buffer. +pub fn fill_fake_proof(len: usize, seed_parts: &[&[u8]]) -> ByteList512KiB { + const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + + let mut state = FNV_OFFSET_BASIS; + for part in seed_parts { + for &byte in *part { + state ^= u64::from(byte); + state = state.wrapping_mul(FNV_PRIME); + } + } + + let mut bytes = Vec::with_capacity(len); + while bytes.len() < len { + let z = state.wrapping_add(0x9E3779B97F4A7C15); + state = z; + let z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + let z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + let z = z ^ (z >> 31); + + let chunk = z.to_le_bytes(); + let remaining = len - bytes.len(); + bytes.extend_from_slice(&chunk[..remaining.min(chunk.len())]); + } + + ByteList512KiB::try_from(bytes).expect("FAKE_PROOF_SIZE fits ByteList512KiB cap") +} + +/// Test-only; every test that flips a global must call this (and hold the +/// serialization lock). +#[cfg(test)] +pub(crate) fn reset_for_test() { + FAKE_ENABLED.store(false, Ordering::Relaxed); + AGG_RATE.store(0, Ordering::Relaxed); + VERIFY_RATE.store(0, Ordering::Relaxed); + MERGE_RATE.store(0, Ordering::Relaxed); +} + +/// Serializes every test in this crate that mutates the process-global +/// atomics above (shared with `lib.rs`'s fake-interception tests). +#[cfg(test)] +pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delay_is_zero_when_rate_unset() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + init(false, None, None, None); + assert_eq!(aggregate_delay(100), Duration::ZERO); + assert_eq!(verify_delay(100), Duration::ZERO); + assert_eq!(merge_delay(100), Duration::ZERO); + + reset_for_test(); + } + + #[test] + fn delay_is_zero_when_rate_non_positive() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + init(false, Some(0.0), Some(-5.0), Some(f64::NAN)); + assert_eq!(aggregate_delay(100), Duration::ZERO); + assert_eq!(verify_delay(100), Duration::ZERO); + assert_eq!(merge_delay(100), Duration::ZERO); + + reset_for_test(); + } + + #[test] + fn delay_is_zero_when_n_is_zero() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + init(false, Some(100.0), Some(100.0), Some(100.0)); + assert_eq!(aggregate_delay(0), Duration::ZERO); + assert_eq!(verify_delay(0), Duration::ZERO); + assert_eq!(merge_delay(0), Duration::ZERO); + + reset_for_test(); + } + + #[test] + fn delay_is_proportional_to_n_over_rate() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + // rate = 100 units/sec, n = 100 units -> ~1 second. + init(false, Some(100.0), None, None); + let delay = aggregate_delay(100); + assert!( + delay > Duration::from_millis(990) && delay < Duration::from_millis(1010), + "expected ~1s, got {delay:?}" + ); + + reset_for_test(); + } + + #[test] + fn delay_scales_with_operation_count() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + init(false, None, Some(50.0), Some(200.0)); + + // verify: 50 units/sec, 25 units -> ~0.5s. + let verify = verify_delay(25); + assert!( + verify > Duration::from_millis(490) && verify < Duration::from_millis(510), + "expected ~0.5s, got {verify:?}" + ); + + // merge: 200 units/sec, 100 units -> ~0.5s. + let merge = merge_delay(100); + assert!( + merge > Duration::from_millis(490) && merge < Duration::from_millis(510), + "expected ~0.5s, got {merge:?}" + ); + + reset_for_test(); + } + + #[test] + fn fill_fake_proof_produces_requested_length() { + for len in [0usize, 1, 7, 64, 1000] { + let proof = fill_fake_proof(len, &[b"seed"]); + assert_eq!(proof.len(), len); + } + } + + #[test] + fn fill_fake_proof_at_fake_proof_size_fits_bytelist_cap() { + let proof = fill_fake_proof(FAKE_PROOF_SIZE, &[b"seed"]); + assert_eq!(proof.len(), FAKE_PROOF_SIZE); + assert!(proof.len() < 512 * 1024); + } + + #[test] + fn fill_fake_proof_is_deterministic_for_identical_seeds() { + let a = fill_fake_proof(256, &[b"hello", b"world"]); + let b = fill_fake_proof(256, &[b"hello", b"world"]); + assert_eq!(a.iter().as_slice(), b.iter().as_slice()); + } + + #[test] + fn fill_fake_proof_differs_when_a_seed_part_changes() { + let a = fill_fake_proof(256, &[b"hello", b"world"]); + let b = fill_fake_proof(256, &[b"hello", b"there"]); + assert_ne!(a.iter().as_slice(), b.iter().as_slice()); + } + + #[test] + fn fake_xmss_defaults_to_false() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + assert!(!fake_xmss()); + + reset_for_test(); + } + + #[test] + fn fake_xmss_reflects_init() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_for_test(); + + init(true, None, None, None); + assert!(fake_xmss()); + + reset_for_test(); + assert!(!fake_xmss()); + } +} From b1fde38a88f51d30cedbf07030c8178c3e17aa88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:03:05 -0300 Subject: [PATCH 2/6] refactor(shadow): extract shadow_cost::init wiring into init_shadow_cost helper --- bin/ethlambda/src/main.rs | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 052b7a09..8b678190 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -80,22 +80,7 @@ async fn main() -> eyre::Result<()> { let options = CliOptions::parse(); #[cfg(feature = "shadow-integration")] - { - let shadow = &options.shadow; - info!( - fake = shadow.shadow_xmss_fake, - aggregate_rate = ?shadow.shadow_xmss_aggregate_signatures_rate, - verify_rate = ?shadow.shadow_xmss_verify_aggregated_signatures_rate, - merge_rate = ?shadow.shadow_xmss_merge_rate, - "Applying Shadow XMSS sim-cost / fake-XMSS config" - ); - ethlambda_crypto::shadow_cost::init( - shadow.shadow_xmss_fake, - shadow.shadow_xmss_aggregate_signatures_rate, - shadow.shadow_xmss_verify_aggregated_signatures_rate, - shadow.shadow_xmss_merge_rate, - ); - } + init_shadow_cost(&options.shadow); // Initialize metrics ethlambda_blockchain::metrics::init(); @@ -315,6 +300,28 @@ async fn main() -> eyre::Result<()> { Ok(()) } +/// Apply the Shadow-simulator sim-cost / fake-XMSS configuration from the CLI. +/// +/// Compiled only under the `shadow-integration` feature. Call once at startup, +/// before any consensus/aggregation work, so the fake-proof and sim-cost hooks +/// are installed before the first signing or aggregation path runs. +#[cfg(feature = "shadow-integration")] +fn init_shadow_cost(shadow: &cli::ShadowOptions) { + info!( + fake = shadow.shadow_xmss_fake, + aggregate_rate = ?shadow.shadow_xmss_aggregate_signatures_rate, + verify_rate = ?shadow.shadow_xmss_verify_aggregated_signatures_rate, + merge_rate = ?shadow.shadow_xmss_merge_rate, + "Applying Shadow XMSS sim-cost / fake-XMSS config" + ); + ethlambda_crypto::shadow_cost::init( + shadow.shadow_xmss_fake, + shadow.shadow_xmss_aggregate_signatures_rate, + shadow.shadow_xmss_verify_aggregated_signatures_rate, + shadow.shadow_xmss_merge_rate, + ); +} + /// Boot the binary in Hive test-driver mode. /// /// Skips every consensus/p2p subsystem and just exposes the From e7435742de83e3e94358cff8c017a1e2953cae12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:08:26 -0300 Subject: [PATCH 3/6] test(shadow): remove shadow-integration tests --- crates/common/crypto/src/lib.rs | 140 ---------------------- crates/common/crypto/src/shadow_cost.rs | 150 ------------------------ 2 files changed, 290 deletions(-) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index fb892a71..fce135c1 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -711,143 +711,3 @@ mod tests { .expect("verify split"); } } - -/// Tests for the `shadow-integration` fake-XMSS interception paths in the 7 -/// aggregation/verify functions above. These never build real XMSS keys — -/// the fake path never dereferences pubkeys/sigs, only cheap dummy bytes. -/// -/// `aggregate_signatures` has no dedicated fake test: unlike the others it -/// takes `Vec`/`Vec` (opaque leansig -/// wrappers that `from_bytes` deserializes structurally, so they can't be -/// constructed from arbitrary bytes cheaply), and its non-empty guard is hit -/// before the fake branch. Its fake seed shape matches `aggregate_mixed`'s -/// with no children, so it is covered transitively. -#[cfg(all(test, feature = "shadow-integration"))] -mod fake_interception_tests { - use super::*; - use crate::shadow_cost::{FAKE_PROOF_SIZE, TEST_LOCK, init, reset_for_test}; - - fn dummy_proof(byte: u8) -> ByteList512KiB { - ByteList512KiB::try_from(vec![byte; 100]).unwrap() - } - - #[test] - fn aggregate_mixed_fake_returns_dummy_of_expected_size() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - init(true, None, None, None); - - let message = H256::from([1u8; 32]); - let children = vec![(vec![], dummy_proof(0xAA)), (vec![], dummy_proof(0xBB))]; - - let result = aggregate_mixed(children, vec![], vec![], &message, 7); - let proof = result.expect("fake aggregate_mixed should succeed"); - assert_eq!(proof.len(), FAKE_PROOF_SIZE); - assert!(proof.len() < 512 * 1024); - - reset_for_test(); - } - - #[test] - fn aggregate_mixed_fake_is_deterministic_and_message_sensitive() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - init(true, None, None, None); - - let message = H256::from([2u8; 32]); - let other_message = H256::from([3u8; 32]); - let children = || vec![(vec![], dummy_proof(0xAA)), (vec![], dummy_proof(0xBB))]; - - let a = aggregate_mixed(children(), vec![], vec![], &message, 7).unwrap(); - let b = aggregate_mixed(children(), vec![], vec![], &message, 7).unwrap(); - assert_eq!(a.iter().as_slice(), b.iter().as_slice()); - - let c = aggregate_mixed(children(), vec![], vec![], &other_message, 7).unwrap(); - assert_ne!(a.iter().as_slice(), c.iter().as_slice()); - - reset_for_test(); - } - - #[test] - fn aggregate_proofs_fake_returns_dummy_of_expected_size() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - init(true, None, None, None); - - let message = H256::from([4u8; 32]); - let children = vec![(vec![], dummy_proof(0xCC)), (vec![], dummy_proof(0xDD))]; - - let result = aggregate_proofs(children, &message, 9); - let proof = result.expect("fake aggregate_proofs should succeed"); - assert_eq!(proof.len(), FAKE_PROOF_SIZE); - assert!(proof.len() < 512 * 1024); - - reset_for_test(); - } - - #[test] - fn merge_and_verify_type_2_fake_round_trip() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - init(true, None, None, None); - - let a = dummy_proof(0x11); - let b = dummy_proof(0x22); - let type_1s = vec![(vec![], a), (vec![], b)]; - - let merged = merge_type_1s_into_type_2(type_1s).expect("fake merge should succeed"); - assert_eq!(merged.len(), FAKE_PROOF_SIZE); - assert!(merged.len() < 512 * 1024); - - let message = H256::from([5u8; 32]); - let slot = 12u32; - let verify_result = - verify_type_2_signature(merged.iter().as_slice(), vec![vec![]], &[(message, slot)]); - assert!( - verify_result.is_ok(), - "fake verify_type_2_signature should accept: {:?}", - verify_result.err() - ); - - reset_for_test(); - } - - #[test] - fn split_type_2_by_message_fake_returns_dummy_of_expected_size() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - init(true, None, None, None); - - let a = dummy_proof(0x33); - let b = dummy_proof(0x44); - let merged = merge_type_1s_into_type_2(vec![(vec![], a), (vec![], b)]) - .expect("fake merge should succeed"); - - let message = H256::from([6u8; 32]); - let split = split_type_2_by_message(merged.iter().as_slice(), vec![vec![]], &message) - .expect("fake split should succeed"); - assert_eq!(split.len(), FAKE_PROOF_SIZE); - assert!(split.len() < 512 * 1024); - - reset_for_test(); - } - - #[test] - fn verify_aggregated_signature_fake_accepts_dummy_input() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - init(true, None, None, None); - - let proof = dummy_proof(0x55); - let message = H256::from([7u8; 32]); - - let result = verify_aggregated_signature(&proof, vec![], &message, 3); - assert!( - result.is_ok(), - "fake verify_aggregated_signature should accept: {:?}", - result.err() - ); - - reset_for_test(); - } -} diff --git a/crates/common/crypto/src/shadow_cost.rs b/crates/common/crypto/src/shadow_cost.rs index 00da8659..eff36863 100644 --- a/crates/common/crypto/src/shadow_cost.rs +++ b/crates/common/crypto/src/shadow_cost.rs @@ -132,153 +132,3 @@ pub fn fill_fake_proof(len: usize, seed_parts: &[&[u8]]) -> ByteList512KiB { ByteList512KiB::try_from(bytes).expect("FAKE_PROOF_SIZE fits ByteList512KiB cap") } - -/// Test-only; every test that flips a global must call this (and hold the -/// serialization lock). -#[cfg(test)] -pub(crate) fn reset_for_test() { - FAKE_ENABLED.store(false, Ordering::Relaxed); - AGG_RATE.store(0, Ordering::Relaxed); - VERIFY_RATE.store(0, Ordering::Relaxed); - MERGE_RATE.store(0, Ordering::Relaxed); -} - -/// Serializes every test in this crate that mutates the process-global -/// atomics above (shared with `lib.rs`'s fake-interception tests). -#[cfg(test)] -pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn delay_is_zero_when_rate_unset() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - init(false, None, None, None); - assert_eq!(aggregate_delay(100), Duration::ZERO); - assert_eq!(verify_delay(100), Duration::ZERO); - assert_eq!(merge_delay(100), Duration::ZERO); - - reset_for_test(); - } - - #[test] - fn delay_is_zero_when_rate_non_positive() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - init(false, Some(0.0), Some(-5.0), Some(f64::NAN)); - assert_eq!(aggregate_delay(100), Duration::ZERO); - assert_eq!(verify_delay(100), Duration::ZERO); - assert_eq!(merge_delay(100), Duration::ZERO); - - reset_for_test(); - } - - #[test] - fn delay_is_zero_when_n_is_zero() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - init(false, Some(100.0), Some(100.0), Some(100.0)); - assert_eq!(aggregate_delay(0), Duration::ZERO); - assert_eq!(verify_delay(0), Duration::ZERO); - assert_eq!(merge_delay(0), Duration::ZERO); - - reset_for_test(); - } - - #[test] - fn delay_is_proportional_to_n_over_rate() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - // rate = 100 units/sec, n = 100 units -> ~1 second. - init(false, Some(100.0), None, None); - let delay = aggregate_delay(100); - assert!( - delay > Duration::from_millis(990) && delay < Duration::from_millis(1010), - "expected ~1s, got {delay:?}" - ); - - reset_for_test(); - } - - #[test] - fn delay_scales_with_operation_count() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - init(false, None, Some(50.0), Some(200.0)); - - // verify: 50 units/sec, 25 units -> ~0.5s. - let verify = verify_delay(25); - assert!( - verify > Duration::from_millis(490) && verify < Duration::from_millis(510), - "expected ~0.5s, got {verify:?}" - ); - - // merge: 200 units/sec, 100 units -> ~0.5s. - let merge = merge_delay(100); - assert!( - merge > Duration::from_millis(490) && merge < Duration::from_millis(510), - "expected ~0.5s, got {merge:?}" - ); - - reset_for_test(); - } - - #[test] - fn fill_fake_proof_produces_requested_length() { - for len in [0usize, 1, 7, 64, 1000] { - let proof = fill_fake_proof(len, &[b"seed"]); - assert_eq!(proof.len(), len); - } - } - - #[test] - fn fill_fake_proof_at_fake_proof_size_fits_bytelist_cap() { - let proof = fill_fake_proof(FAKE_PROOF_SIZE, &[b"seed"]); - assert_eq!(proof.len(), FAKE_PROOF_SIZE); - assert!(proof.len() < 512 * 1024); - } - - #[test] - fn fill_fake_proof_is_deterministic_for_identical_seeds() { - let a = fill_fake_proof(256, &[b"hello", b"world"]); - let b = fill_fake_proof(256, &[b"hello", b"world"]); - assert_eq!(a.iter().as_slice(), b.iter().as_slice()); - } - - #[test] - fn fill_fake_proof_differs_when_a_seed_part_changes() { - let a = fill_fake_proof(256, &[b"hello", b"world"]); - let b = fill_fake_proof(256, &[b"hello", b"there"]); - assert_ne!(a.iter().as_slice(), b.iter().as_slice()); - } - - #[test] - fn fake_xmss_defaults_to_false() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - assert!(!fake_xmss()); - - reset_for_test(); - } - - #[test] - fn fake_xmss_reflects_init() { - let _guard = TEST_LOCK.lock().unwrap(); - reset_for_test(); - - init(true, None, None, None); - assert!(fake_xmss()); - - reset_for_test(); - assert!(!fake_xmss()); - } -} From e9afada8d30efdda6d55741f00c1244c6f6496a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:22:58 -0300 Subject: [PATCH 4/6] refactor(shadow): guard sim-cost sleeps and use zeam's fall-through verify Add a shadow_cost::sleep helper that skips the sleep when the delay is zero (rate unset/disabled), matching zeam's 'if (delay_ns != 0)' guard so a disabled rate costs nothing. Route every prover sleep through it, and restructure verify_aggregated_signature to zeam's single fall-through sleep (run the real verifier unless fake, then one sleep) instead of the early-return two-sleep shape. Behavior is unchanged. --- crates/common/crypto/src/lib.rs | 49 ++++++++++++++----------- crates/common/crypto/src/shadow_cost.rs | 10 +++++ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index fce135c1..08d1a01f 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -177,7 +177,7 @@ pub fn aggregate_signatures( crate::shadow_cost::FAKE_PROOF_SIZE, &[&message.0, &slot_bytes, &count_bytes], ); - std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); return Ok(dummy); } @@ -236,7 +236,7 @@ pub fn aggregate_mixed( parts.push(&count_bytes); let dummy = crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); - std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); return Ok(dummy); } @@ -294,7 +294,7 @@ pub fn aggregate_proofs( } let dummy = crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); - std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); return Ok(dummy); } @@ -336,30 +336,37 @@ pub fn verify_aggregated_signature( #[cfg(feature = "shadow-integration")] let verify_n = public_keys.len(); + // Skip the real verifier under fake-XMSS; otherwise verify for real. In a + // stock build `fake` is always false, so the real path always runs. #[cfg(feature = "shadow-integration")] - if crate::shadow_cost::fake_xmss() { - std::thread::sleep(crate::shadow_cost::verify_delay(verify_n)); - return Ok(()); - } + let fake = crate::shadow_cost::fake_xmss(); + #[cfg(not(feature = "shadow-integration"))] + let fake = false; - ensure_verifier_ready(); + if !fake { + ensure_verifier_ready(); - let lean_pubkeys = into_lean_pubkeys(public_keys); - let sig = LMType1::decompress_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) - .ok_or(VerificationError::DeserializationFailed)?; + let lean_pubkeys = into_lean_pubkeys(public_keys); + let sig = LMType1::decompress_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) + .ok_or(VerificationError::DeserializationFailed)?; - if sig.info.without_pubkeys.message != message.0 || sig.info.without_pubkeys.slot != slot { - return Err(VerificationError::BindingMismatch { - expected_msg: *message, - expected_slot: slot, - got_msg: H256(sig.info.without_pubkeys.message), - got_slot: sig.info.without_pubkeys.slot, - }); + if sig.info.without_pubkeys.message != message.0 || sig.info.without_pubkeys.slot != slot { + return Err(VerificationError::BindingMismatch { + expected_msg: *message, + expected_slot: slot, + got_msg: H256(sig.info.without_pubkeys.message), + got_slot: sig.info.without_pubkeys.slot, + }); + } + + verify_single_message_aggregate(&sig)?; } - verify_single_message_aggregate(&sig)?; + // Model verify cost on the virtual clock (no-op unless a rate is set), + // whether or not the real verifier ran. Mirrors zeam's single fall-through + // sleep in verifyType1. #[cfg(feature = "shadow-integration")] - std::thread::sleep(crate::shadow_cost::verify_delay(verify_n)); + crate::shadow_cost::sleep(crate::shadow_cost::verify_delay(verify_n)); Ok(()) } @@ -395,7 +402,7 @@ pub fn merge_type_1s_into_type_2( parts.push(&count_bytes); let dummy = crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); - std::thread::sleep(crate::shadow_cost::merge_delay(merge_n)); + crate::shadow_cost::sleep(crate::shadow_cost::merge_delay(merge_n)); return Ok(dummy); } diff --git a/crates/common/crypto/src/shadow_cost.rs b/crates/common/crypto/src/shadow_cost.rs index eff36863..d6d913b8 100644 --- a/crates/common/crypto/src/shadow_cost.rs +++ b/crates/common/crypto/src/shadow_cost.rs @@ -87,6 +87,16 @@ pub fn merge_delay(n: usize) -> Duration { compute_delay(&MERGE_RATE, n) } +/// Sleep for a modeled sim-cost `delay`, skipping the sleep entirely when it is +/// zero (rate unset/disabled). Mirrors zeam's `if (delay_ns != 0) sleepNs(...)` +/// guard, so a disabled rate costs nothing — not even a `nanosleep(0)` event on +/// Shadow's virtual clock. +pub fn sleep(delay: Duration) { + if !delay.is_zero() { + std::thread::sleep(delay); + } +} + /// Fixed size of every fake stub proof; well under the 512 KiB /// `ByteList512KiB` wire cap. pub const FAKE_PROOF_SIZE: usize = 32 * 1024; From 1cbbe784cebc66bd83c0a19b2e2b0785ab485bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:48:29 -0300 Subject: [PATCH 5/6] feat(shadow): add --shadow-xmss-fake-proof-size flag (default 32 KiB) Make the fake stub proof size runtime-configurable instead of a hardcoded constant. Adds a --shadow-xmss-fake-proof-size CLI flag (default 32 KiB, bounded to the 512 KiB ByteList512KiB cap so it can't panic), stored in the shadow_cost config atomics and read by every fake-proof call site via fake_proof_size(). DEFAULT_FAKE_PROOF_SIZE remains the shared default. An ethlambda-only capability; zeam hardcodes the 32 KiB constant. --- bin/ethlambda/src/cli.rs | 9 +++++++ bin/ethlambda/src/main.rs | 2 ++ crates/common/crypto/src/lib.rs | 10 ++++---- crates/common/crypto/src/shadow_cost.rs | 32 +++++++++++++++++++------ 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index a6997b6f..6e565bf7 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -130,4 +130,13 @@ pub(crate) struct ShadowOptions { /// Unset or <= 0 disables. #[arg(long)] pub(crate) shadow_xmss_merge_rate: Option, + + /// Shadow sim only: byte length of each fake stub proof. Defaults to 32 + /// KiB; capped at the 512 KiB on-wire proof limit. + #[arg( + long, + default_value_t = ethlambda_crypto::shadow_cost::DEFAULT_FAKE_PROOF_SIZE as u64, + value_parser = clap::value_parser!(u64).range(1..=524_288) + )] + pub(crate) shadow_xmss_fake_proof_size: u64, } diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 8b678190..b75454dc 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -312,6 +312,7 @@ fn init_shadow_cost(shadow: &cli::ShadowOptions) { aggregate_rate = ?shadow.shadow_xmss_aggregate_signatures_rate, verify_rate = ?shadow.shadow_xmss_verify_aggregated_signatures_rate, merge_rate = ?shadow.shadow_xmss_merge_rate, + fake_proof_size = shadow.shadow_xmss_fake_proof_size, "Applying Shadow XMSS sim-cost / fake-XMSS config" ); ethlambda_crypto::shadow_cost::init( @@ -319,6 +320,7 @@ fn init_shadow_cost(shadow: &cli::ShadowOptions) { shadow.shadow_xmss_aggregate_signatures_rate, shadow.shadow_xmss_verify_aggregated_signatures_rate, shadow.shadow_xmss_merge_rate, + shadow.shadow_xmss_fake_proof_size as usize, ); } diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index 08d1a01f..f9703de8 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -174,7 +174,7 @@ pub fn aggregate_signatures( let count_bytes = public_keys.len().to_le_bytes(); let slot_bytes = slot.to_le_bytes(); let dummy = crate::shadow_cost::fill_fake_proof( - crate::shadow_cost::FAKE_PROOF_SIZE, + crate::shadow_cost::fake_proof_size(), &[&message.0, &slot_bytes, &count_bytes], ); crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); @@ -235,7 +235,7 @@ pub fn aggregate_mixed( } parts.push(&count_bytes); let dummy = - crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); + crate::shadow_cost::fill_fake_proof(crate::shadow_cost::fake_proof_size(), &parts); crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); return Ok(dummy); } @@ -293,7 +293,7 @@ pub fn aggregate_proofs( parts.push(proof.iter().as_slice()); } let dummy = - crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); + crate::shadow_cost::fill_fake_proof(crate::shadow_cost::fake_proof_size(), &parts); crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); return Ok(dummy); } @@ -401,7 +401,7 @@ pub fn merge_type_1s_into_type_2( } parts.push(&count_bytes); let dummy = - crate::shadow_cost::fill_fake_proof(crate::shadow_cost::FAKE_PROOF_SIZE, &parts); + crate::shadow_cost::fill_fake_proof(crate::shadow_cost::fake_proof_size(), &parts); crate::shadow_cost::sleep(crate::shadow_cost::merge_delay(merge_n)); return Ok(dummy); } @@ -498,7 +498,7 @@ pub fn split_type_2_by_message( #[cfg(feature = "shadow-integration")] if crate::shadow_cost::fake_xmss() { return Ok(crate::shadow_cost::fill_fake_proof( - crate::shadow_cost::FAKE_PROOF_SIZE, + crate::shadow_cost::fake_proof_size(), &[proof_data, &message.0], )); } diff --git a/crates/common/crypto/src/shadow_cost.rs b/crates/common/crypto/src/shadow_cost.rs index d6d913b8..0411abb9 100644 --- a/crates/common/crypto/src/shadow_cost.rs +++ b/crates/common/crypto/src/shadow_cost.rs @@ -1,7 +1,7 @@ //! Shadow-simulator sim-cost + fake-proof backend. Compiled only under the //! `shadow-integration` feature. Ported from zeam's shadow_cost.zig. -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::Duration; use ethlambda_types::block::ByteList512KiB; @@ -19,6 +19,8 @@ static FAKE_ENABLED: AtomicBool = AtomicBool::new(false); static AGG_RATE: AtomicU64 = AtomicU64::new(0); static VERIFY_RATE: AtomicU64 = AtomicU64::new(0); static MERGE_RATE: AtomicU64 = AtomicU64::new(0); +/// Byte length of each fake stub proof; see `DEFAULT_FAKE_PROOF_SIZE`. +static FAKE_PROOF_SIZE: AtomicUsize = AtomicUsize::new(DEFAULT_FAKE_PROOF_SIZE); /// Convert an optional rate into the bit pattern stored in the atomic. /// @@ -39,12 +41,21 @@ fn rate_bits(v: Option) -> u64 { /// `fake` switches the prover/verifier to the deterministic stub backend. /// `agg`/`verify`/`merge` are the modeled operation rates (units per /// second) used to compute sim-cost sleeps; `None` (or a non-finite / -/// non-positive value) disables the sleep for that operation. -pub fn init(fake: bool, agg: Option, verify: Option, merge: Option) { +/// non-positive value) disables the sleep for that operation. `proof_size` +/// is the byte length of each fake stub proof (callers must keep it within +/// the `ByteList512KiB` cap). +pub fn init( + fake: bool, + agg: Option, + verify: Option, + merge: Option, + proof_size: usize, +) { FAKE_ENABLED.store(fake, Ordering::Relaxed); AGG_RATE.store(rate_bits(agg), Ordering::Relaxed); VERIFY_RATE.store(rate_bits(verify), Ordering::Relaxed); MERGE_RATE.store(rate_bits(merge), Ordering::Relaxed); + FAKE_PROOF_SIZE.store(proof_size, Ordering::Relaxed); } /// Whether the fake-XMSS stub backend is active. @@ -97,9 +108,16 @@ pub fn sleep(delay: Duration) { } } -/// Fixed size of every fake stub proof; well under the 512 KiB -/// `ByteList512KiB` wire cap. -pub const FAKE_PROOF_SIZE: usize = 32 * 1024; +/// Default byte length of each fake stub proof (32 KiB); overridable at +/// startup via `init`. Well under the 512 KiB `ByteList512KiB` wire cap. +pub const DEFAULT_FAKE_PROOF_SIZE: usize = 32 * 1024; + +/// The configured fake stub proof size in bytes. Defaults to +/// `DEFAULT_FAKE_PROOF_SIZE`; set once via `init` and bounded by the CLI to the +/// `ByteList512KiB` cap. +pub fn fake_proof_size() -> usize { + FAKE_PROOF_SIZE.load(Ordering::Relaxed) +} /// Produce a deterministic `len`-byte stub proof derived only from /// `seed_parts`. @@ -140,5 +158,5 @@ pub fn fill_fake_proof(len: usize, seed_parts: &[&[u8]]) -> ByteList512KiB { bytes.extend_from_slice(&chunk[..remaining.min(chunk.len())]); } - ByteList512KiB::try_from(bytes).expect("FAKE_PROOF_SIZE fits ByteList512KiB cap") + ByteList512KiB::try_from(bytes).expect("fake proof size must not exceed the ByteList512KiB cap") } From 5b1238b048c82ef44c93ac621bd8ca0e21ca5ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:33:37 -0300 Subject: [PATCH 6/6] refactor: remove sleep after real crypto usage --- crates/common/crypto/src/lib.rs | 72 +++++++++++---------------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index f9703de8..4e081445 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -166,11 +166,9 @@ pub fn aggregate_signatures( return Err(AggregationError::EmptyInput); } - #[cfg(feature = "shadow-integration")] - let agg_n = public_keys.len(); - #[cfg(feature = "shadow-integration")] if crate::shadow_cost::fake_xmss() { + let agg_n = public_keys.len(); let count_bytes = public_keys.len().to_le_bytes(); let slot_bytes = slot.to_le_bytes(); let dummy = crate::shadow_cost::fill_fake_proof( @@ -193,8 +191,6 @@ pub fn aggregate_signatures( .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; let result = compress_type1_to_byte_list(&proof)?; - #[cfg(feature = "shadow-integration")] - std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); Ok(result) } @@ -222,11 +218,9 @@ pub fn aggregate_mixed( return Err(AggregationError::InsufficientChildren(children.len())); } - #[cfg(feature = "shadow-integration")] - let agg_n = raw_public_keys.len(); - #[cfg(feature = "shadow-integration")] if crate::shadow_cost::fake_xmss() { + let agg_n = raw_public_keys.len(); let count_bytes = raw_public_keys.len().to_le_bytes(); let slot_bytes = slot.to_le_bytes(); let mut parts: Vec<&[u8]> = vec![&message.0, &slot_bytes]; @@ -264,8 +258,6 @@ pub fn aggregate_mixed( .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; let result = compress_type1_to_byte_list(&proof)?; - #[cfg(feature = "shadow-integration")] - std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); Ok(result) } @@ -282,11 +274,9 @@ pub fn aggregate_proofs( return Err(AggregationError::InsufficientChildren(children.len())); } - #[cfg(feature = "shadow-integration")] - let agg_n = children.len(); - #[cfg(feature = "shadow-integration")] if crate::shadow_cost::fake_xmss() { + let agg_n = children.len(); let slot_bytes = slot.to_le_bytes(); let mut parts: Vec<&[u8]> = vec![&message.0, &slot_bytes]; for (_, proof) in &children { @@ -316,8 +306,6 @@ pub fn aggregate_proofs( .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; let result = compress_type1_to_byte_list(&proof)?; - #[cfg(feature = "shadow-integration")] - std::thread::sleep(crate::shadow_cost::aggregate_delay(agg_n)); Ok(result) } @@ -333,40 +321,30 @@ pub fn verify_aggregated_signature( message: &H256, slot: u32, ) -> Result<(), VerificationError> { + // Skip the real verifier under fake-XMSS; otherwise verify for real. #[cfg(feature = "shadow-integration")] - let verify_n = public_keys.len(); - - // Skip the real verifier under fake-XMSS; otherwise verify for real. In a - // stock build `fake` is always false, so the real path always runs. - #[cfg(feature = "shadow-integration")] - let fake = crate::shadow_cost::fake_xmss(); - #[cfg(not(feature = "shadow-integration"))] - let fake = false; - - if !fake { - ensure_verifier_ready(); - - let lean_pubkeys = into_lean_pubkeys(public_keys); - let sig = LMType1::decompress_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) - .ok_or(VerificationError::DeserializationFailed)?; + if crate::shadow_cost::fake_xmss() { + let verify_n = public_keys.len(); + // Model verify cost on the virtual clock (no-op unless a rate is set). + crate::shadow_cost::sleep(crate::shadow_cost::verify_delay(verify_n)); + return Ok(()); + } + ensure_verifier_ready(); - if sig.info.without_pubkeys.message != message.0 || sig.info.without_pubkeys.slot != slot { - return Err(VerificationError::BindingMismatch { - expected_msg: *message, - expected_slot: slot, - got_msg: H256(sig.info.without_pubkeys.message), - got_slot: sig.info.without_pubkeys.slot, - }); - } + let lean_pubkeys = into_lean_pubkeys(public_keys); + let sig = LMType1::decompress_without_pubkeys(proof_data.iter().as_slice(), lean_pubkeys) + .ok_or(VerificationError::DeserializationFailed)?; - verify_single_message_aggregate(&sig)?; + if sig.info.without_pubkeys.message != message.0 || sig.info.without_pubkeys.slot != slot { + return Err(VerificationError::BindingMismatch { + expected_msg: *message, + expected_slot: slot, + got_msg: H256(sig.info.without_pubkeys.message), + got_slot: sig.info.without_pubkeys.slot, + }); } - // Model verify cost on the virtual clock (no-op unless a rate is set), - // whether or not the real verifier ran. Mirrors zeam's single fall-through - // sleep in verifyType1. - #[cfg(feature = "shadow-integration")] - crate::shadow_cost::sleep(crate::shadow_cost::verify_delay(verify_n)); + verify_single_message_aggregate(&sig)?; Ok(()) } @@ -389,11 +367,9 @@ pub fn merge_type_1s_into_type_2( return Err(AggregationError::EmptyInput); } - #[cfg(feature = "shadow-integration")] - let merge_n = type_1s.len(); - #[cfg(feature = "shadow-integration")] if crate::shadow_cost::fake_xmss() { + let merge_n = type_1s.len(); let count_bytes = type_1s.len().to_le_bytes(); let mut parts: Vec<&[u8]> = Vec::with_capacity(type_1s.len() + 1); for (_, proof) in &type_1s { @@ -418,8 +394,6 @@ pub fn merge_type_1s_into_type_2( .map_err(|err| AggregationError::ProverFailure(err.to_string()))?; let result = compress_type2_to_byte_list(&merged)?; - #[cfg(feature = "shadow-integration")] - std::thread::sleep(crate::shadow_cost::merge_delay(merge_n)); Ok(result) }