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 534cacf3..4bbe1683 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -107,4 +107,46 @@ pub(crate) struct CliOptions { /// `on_block`. #[arg(long, default_value = "3")] pub(crate) max_attestations_per_block: usize, + /// 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, + + /// 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 914c6c86..b933b928 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -80,6 +80,9 @@ async fn main() -> eyre::Result<()> { let options = CliOptions::parse(); + #[cfg(feature = "shadow-integration")] + init_shadow_cost(&options.shadow); + // Initialize metrics ethlambda_blockchain::metrics::init(); ethlambda_blockchain::metrics::set_node_info("ethlambda", version::CLIENT_VERSION); @@ -301,6 +304,30 @@ 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, + fake_proof_size = shadow.shadow_xmss_fake_proof_size, + "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, + shadow.shadow_xmss_fake_proof_size as usize, + ); +} + /// Boot the binary in Hive test-driver mode. /// /// Skips every consensus/p2p subsystem and just exposes the 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..4e081445 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,19 @@ pub fn aggregate_signatures( return Err(AggregationError::EmptyInput); } + #[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( + crate::shadow_cost::fake_proof_size(), + &[&message.0, &slot_bytes, &count_bytes], + ); + crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + return Ok(dummy); + } + ensure_prover_ready(); let raw_xmss: Vec<(LeanSigPubKey, LeanSigSignature)> = public_keys @@ -174,7 +190,8 @@ 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)?; + Ok(result) } /// Aggregate both existing Type-1 proofs (children) and raw XMSS signatures. @@ -201,6 +218,22 @@ pub fn aggregate_mixed( return Err(AggregationError::InsufficientChildren(children.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]; + 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); + crate::shadow_cost::sleep(crate::shadow_cost::aggregate_delay(agg_n)); + return Ok(dummy); + } + ensure_prover_ready(); let children_native: Vec = children @@ -224,7 +257,8 @@ 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)?; + Ok(result) } /// Recursively aggregate two or more already-aggregated Type-1 proofs into one. @@ -240,6 +274,20 @@ pub fn aggregate_proofs( return Err(AggregationError::InsufficientChildren(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 { + parts.push(proof.iter().as_slice()); + } + let dummy = + 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); + } + ensure_prover_ready(); let children_native: Vec = children @@ -257,7 +305,8 @@ 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)?; + Ok(result) } /// Verify a Type-1 aggregated signature proof. @@ -272,6 +321,14 @@ 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")] + 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(); let lean_pubkeys = into_lean_pubkeys(public_keys); @@ -310,6 +367,21 @@ pub fn merge_type_1s_into_type_2( return Err(AggregationError::EmptyInput); } + #[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 { + 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); + crate::shadow_cost::sleep(crate::shadow_cost::merge_delay(merge_n)); + return Ok(dummy); + } + ensure_prover_ready(); let type_1s_native: Vec = type_1s @@ -321,7 +393,8 @@ 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)?; + Ok(result) } /// Verify a Type-2 merged proof against the per-component expected bindings. @@ -341,6 +414,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 +469,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 diff --git a/crates/common/crypto/src/shadow_cost.rs b/crates/common/crypto/src/shadow_cost.rs new file mode 100644 index 00000000..0411abb9 --- /dev/null +++ b/crates/common/crypto/src/shadow_cost.rs @@ -0,0 +1,162 @@ +//! 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, AtomicUsize, 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); +/// 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. +/// +/// 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. `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. +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) +} + +/// 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); + } +} + +/// 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`. +/// +/// # 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 must not exceed the ByteList512KiB cap") +}