diff --git a/light-base/src/database.rs b/light-base/src/database.rs index f1981062fa..ddcba38135 100644 --- a/light-base/src/database.rs +++ b/light-base/src/database.rs @@ -62,6 +62,13 @@ pub struct DatabaseContent { /// Does **not** necessarily match the finalized block found in /// [`DatabaseContent::chain_information`]. pub runtime_code_hint: Option, + + /// Raw bytes of the runtime code (`:code` storage value) saved from the last session. + /// + /// When present, a parachain warm-start can compile this code locally and verify + /// it against the network via lightweight Aura call proofs, skipping the ~2 MiB + /// P2P runtime download. + pub runtime_code: Option>, } /// See [`DatabaseContent::runtime_code_hint`]. @@ -207,14 +214,24 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result>(); + // Decode the runtime code storage value (base64) once. Both `runtime_code_hint` + // and `runtime_code` derive from it, avoiding a redundant 2 MiB clone. + let decoded_code = decoded + .code_storage_value + .as_ref() + .map(|sv| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, sv) + .map_err(|_| ()) + }) + .transpose()?; + let runtime_code_hint = match ( decoded.code_merkle_value, - decoded.code_storage_value, + &decoded_code, decoded.code_closest_ancestor_excluding, ) { - (Some(mv), Some(sv), Some(an)) => Some(DatabaseContentRuntimeCodeHint { - code: base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, sv) - .map_err(|_| ())?, + (Some(mv), Some(code), Some(an)) => Some(DatabaseContentRuntimeCodeHint { + code: code.clone(), code_merkle_value: hex::decode(mv).map_err(|_| ())?, closest_ancestor_excluding: an .as_bytes() @@ -222,8 +239,8 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result, ()>>()?, }), - // A combination of `Some` and `None` is technically invalid, but we simply ignore this - // situation. + // A combination of `Some` and `None` is technically invalid, but we simply + // ignore this situation. _ => None, }; @@ -232,6 +249,7 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result, } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_db_json(fields: &[(&str, &str)]) -> String { + let genesis = format!(r#""genesisHash":"{}""#, "aa".repeat(32)); + let nodes = r#""nodes":{}"#; + let extras: Vec = fields + .iter() + .map(|(k, v)| format!(r#""{k}":{v}"#)) + .collect(); + let mut parts = vec![genesis, nodes.to_string()]; + parts.extend(extras); + format!("{{{}}}", parts.join(",")) + } + + #[test] + fn decode_database_without_runtime_code() { + let json = make_db_json(&[]); + let db = decode_database(&json, 4).unwrap(); + assert!(db.runtime_code.is_none()); + assert!(db.runtime_code_hint.is_none()); + } + + #[test] + fn decode_database_with_runtime_code_only() { + let code_bytes = b"\x00asm\x01\x00\x00\x00"; + let encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD_NO_PAD, + code_bytes, + ); + let json = make_db_json(&[("runtimeCode", &format!(r#""{encoded}""#))]); + let db = decode_database(&json, 4).unwrap(); + assert_eq!(db.runtime_code.as_deref(), Some(code_bytes.as_slice())); + assert!(db.runtime_code_hint.is_none()); + } + + #[test] + fn decode_database_with_full_hint_populates_both() { + let code_bytes = b"\x00asm\x01\x00\x00\x00"; + let encoded_code = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD_NO_PAD, + code_bytes, + ); + let merkle_hex = "ab".repeat(32); + let ancestor_nibbles = "3a636f"; + let json = make_db_json(&[ + ("runtimeCode", &format!(r#""{encoded_code}""#)), + ("codeMerkleValue", &format!(r#""{merkle_hex}""#)), + ("codeClosestAncestor", &format!(r#""{ancestor_nibbles}""#)), + ]); + let db = decode_database(&json, 4).unwrap(); + assert_eq!(db.runtime_code.as_deref(), Some(code_bytes.as_slice())); + assert!(db.runtime_code_hint.is_some()); + let hint = db.runtime_code_hint.unwrap(); + assert_eq!(hint.code, code_bytes); + assert_eq!(hint.code_merkle_value, hex::decode(&merkle_hex).unwrap()); + } + + #[test] + fn decode_database_invalid_base64_runtime_code_returns_error() { + let json = make_db_json(&[("runtimeCode", r#""not-valid-base64!!!""#)]); + assert!(decode_database(&json, 4).is_err()); + } + + #[test] + fn encode_shrink_drops_runtime_code_when_too_large() { + let large_code = "A".repeat(10_000); + let db = SerdeDatabase { + genesis_hash: "aa".repeat(32), + chain: None, + nodes: Default::default(), + code_storage_value: Some(large_code), + code_merkle_value: Some("bb".repeat(32)), + code_closest_ancestor_excluding: None, + }; + let serialized = serde_json::to_string(&db).unwrap(); + assert!(serialized.len() > 1000); + + let mut shrunk = db; + shrunk.code_storage_value = None; + shrunk.code_merkle_value = None; + let shrunk_serialized = serde_json::to_string(&shrunk).unwrap(); + assert!(shrunk_serialized.len() < 200); + + let decoded = decode_database(&shrunk_serialized, 4).unwrap(); + assert!(decoded.runtime_code.is_none()); + } +} diff --git a/light-base/src/lib.rs b/light-base/src/lib.rs index 86137c3a64..1f3162437e 100644 --- a/light-base/src/lib.rs +++ b/light-base/src/lib.rs @@ -454,9 +454,15 @@ impl Client { // Load the information about the chain. If a light sync state (also known as a checkpoint) // is present in the chain spec, it is possible to start syncing at the finalized block // it describes. - // At the same time, we deconstruct the database into `known_nodes` - // and `runtime_code_hint`. - let (chain_information, used_database_chain_information, known_nodes, runtime_code_hint) = { + // At the same time, we deconstruct the database into `known_nodes`, + // `runtime_code_hint`, and `saved_runtime_code`. + let ( + chain_information, + used_database_chain_information, + known_nodes, + runtime_code_hint, + saved_runtime_code, + ) = { let checkpoint = chain_spec .light_sync_state() .map(|s| s.to_chain_information()); @@ -471,12 +477,19 @@ impl Client { chain_information: Some(db_ci), known_nodes, runtime_code_hint, + runtime_code, .. }), ) if db_ci.as_ref().finalized_block_header.number >= checkpoint.as_ref().finalized_block_header.number => { - (Some(db_ci), true, known_nodes, runtime_code_hint) + ( + Some(db_ci), + true, + known_nodes, + runtime_code_hint, + runtime_code, + ) } // Otherwise, use the chain spec checkpoint. @@ -486,10 +499,19 @@ impl Client { Some(database::DatabaseContent { known_nodes, runtime_code_hint, + runtime_code, .. }), - ) => (Some(checkpoint), false, known_nodes, runtime_code_hint), - (_, Some(Ok(checkpoint)), None) => (Some(checkpoint), false, Vec::new(), None), + ) => ( + Some(checkpoint), + false, + known_nodes, + runtime_code_hint, + runtime_code, + ), + (_, Some(Ok(checkpoint)), None) => { + (Some(checkpoint), false, Vec::new(), None, None) + } // If neither the genesis chain information nor the checkpoint chain information // is available, we could in principle use the database, but for API reasons we @@ -501,10 +523,11 @@ impl Client { Some(database::DatabaseContent { known_nodes, runtime_code_hint, + runtime_code, .. }), - ) => (None, false, known_nodes, runtime_code_hint), - (None, None, None) => (None, false, Vec::new(), None), + ) => (None, false, known_nodes, runtime_code_hint, runtime_code), + (None, None, None) => (None, false, Vec::new(), None, None), // Use the genesis block if no checkpoint is available. ( @@ -516,9 +539,16 @@ impl Client { Some(database::DatabaseContent { known_nodes, runtime_code_hint, + runtime_code, .. }), - ) => (Some(genesis_ci), false, known_nodes, runtime_code_hint), + ) => ( + Some(genesis_ci), + false, + known_nodes, + runtime_code_hint, + runtime_code, + ), ( Some(genesis_ci), None @@ -526,7 +556,7 @@ impl Client { chain_spec::CheckpointToChainInformationError::GenesisBlockCheckpoint, )), None, - ) => (Some(genesis_ci), false, Vec::new(), None), + ) => (Some(genesis_ci), false, Vec::new(), None, None), // If the checkpoint format is invalid, we return an error no matter whether the // genesis chain information could be used. @@ -706,10 +736,24 @@ impl Client { ); let config = match (&relay_chain, &chain_information) { - (Some((relay_chain, para_id, _)), _) => StartServicesChainTy::Parachain { - relay_chain, - para_id: *para_id, - }, + (Some((relay_chain, para_id, _)), _) => { + if let Some(code) = &saved_runtime_code { + log!( + &self.platform, + Debug, + "smoldot", + format!( + "Parachain warm-start available: cached runtime={}KB", + code.len() / 1024, + ) + ); + } + StartServicesChainTy::Parachain { + relay_chain, + para_id: *para_id, + saved_runtime_code: saved_runtime_code.clone(), + } + } (None, Some(chain_information)) => { StartServicesChainTy::SubstrateCompatible { chain_information } } @@ -1117,6 +1161,7 @@ enum StartServicesChainTy<'a, TPlat: platform::PlatformRef> { Parachain { relay_chain: &'a ChainServices, para_id: u32, + saved_runtime_code: Option>, }, } @@ -1185,6 +1230,7 @@ fn start_services( StartServicesChainTy::Parachain { relay_chain, para_id, + saved_runtime_code, } => { // Chain is a parachain. @@ -1202,6 +1248,7 @@ fn start_services( para_id, relay_chain_sync: relay_chain.runtime_service.clone(), }, + saved_runtime_code, }, ), })); diff --git a/light-base/src/sync_service.rs b/light-base/src/sync_service.rs index 087dbcfb36..ccd2dc97c2 100644 --- a/light-base/src/sync_service.rs +++ b/light-base/src/sync_service.rs @@ -108,6 +108,13 @@ pub struct ConfigSubstrateCompatibleRuntimeCodeHint { pub struct ConfigParachain { /// Parameters of the relay chain. pub relay_chain: ConfigRelayChain, + + /// Raw bytes of the runtime (`:code` storage value) saved in the database. + /// + /// When `Some`, `start_parachain` compiles this code locally and verifies it + /// against the network via lightweight Aura call proofs, skipping the ~2 MiB + /// P2P download. Falls back to cold bootstrap on any failure. + pub saved_runtime_code: Option>, } /// See [`ConfigParachain::relay_chain`]. @@ -154,6 +161,7 @@ impl SyncService { config_parachain.relay_chain.para_id, from_foreground, config.network_service.clone(), + config_parachain.saved_runtime_code, )), ConfigChainType::SubstrateCompatible(config_substrate_compat) => { Box::pin(substrate_compat::start_substrate_compatible_chain( diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 9a9dda8272..2ba7416457 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -47,6 +47,7 @@ pub(super) async fn start_parachain( parachain_id: u32, mut from_foreground: Pin>>, network_service: Arc>, + saved_runtime_code: Option>, ) { // Phase 1: Fetch the current finalized parachain head from the relay chain. let effective_chain_info = fetch_parachain_head_from_relay( @@ -68,29 +69,53 @@ pub(super) async fn start_parachain( ) ); - // Phase 2: Download the parachain runtime from a P2P peer and determine Aura - // consensus parameters. Retries indefinitely until successful. - let (effective_chain_info, finalized_runtime) = loop { - match bootstrap_parachain_consensus( + // Phase 2: Obtain Aura consensus parameters and the compiled runtime. + // + // Warm path: if the database contains cached runtime code, anchor the cached bytes to + // the on-chain state root via a `:code\0` absence proof (the absence proof traverses + // `:code`'s leaf without bundling its 2 MiB value), then compile and run the Aura call + // proofs against state. Skips the ~2 MiB `:code` P2P download. + // + // Cold path: download `:code` + `:heappages` (~2 MiB), compile, then fetch Aura call + // proofs to determine consensus parameters. + let (effective_chain_info, finalized_runtime) = if let Some(code) = saved_runtime_code { + match warm_bootstrap( &log_target, &platform, &network_service, &effective_chain_info, block_number_bytes, + code, ) .await { - Ok(b) => break (b.chain_info, b.finalized_runtime), + Ok(b) => (b.chain_info, b.finalized_runtime), Err(err) => { log!( &platform, Warn, &log_target, - format!("Failed to bootstrap parachain consensus: {err}. Retrying in 5s...") + format!("Warm start failed ({err}), falling back to cold bootstrap...") ); - platform.sleep(Duration::from_secs(5)).await; + cold_bootstrap_loop( + &log_target, + &platform, + &network_service, + &effective_chain_info, + block_number_bytes, + ) + .await } } + } else { + cold_bootstrap_loop( + &log_target, + &platform, + &network_service, + &effective_chain_info, + block_number_bytes, + ) + .await }; // Phase 3: Spawn the paraheads background service that tracks relay chain @@ -1105,6 +1130,203 @@ impl Task { } } +/// Retries `bootstrap_parachain_consensus` indefinitely with a 5s back-off. +/// +/// Returns `(chain_info, finalized_runtime)` once a successful bootstrap completes. +async fn cold_bootstrap_loop( + log_target: &str, + platform: &TPlat, + network_service: &Arc>, + chain_info: &chain::chain_information::ValidChainInformation, + block_number_bytes: usize, +) -> ( + chain::chain_information::ValidChainInformation, + FinalizedBlockRuntime, +) { + loop { + match bootstrap_parachain_consensus( + log_target, + platform, + network_service, + chain_info, + block_number_bytes, + ) + .await + { + Ok(b) => return (b.chain_info, b.finalized_runtime), + Err(err) => { + log!( + platform, + Warn, + log_target, + format!("Failed to bootstrap parachain consensus: {err}. Retrying in 5s...") + ); + platform.sleep(Duration::from_secs(5)).await; + } + } + } +} + +/// Probe key for the warm-start `:code` anchor. A strict descendant of `:code` that doesn't +/// exist on chain — its absence proof must traverse `:code`'s leaf, exposing the value-hash +/// (state v1) without bundling the 2 MiB runtime value. +const CODE_ANCHOR_PROBE_KEY: &[u8] = b":code\0"; + +/// Warm-start: compile cached runtime code locally, then verify against the +/// network by fetching `:heappages` and Aura call proofs (~few KB total). +/// Skips the ~2 MiB `:code` P2P download. +async fn warm_bootstrap( + log_target: &str, + platform: &TPlat, + network_service: &Arc>, + chain_info: &chain::chain_information::ValidChainInformation, + block_number_bytes: usize, + code: Vec, +) -> Result { + let ci_ref = chain_info.as_ref(); + let state_root = *ci_ref.finalized_block_header.state_root; + let block_hash = ci_ref.finalized_block_header.hash(block_number_bytes); + + log!( + platform, + Info, + log_target, + format!( + "Warm-starting parachain at block #{} ({}) using {} bytes of cached runtime", + ci_ref.finalized_block_header.number, + HashDisplay(&block_hash), + code.len() + ) + ); + + let peer_id = wait_for_peer(network_service).await; + + // Fetch the `:code` anchor probe + `:heappages` storage proof and both Aura call proofs + // in parallel. See `CODE_ANCHOR_PROBE_KEY` for why we probe an absent descendant of + // `:code` rather than `:code` itself. + let (code_hp_proof, slot_duration_proof, authorities_proof) = future::try_join3( + async { + network_service + .clone() + .storage_proof_request( + peer_id.clone(), + codec::StorageProofRequestConfig { + block_hash, + keys: [CODE_ANCHOR_PROBE_KEY, &b":heappages"[..]].into_iter(), + }, + Duration::from_secs(16), + ) + .await + .map_err(|e| format!("Storage proof request failed: {e}")) + }, + fetch_call_proof( + network_service, + &peer_id, + block_hash, + "AuraApi_slot_duration", + ), + fetch_call_proof(network_service, &peer_id, block_hash, "AuraApi_authorities"), + ) + .await?; + + let proof_bytes = code_hp_proof.decode().to_vec(); + let proof_byte_len = proof_bytes.len(); + let decoded_proof = trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { + proof: proof_bytes, + }) + .map_err(|e| format!("Failed to decode storage proof: {e}"))?; + + // Anchor the cached runtime to the finalized state root. For state v1 (the design target) + // the proof exposes only `:code`'s blake2_256 value-hash via `HashKnownValueMissing`, and + // we hash the cached bytes to compare. The `Known` arm is a defensive fallback for state + // v0 chains or peers that bundle the value despite the probe key targeting absence — we + // still verify, just without the bandwidth saving. Any mismatch returns Err and the + // caller falls back to cold bootstrap. + let code_info = decoded_proof + .trie_node_info( + &state_root, + trie::bytes_to_nibbles(b":code".iter().copied()), + ) + .map_err(|_| String::from("Proof missing :code path"))?; + match code_info.storage_value { + trie::proof_decode::StorageValue::HashKnownValueMissing(on_chain_hash) => { + log!( + platform, + Debug, + log_target, + format!( + "Warm-start :code anchor: branch=HashKnownValueMissing proof_bytes={}", + proof_byte_len, + ) + ); + let computed = blake2_rfc::blake2b::blake2b(32, &[], &code); + if computed.as_bytes() != &on_chain_hash[..] { + return Err(String::from( + "cached :code hash does not match on-chain :code hash", + )); + } + } + trie::proof_decode::StorageValue::Known { value, inline: _ } => { + log!( + platform, + Warn, + log_target, + format!( + "Warm-start :code anchor: branch=Known proof_bytes={}, unexpected branch hit", + proof_byte_len, + ) + ); + if value != code.as_slice() { + return Err(String::from("cached :code does not match on-chain bytes")); + } + } + trie::proof_decode::StorageValue::None => { + return Err(String::from(":code missing in on-chain state")); + } + } + + let heap_pages_raw = decoded_proof + .storage_value(&state_root, b":heappages") + .map_err(|_| String::from("Proof doesn't contain :heappages"))?; + let storage_heap_pages = heap_pages_raw.map(|(v, _)| v.to_vec()); + + let heap_pages = executor::storage_heap_pages_to_value(storage_heap_pages.as_deref()) + .map_err(|e| format!("Invalid :heappages value: {e}"))?; + + // Compile cached code with correct heap pages. + let vm = executor::host::HostVmPrototype::new(executor::host::Config { + module: &code, + heap_pages, + exec_hint: executor::vm::ExecHint::CompileWithNonDeterministicValidation, + allow_unresolved_imports: true, + }) + .map_err(|e| format!("Failed to compile cached runtime: {e}"))?; + + // Verify Aura consensus parameters against the network. + let (slot_duration, authorities, vm) = + run_aura_calls(vm, slot_duration_proof, authorities_proof, &state_root)?; + + log!( + platform, + Info, + log_target, + format!( + "Warm start verified: Aura consensus (slot_duration={}ms, authorities={})", + slot_duration, + authorities.len() + ) + ); + + build_bootstrapped_parachain( + chain_info, + slot_duration, + authorities, + vm, + code, + storage_heap_pages, + ) +} + // Fetch the included parachain head from a finalized relay chain block. async fn fetch_parachain_head_from_relay( log_target: &str, @@ -1232,7 +1454,8 @@ struct BootstrappedParachain { finalized_runtime: FinalizedBlockRuntime, } -/// Downloads the parachain runtime from a P2P peer and determines Aura consensus parameters. +/// Cold bootstrap: downloads `:code` + `:heappages` from a P2P peer, compiles the +/// runtime, and verifies Aura consensus parameters via call proofs. async fn bootstrap_parachain_consensus( log_target: &str, platform: &TPlat, @@ -1255,24 +1478,7 @@ async fn bootstrap_parachain_consensus( ) ); - // Wait for a peer to connect. - let peer_id = { - let mut from_network = Box::pin(network_service.subscribe().await); - - if let Some(peer) = network_service.peers_list().await.next() { - peer - } else { - loop { - match from_network.next().await { - Some(network_service::Event::Connected { peer_id, .. }) => break peer_id, - Some(_) => continue, - None => { - from_network = Box::pin(network_service.subscribe().await); - } - } - } - } - }; + let peer_id = wait_for_peer(network_service).await; log!( platform, @@ -1334,73 +1540,19 @@ async fn bootstrap_parachain_consensus( }) .map_err(|e| format!("Failed to compile runtime: {e}"))?; - // AuraApi_slot_duration - let (slot_duration, vm) = { - let call_proof = network_service - .clone() - .call_proof_request( - peer_id.clone(), - codec::CallProofRequestConfig { - block_hash, - method: Cow::Borrowed("AuraApi_slot_duration"), - parameter_vectored: iter::empty::>(), - }, - Duration::from_secs(16), - ) - .await - .map_err(|e| format!("AuraApi_slot_duration call proof request failed: {e}"))?; - - let decoded_call_proof = - trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { - proof: call_proof.decode().to_vec(), - }) - .map_err(|e| format!("Failed to decode slot_duration call proof: {e}"))?; - - let (output, vm) = run_single_runtime_call( - vm, - "AuraApi_slot_duration", - &decoded_call_proof, - &state_root, - )?; - - let duration = <[u8; 8]>::try_from(output.as_slice()) - .ok() - .and_then(|b| NonZero::::new(u64::from_le_bytes(b))) - .ok_or_else(|| String::from("Failed to decode AuraApi_slot_duration output"))?; - (duration, vm) - }; - - // AuraApi_authorities - let (authorities, vm) = { - let call_proof = network_service - .clone() - .call_proof_request( - peer_id, - codec::CallProofRequestConfig { - block_hash, - method: Cow::Borrowed("AuraApi_authorities"), - parameter_vectored: iter::empty::>(), - }, - Duration::from_secs(16), - ) - .await - .map_err(|e| format!("AuraApi_authorities call proof request failed: {e}"))?; - - let decoded_call_proof = - trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { - proof: call_proof.decode().to_vec(), - }) - .map_err(|e| format!("Failed to decode authorities call proof: {e}"))?; - - let (output, vm) = - run_single_runtime_call(vm, "AuraApi_authorities", &decoded_call_proof, &state_root)?; + // Fetch Aura call proofs and verify consensus parameters. + let slot_duration_proof = fetch_call_proof( + network_service, + &peer_id, + block_hash, + "AuraApi_slot_duration", + ) + .await?; + let authorities_proof = + fetch_call_proof(network_service, &peer_id, block_hash, "AuraApi_authorities").await?; - let auths = header::AuraAuthoritiesIter::decode(&output) - .map_err(|_| String::from("Failed to decode AuraApi_authorities output"))? - .map(header::AuraAuthority::from) - .collect::>(); - (auths, vm) - }; + let (slot_duration, authorities, vm) = + run_aura_calls(vm, slot_duration_proof, authorities_proof, &state_root)?; log!( platform, @@ -1413,6 +1565,113 @@ async fn bootstrap_parachain_consensus( ) ); + build_bootstrapped_parachain( + chain_info, + slot_duration, + authorities, + vm, + code, + storage_heap_pages, + ) +} + +/// Waits for at least one peer to be connected, returning its ID. +async fn wait_for_peer( + network_service: &Arc>, +) -> libp2p::PeerId { + let mut from_network = Box::pin(network_service.subscribe().await); + if let Some(peer) = network_service.peers_list().await.next() { + return peer; + } + loop { + match from_network.next().await { + Some(network_service::Event::Connected { peer_id, .. }) => return peer_id, + Some(_) => continue, + None => { + from_network = Box::pin(network_service.subscribe().await); + } + } + } +} + +/// Fetches a single call proof from a peer. +async fn fetch_call_proof( + network_service: &Arc>, + peer_id: &libp2p::PeerId, + block_hash: [u8; 32], + method: &str, +) -> Result { + network_service + .clone() + .call_proof_request( + peer_id.clone(), + codec::CallProofRequestConfig { + block_hash, + method: Cow::Borrowed(method), + parameter_vectored: iter::empty::>(), + }, + Duration::from_secs(16), + ) + .await + .map_err(|e| format!("{method} call proof request failed: {e}")) +} + +/// Decodes Aura call proofs and executes them against a compiled VM. +/// Returns (slot_duration, authorities, vm). +fn run_aura_calls( + vm: executor::host::HostVmPrototype, + slot_duration_proof: network_service::EncodedMerkleProof, + authorities_proof: network_service::EncodedMerkleProof, + state_root: &[u8; 32], +) -> Result< + ( + NonZero, + Vec, + executor::host::HostVmPrototype, + ), + String, +> { + let decoded_sd_proof = + trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { + proof: slot_duration_proof.decode().to_vec(), + }) + .map_err(|e| format!("Failed to decode slot_duration call proof: {e}"))?; + + let (sd_output, vm) = + run_single_runtime_call(vm, "AuraApi_slot_duration", &decoded_sd_proof, state_root)?; + + let slot_duration = <[u8; 8]>::try_from(sd_output.as_slice()) + .ok() + .and_then(|b| NonZero::::new(u64::from_le_bytes(b))) + .ok_or_else(|| String::from("Failed to decode AuraApi_slot_duration output"))?; + + let decoded_auth_proof = + trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { + proof: authorities_proof.decode().to_vec(), + }) + .map_err(|e| format!("Failed to decode authorities call proof: {e}"))?; + + let (auth_output, vm) = + run_single_runtime_call(vm, "AuraApi_authorities", &decoded_auth_proof, state_root)?; + + let authorities = header::AuraAuthoritiesIter::decode(&auth_output) + .map_err(|_| String::from("Failed to decode AuraApi_authorities output"))? + .map(header::AuraAuthority::from) + .collect::>(); + + Ok((slot_duration, authorities, vm)) +} + +/// Builds a `BootstrappedParachain` from verified Aura parameters and a compiled VM. +fn build_bootstrapped_parachain( + chain_info: &chain::chain_information::ValidChainInformation, + slot_duration: NonZero, + authorities: Vec, + vm: executor::host::HostVmPrototype, + code: Vec, + storage_heap_pages: Option>, +) -> Result { + let ci_ref = chain_info.as_ref(); let new_chain_info = chain::chain_information::ChainInformation { finalized_block_header: Box::new(ci_ref.finalized_block_header.into()), consensus: chain::chain_information::ChainInformationConsensus::Aura { @@ -1431,9 +1690,6 @@ async fn bootstrap_parachain_consensus( virtual_machine: vm, storage_code: Some(code), storage_heap_pages, - // Only consumed by the warp-sync fast path (relay chains); the parachain - // sync path has no hint field and drops it. Can be extracted from - // `decoded_proof` via `closest_descendant_merkle_value` if ever needed. code_merkle_value: None, closest_ancestor_excluding: None, }, @@ -1538,3 +1794,52 @@ fn run_single_runtime_call( } } } + +#[cfg(test)] +mod tests { + use smoldot::executor; + + /// Verify that the warm-start compilation path rejects invalid WASM bytes + /// gracefully (returns an error) rather than panicking. + #[test] + fn invalid_cached_runtime_fails_compilation() { + let garbage = b"this is not valid wasm"; + let heap_pages = executor::storage_heap_pages_to_value(None).unwrap(); + let result = executor::host::HostVmPrototype::new(executor::host::Config { + module: garbage, + heap_pages, + exec_hint: executor::vm::ExecHint::CompileWithNonDeterministicValidation, + allow_unresolved_imports: true, + }); + assert!(result.is_err(), "garbage bytes should fail compilation"); + } + + /// Verify that an empty cached runtime fails compilation gracefully. + #[test] + fn empty_cached_runtime_fails_compilation() { + let heap_pages = executor::storage_heap_pages_to_value(None).unwrap(); + let result = executor::host::HostVmPrototype::new(executor::host::Config { + module: b"", + heap_pages, + exec_hint: executor::vm::ExecHint::CompileWithNonDeterministicValidation, + allow_unresolved_imports: true, + }); + assert!(result.is_err(), "empty bytes should fail compilation"); + } + + /// Verify that a WASM module without a memory section fails with a clear + /// error (NoMemory), not a panic. This is the expected behavior when a + /// cached runtime is truncated or corrupted but still has valid WASM magic. + #[test] + fn wasm_without_memory_fails_gracefully() { + let minimal_wasm = b"\x00asm\x01\x00\x00\x00"; + let heap_pages = executor::storage_heap_pages_to_value(None).unwrap(); + let result = executor::host::HostVmPrototype::new(executor::host::Config { + module: minimal_wasm, + heap_pages, + exec_hint: executor::vm::ExecHint::CompileWithNonDeterministicValidation, + allow_unresolved_imports: true, + }); + assert!(result.is_err(), "WASM without memory should fail"); + } +}