From a4c054903e595ade04e5df5b5665cbe4376e9395 Mon Sep 17 00:00:00 2001 From: w Date: Tue, 21 Apr 2026 16:20:35 -0400 Subject: [PATCH 01/11] fix(sync-service): use initial finalized block for parachain head fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On warm restart from databaseContent, the relay chain may already be synced. fetch_parachain_head_from_relay() was waiting for a NEW Notification::Finalized event from subscribe_all(), which might not arrive for seconds (or indefinitely if the relay sync stalls). The fix: try the already-finalized block from subscribe_all immediately before waiting for new notifications. This is the block that's already available in subscription.finalized_block_scale_encoded_header. Before: parachain warm restart NEVER initialized (>5min timeout) After: parachain warm restart initializes in ~3s The runtime hint verification in bootstrap_parachain_consensus already handles reusing the cached runtime from databaseContent — it verifies the merkle value and skips the ~2MB download when it matches. Fixes #3204. --- light-base/src/sync_service/parachain.rs | 40 +++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 9a9dda8272..9c63ece157 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -1117,6 +1117,11 @@ async fn fetch_parachain_head_from_relay( .subscribe_all(32, NonZero::::new(usize::MAX).unwrap()) .await; + // Try the already-finalized block first before waiting for new notifications. + // On warm restart the relay chain may already be synced, so there's no reason + // to wait for the next finalization round. + let mut try_initial_finalized = true; + log!( platform, Info, @@ -1125,20 +1130,27 @@ async fn fetch_parachain_head_from_relay( ); loop { - let finalized_hash = loop { - match subscription.new_blocks.next().await { - Some(runtime_service::Notification::Finalized { hash, .. }) => { - break hash; - } - Some(_) => continue, - None => { - // Subscription died. Re-subscribe. - subscription = relay_chain_sync - .subscribe_all(32, NonZero::::new(usize::MAX).unwrap()) - .await; - break header::hash_from_scale_encoded_header( - &subscription.finalized_block_scale_encoded_header, - ); + let finalized_hash = if try_initial_finalized { + try_initial_finalized = false; + header::hash_from_scale_encoded_header( + &subscription.finalized_block_scale_encoded_header, + ) + } else { + loop { + match subscription.new_blocks.next().await { + Some(runtime_service::Notification::Finalized { hash, .. }) => { + break hash; + } + Some(_) => continue, + None => { + // Subscription died. Re-subscribe. + subscription = relay_chain_sync + .subscribe_all(32, NonZero::::new(usize::MAX).unwrap()) + .await; + break header::hash_from_scale_encoded_header( + &subscription.finalized_block_scale_encoded_header, + ); + } } } }; From 0ab4af14ffa27dfb3150cb4c208e9247eb0c995e Mon Sep 17 00:00:00 2001 From: w Date: Tue, 21 Apr 2026 21:10:10 -0400 Subject: [PATCH 02/11] perf(sync-service): skip parachain runtime download on warm start When databaseContent includes the runtime code (runtimeCode in the JSON), compile it locally instead of downloading ~2 MiB from a P2P peer. The warm path still fetches two lightweight Aura call proofs (~few KB each) to verify the cached runtime works against the current block. If compilation or verification fails, falls back to the full P2P download. Changes: - database.rs: persist code_storage_value (was intentionally discarded); decode it back as runtime_code in DatabaseContent - sync_service.rs: add saved_runtime_code to ConfigParachain - parachain.rs: add try_warm_start_from_cached_code() that compiles cached code and verifies via AuraApi call proofs; extract cold_bootstrap_loop() - lib.rs: thread saved_runtime_code from database through to ConfigParachain Tested on Paseo, Polkadot, Kusama Asset Hubs: - Paseo: warm para 1.1s vs cold 2.1s (no :code download) - Polkadot: warm para 4.1s vs cold 2.2s (call proof latency) - Kusama: warm para 5.5s vs cold 5.9s (call proof latency) - All three: runtimeCode saved to DB (2.0-2.5 MB), no download on warm Builds on #3210 (correctness fix for the warm hang). --- light-base/src/database.rs | 48 ++-- light-base/src/lib.rs | 29 ++- light-base/src/sync_service.rs | 8 + light-base/src/sync_service/parachain.rs | 279 +++++++++++++++++++++-- 4 files changed, 317 insertions(+), 47 deletions(-) diff --git a/light-base/src/database.rs b/light-base/src/database.rs index f1981062fa..4a76319146 100644 --- a/light-base/src/database.rs +++ b/light-base/src/database.rs @@ -62,6 +62,12 @@ 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 skip the ~2 MiB + /// P2P runtime download, falling back to cold bootstrap only on failure. + pub runtime_code: Option>, } /// See [`DatabaseContent::runtime_code_hint`]. @@ -207,24 +213,37 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result>(); - let runtime_code_hint = match ( + let (runtime_code_hint, runtime_code) = match ( decoded.code_merkle_value, decoded.code_storage_value, 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(|_| ())?, - code_merkle_value: hex::decode(mv).map_err(|_| ())?, - closest_ancestor_excluding: an - .as_bytes() - .iter() - .map(|char| Nibble::from_ascii_hex_digit(*char).ok_or(())) - .collect::, ()>>()?, - }), - // A combination of `Some` and `None` is technically invalid, but we simply ignore this - // situation. - _ => None, + (Some(mv), Some(sv), Some(an)) => { + let code = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sv) + .map_err(|_| ())?; + let hint = DatabaseContentRuntimeCodeHint { + code: code.clone(), + code_merkle_value: hex::decode(mv).map_err(|_| ())?, + closest_ancestor_excluding: an + .as_bytes() + .iter() + .map(|char| Nibble::from_ascii_hex_digit(*char).ok_or(())) + .collect::, ()>>()?, + }; + (Some(hint), Some(code)) + } + // When only the storage value is present (no merkle value / ancestor), we still + // expose the raw code for the parachain warm-start path. + (None, Some(sv), _) => { + let code = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sv) + .map_err(|_| ())?; + (None, Some(code)) + } + // A combination of `Some` and `None` for the hint fields is technically invalid, but + // we simply ignore this situation. + _ => (None, None), }; Ok(DatabaseContent { @@ -232,6 +251,7 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result 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 +471,13 @@ 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 +487,11 @@ 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 +503,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 +519,10 @@ 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 +530,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. @@ -709,6 +713,7 @@ impl Client { (Some((relay_chain, para_id, _)), _) => 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 +1122,8 @@ enum StartServicesChainTy<'a, TPlat: platform::PlatformRef> { Parachain { relay_chain: &'a ChainServices, para_id: u32, + /// Cached runtime code from the database, used for warm-start optimization. + saved_runtime_code: Option>, }, } @@ -1185,6 +1192,7 @@ fn start_services( StartServicesChainTy::Parachain { relay_chain, para_id, + saved_runtime_code, } => { // Chain is a parachain. @@ -1202,6 +1210,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..04a5915f7b 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` will attempt a warm-start by compiling this code locally + /// and verifying it with 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 9c63ece157..70195fd269 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,30 +69,48 @@ 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( - &log_target, - &platform, - &network_service, - &effective_chain_info, - block_number_bytes, - ) - .await - { - Ok(b) => break (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; + // Phase 2: Bootstrap Aura consensus parameters. + // + // If the database contains saved runtime code, attempt a warm start: compile the cached + // code locally and verify it by running lightweight Aura call proofs (~few KB each). + // This skips the ~2 MiB `:code` + `:heappages` P2P storage download on warm restart. + // On any failure, fall back to the cold bootstrap loop which downloads from peers. + let (effective_chain_info, finalized_runtime) = + if let Some(code) = saved_runtime_code { + match try_warm_start_from_cached_code( + &log_target, + &platform, + &network_service, + &effective_chain_info, + block_number_bytes, + code, + ) + .await + { + Ok(b) => { + log!( + &platform, + Info, + &log_target, + "Warm-started parachain consensus from cached runtime code" + ); + (b.chain_info, b.finalized_runtime) + } + Err(err) => { + log!( + &platform, + Warn, + &log_target, + format!( + "Warm start failed ({err}), falling back to cold bootstrap..." + ) + ); + 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 // finalization and reports finalized parachain blocks. @@ -1105,6 +1124,220 @@ 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; + } + } + } +} + +/// Attempts a warm start using runtime code cached in the database. +/// +/// Compiles the cached code locally (no P2P download of `:code`), then fetches lightweight +/// Aura call proofs (`AuraApi_slot_duration` and `AuraApi_authorities`) to determine consensus +/// parameters. The entire P2P traffic is a few KB rather than ~2 MiB. +/// +/// Returns an error string on any failure; the caller should fall back to cold bootstrap. +async fn try_warm_start_from_cached_code( + 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!( + "Attempting warm start for parachain block #{} ({}) using {} bytes of cached runtime", + ci_ref.finalized_block_header.number, + HashDisplay(&block_hash), + code.len() + ) + ); + + // Wait for a peer — same as bootstrap_parachain_consensus. + 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); + } + } + } + } + }; + + // Compile the cached code locally — no network download needed. + // Use default heap pages; the cold path does the same when `:heappages` is absent. + let heap_pages = executor::storage_heap_pages_to_value(None) + .map_err(|e| format!("Failed to derive default heap pages: {e}"))?; + + log!( + platform, + Info, + log_target, + format!( + "Compiling cached parachain runtime ({} bytes)...", + code.len() + ) + ); + + 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}"))?; + + // Fetch AuraApi_slot_duration call proof (~few KB). + 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) + }; + + // Fetch AuraApi_authorities call proof (~few KB). + 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)?; + + let auths = header::AuraAuthoritiesIter::decode(&output) + .map_err(|_| String::from("Failed to decode AuraApi_authorities output"))? + .map(header::AuraAuthority::from) + .collect::>(); + (auths, vm) + }; + + log!( + platform, + Info, + log_target, + format!( + "Warm start: Aura consensus verified (slot_duration={}ms, authorities={})", + slot_duration, + authorities.len() + ) + ); + + 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 { + finalized_authorities_list: authorities, + slot_duration, + }, + finality: chain::chain_information::ChainInformationFinality::Outsourced, + }; + + let chain_info = chain::chain_information::ValidChainInformation::try_from(new_chain_info) + .map_err(|e| format!("Invalid chain information from warm start: {e}"))?; + + Ok(BootstrappedParachain { + chain_info, + finalized_runtime: FinalizedBlockRuntime { + virtual_machine: vm, + storage_code: Some(code), + storage_heap_pages: None, + code_merkle_value: None, + closest_ancestor_excluding: None, + }, + }) +} + // Fetch the included parachain head from a finalized relay chain block. async fn fetch_parachain_head_from_relay( log_target: &str, From d1fdfb69d9e0e5e4b3833964e8b2b27de450417f Mon Sep 17 00:00:00 2001 From: w Date: Wed, 22 Apr 2026 14:05:50 -0400 Subject: [PATCH 03/11] style: collapse single-line format! calls to satisfy rustfmt --- light-base/src/sync_service/parachain.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 70195fd269..25a9220a99 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -101,9 +101,7 @@ pub(super) async fn start_parachain( &platform, Warn, &log_target, - format!( - "Warm start failed ({err}), falling back to cold bootstrap..." - ) + format!("Warm start failed ({err}), falling back to cold bootstrap...") ); cold_bootstrap_loop(&log_target, &platform, &network_service, &effective_chain_info, block_number_bytes).await } @@ -1153,9 +1151,7 @@ async fn cold_bootstrap_loop( platform, Warn, log_target, - format!( - "Failed to bootstrap parachain consensus: {err}. Retrying in 5s..." - ) + format!("Failed to bootstrap parachain consensus: {err}. Retrying in 5s...") ); platform.sleep(Duration::from_secs(5)).await; } From fb8ef1df441c57756d8a6b9f19115f605ddb8327 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 22 Apr 2026 14:54:50 -0400 Subject: [PATCH 04/11] style: fix remaining rustfmt issues in lib.rs and parachain.rs --- light-base/src/lib.rs | 36 +++++++++-- light-base/src/sync_service/parachain.rs | 79 ++++++++++++++---------- 2 files changed, 77 insertions(+), 38 deletions(-) diff --git a/light-base/src/lib.rs b/light-base/src/lib.rs index d5d056669f..58f3831750 100644 --- a/light-base/src/lib.rs +++ b/light-base/src/lib.rs @@ -456,7 +456,13 @@ impl Client { // it describes. // 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 ( + 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()); @@ -477,7 +483,13 @@ impl Client { ) 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, runtime_code) + ( + Some(db_ci), + true, + known_nodes, + runtime_code_hint, + runtime_code, + ) } // Otherwise, use the chain spec checkpoint. @@ -490,8 +502,16 @@ impl Client { runtime_code, .. }), - ) => (Some(checkpoint), false, known_nodes, runtime_code_hint, runtime_code), - (_, Some(Ok(checkpoint)), None) => (Some(checkpoint), false, Vec::new(), None, 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 @@ -522,7 +542,13 @@ impl Client { runtime_code, .. }), - ) => (Some(genesis_ci), false, known_nodes, runtime_code_hint, runtime_code), + ) => ( + Some(genesis_ci), + false, + known_nodes, + runtime_code_hint, + runtime_code, + ), ( Some(genesis_ci), None diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 25a9220a99..4e4f10ef39 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -75,40 +75,53 @@ pub(super) async fn start_parachain( // code locally and verify it by running lightweight Aura call proofs (~few KB each). // This skips the ~2 MiB `:code` + `:heappages` P2P storage download on warm restart. // On any failure, fall back to the cold bootstrap loop which downloads from peers. - let (effective_chain_info, finalized_runtime) = - if let Some(code) = saved_runtime_code { - match try_warm_start_from_cached_code( - &log_target, - &platform, - &network_service, - &effective_chain_info, - block_number_bytes, - code, - ) - .await - { - Ok(b) => { - log!( - &platform, - Info, - &log_target, - "Warm-started parachain consensus from cached runtime code" - ); - (b.chain_info, b.finalized_runtime) - } - Err(err) => { - log!( - &platform, - Warn, - &log_target, - format!("Warm start failed ({err}), falling back to cold bootstrap...") - ); - cold_bootstrap_loop(&log_target, &platform, &network_service, &effective_chain_info, block_number_bytes).await - } + let (effective_chain_info, finalized_runtime) = if let Some(code) = saved_runtime_code { + match try_warm_start_from_cached_code( + &log_target, + &platform, + &network_service, + &effective_chain_info, + block_number_bytes, + code, + ) + .await + { + Ok(b) => { + log!( + &platform, + Info, + &log_target, + "Warm-started parachain consensus from cached runtime code" + ); + (b.chain_info, b.finalized_runtime) } - } else { - cold_bootstrap_loop(&log_target, &platform, &network_service, &effective_chain_info, block_number_bytes).await - }; + Err(err) => { + log!( + &platform, + Warn, + &log_target, + format!("Warm start failed ({err}), falling back to cold bootstrap...") + ); + 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 // finalization and reports finalized parachain blocks. From 087509a03df9e3642199cea0e37f17a90d01bcc2 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 22 Apr 2026 20:05:14 -0400 Subject: [PATCH 05/11] test: add unit tests for database runtime_code decoding and warm-start fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Database tests: - decode_database_without_runtime_code: no runtime_code field → None - decode_database_with_runtime_code_only: runtimeCode without merkle hint - decode_database_with_full_hint_populates_both: all three fields present - decode_database_invalid_base64_runtime_code_returns_error: bad input - encode_shrink_drops_runtime_code_when_too_large: size cap drops code Warm-start fallback tests: - invalid_cached_runtime_fails_compilation: garbage bytes → Err - empty_cached_runtime_fails_compilation: empty bytes → Err - wasm_without_memory_fails_gracefully: truncated WASM → Err (not panic) --- light-base/src/database.rs | 90 ++++++++++++++++++++++++ light-base/src/sync_service/parachain.rs | 49 +++++++++++++ 2 files changed, 139 insertions(+) diff --git a/light-base/src/database.rs b/light-base/src/database.rs index 4a76319146..9753734a2d 100644 --- a/light-base/src/database.rs +++ b/light-base/src/database.rs @@ -282,3 +282,93 @@ struct SerdeDatabase { )] code_closest_ancestor_excluding: Option, } + +#[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/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 4e4f10ef39..ec438fae94 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -1792,3 +1792,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"); + } +} From 5d93906fce763f9ac13512f1c984ac3e2c478a89 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 23 Apr 2026 12:08:47 -0400 Subject: [PATCH 06/11] fix: verify warm-start cached runtime against network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warm path was trusting saved Aura params and heap pages from the database without any network verification. This could silently use a stale runtime if it was upgraded between sessions, and compile with wrong heap pages if the chain uses custom :heappages. Fix: - Warm path now fetches :heappages + both Aura call proofs from the network (~few KB), verifying the cached code works against current state. Only the ~2 MiB :code download is skipped. - Extract shared helpers (wait_for_peer, fetch_call_proof, run_aura_calls, build_bootstrapped_parachain) used by both cold and warm paths, eliminating the code duplication. - Remove SavedParachainState struct — just pass Option> for the cached runtime code. Aura params are always verified from network. - Remove aura_slot_duration/aura_authorities from DatabaseContent and the Aura JSON parsing in decode_database. - Fix double-decode in decode_database: base64 is decoded once, shared between runtime_code_hint and runtime_code. - Remove tests that only tested HostVmPrototype::new (the WASM compiler), not the warm-start logic. --- light-base/src/database.rs | 60 ++-- light-base/src/lib.rs | 37 +- light-base/src/sync_service.rs | 6 +- light-base/src/sync_service/parachain.rs | 419 ++++++++++------------- 4 files changed, 251 insertions(+), 271 deletions(-) diff --git a/light-base/src/database.rs b/light-base/src/database.rs index 9753734a2d..ddcba38135 100644 --- a/light-base/src/database.rs +++ b/light-base/src/database.rs @@ -65,8 +65,9 @@ pub struct DatabaseContent { /// 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 skip the ~2 MiB - /// P2P runtime download, falling back to cold bootstrap only on failure. + /// 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>, } @@ -213,37 +214,34 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result>(); - let (runtime_code_hint, runtime_code) = match ( + // 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)) => { - let code = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sv) - .map_err(|_| ())?; - let hint = DatabaseContentRuntimeCodeHint { - code: code.clone(), - code_merkle_value: hex::decode(mv).map_err(|_| ())?, - closest_ancestor_excluding: an - .as_bytes() - .iter() - .map(|char| Nibble::from_ascii_hex_digit(*char).ok_or(())) - .collect::, ()>>()?, - }; - (Some(hint), Some(code)) - } - // When only the storage value is present (no merkle value / ancestor), we still - // expose the raw code for the parachain warm-start path. - (None, Some(sv), _) => { - let code = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sv) - .map_err(|_| ())?; - (None, Some(code)) - } - // A combination of `Some` and `None` for the hint fields is technically invalid, but - // we simply ignore this situation. - _ => (None, None), + (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() + .iter() + .map(|char| Nibble::from_ascii_hex_digit(*char).ok_or(())) + .collect::, ()>>()?, + }), + // A combination of `Some` and `None` is technically invalid, but we simply + // ignore this situation. + _ => None, }; Ok(DatabaseContent { @@ -251,7 +249,7 @@ pub fn decode_database(encoded: &str, block_number_bytes: usize) -> Result Client { } }; + log!( + &self.platform, + Warn, + "smoldot", + format!( + "DB decode result: chain_info={} used_db={} runtime_code={} hint={}", + chain_information.is_some(), + used_database_chain_information, + saved_runtime_code.as_ref().map(|c| c.len()).unwrap_or(0), + runtime_code_hint.is_some(), + ) + ); + // If the chain specification specifies a parachain, find the corresponding relay chain // in the list of potential relay chains passed by the user. // If no relay chain can be found, the chain creation fails. Exactly one matching relay @@ -736,11 +749,24 @@ impl Client { ); let config = match (&relay_chain, &chain_information) { - (Some((relay_chain, para_id, _)), _) => StartServicesChainTy::Parachain { - relay_chain, - para_id: *para_id, - saved_runtime_code: saved_runtime_code.clone(), - }, + (Some((relay_chain, para_id, _)), _) => { + if let Some(code) = &saved_runtime_code { + log!( + &self.platform, + Info, + "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 } } @@ -1148,7 +1174,6 @@ enum StartServicesChainTy<'a, TPlat: platform::PlatformRef> { Parachain { relay_chain: &'a ChainServices, para_id: u32, - /// Cached runtime code from the database, used for warm-start optimization. saved_runtime_code: Option>, }, } diff --git a/light-base/src/sync_service.rs b/light-base/src/sync_service.rs index 04a5915f7b..ccd2dc97c2 100644 --- a/light-base/src/sync_service.rs +++ b/light-base/src/sync_service.rs @@ -111,9 +111,9 @@ pub struct ConfigParachain { /// Raw bytes of the runtime (`:code` storage value) saved in the database. /// - /// When `Some`, `start_parachain` will attempt a warm-start by compiling this code locally - /// and verifying it with lightweight Aura call proofs, skipping the ~2 MiB P2P download. - /// Falls back to cold bootstrap on any failure. + /// 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>, } diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index ec438fae94..192acbace8 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -69,14 +69,16 @@ pub(super) async fn start_parachain( ) ); - // Phase 2: Bootstrap Aura consensus parameters. + // Phase 2: Obtain Aura consensus parameters and the compiled runtime. // - // If the database contains saved runtime code, attempt a warm start: compile the cached - // code locally and verify it by running lightweight Aura call proofs (~few KB each). - // This skips the ~2 MiB `:code` + `:heappages` P2P storage download on warm restart. - // On any failure, fall back to the cold bootstrap loop which downloads from peers. + // Warm path: if the database contains cached runtime code, compile it locally + // and verify against the network via lightweight `:heappages` + Aura call proofs + // (~few KB total). 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 try_warm_start_from_cached_code( + match warm_bootstrap( &log_target, &platform, &network_service, @@ -86,15 +88,7 @@ pub(super) async fn start_parachain( ) .await { - Ok(b) => { - log!( - &platform, - Info, - &log_target, - "Warm-started parachain consensus from cached runtime code" - ); - (b.chain_info, b.finalized_runtime) - } + Ok(b) => (b.chain_info, b.finalized_runtime), Err(err) => { log!( &platform, @@ -1172,14 +1166,10 @@ async fn cold_bootstrap_loop( } } -/// Attempts a warm start using runtime code cached in the database. -/// -/// Compiles the cached code locally (no P2P download of `:code`), then fetches lightweight -/// Aura call proofs (`AuraApi_slot_duration` and `AuraApi_authorities`) to determine consensus -/// parameters. The entire P2P traffic is a few KB rather than ~2 MiB. -/// -/// Returns an error string on any failure; the caller should fall back to cold bootstrap. -async fn try_warm_start_from_cached_code( +/// 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>, @@ -1196,46 +1186,57 @@ async fn try_warm_start_from_cached_code( Info, log_target, format!( - "Attempting warm start for parachain block #{} ({}) using {} bytes of cached runtime", + "Warm-starting parachain at block #{} ({}) using {} bytes of cached runtime", ci_ref.finalized_block_header.number, HashDisplay(&block_hash), code.len() ) ); - // Wait for a peer — same as bootstrap_parachain_consensus. - 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; - // Compile the cached code locally — no network download needed. - // Use default heap pages; the cold path does the same when `:heappages` is absent. - let heap_pages = executor::storage_heap_pages_to_value(None) - .map_err(|e| format!("Failed to derive default heap pages: {e}"))?; + // Fetch :heappages (tiny) and both Aura call proofs in parallel. + let (heap_pages_proof, slot_duration_proof, authorities_proof) = future::try_join3( + async { + network_service + .clone() + .storage_proof_request( + peer_id.clone(), + codec::StorageProofRequestConfig { + block_hash, + keys: [&b":heappages"[..]].into_iter(), + }, + Duration::from_secs(16), + ) + .await + .map_err(|e| format!(":heappages 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?; - log!( - platform, - Info, - log_target, - format!( - "Compiling cached parachain runtime ({} bytes)...", - code.len() - ) - ); + // Decode :heappages from the storage proof. + let decoded_hp_proof = + trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { + proof: heap_pages_proof.decode().to_vec(), + }) + .map_err(|e| format!("Failed to decode :heappages proof: {e}"))?; + let heap_pages_raw = decoded_hp_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, @@ -1244,107 +1245,29 @@ async fn try_warm_start_from_cached_code( }) .map_err(|e| format!("Failed to compile cached runtime: {e}"))?; - // Fetch AuraApi_slot_duration call proof (~few KB). - 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) - }; - - // Fetch AuraApi_authorities call proof (~few KB). - 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)?; - - let auths = header::AuraAuthoritiesIter::decode(&output) - .map_err(|_| String::from("Failed to decode AuraApi_authorities output"))? - .map(header::AuraAuthority::from) - .collect::>(); - (auths, vm) - }; + // 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: Aura consensus verified (slot_duration={}ms, authorities={})", + "Warm start verified: Aura consensus (slot_duration={}ms, authorities={})", slot_duration, authorities.len() ) ); - 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 { - finalized_authorities_list: authorities, - slot_duration, - }, - finality: chain::chain_information::ChainInformationFinality::Outsourced, - }; - - let chain_info = chain::chain_information::ValidChainInformation::try_from(new_chain_info) - .map_err(|e| format!("Invalid chain information from warm start: {e}"))?; - - Ok(BootstrappedParachain { + build_bootstrapped_parachain( chain_info, - finalized_runtime: FinalizedBlockRuntime { - virtual_machine: vm, - storage_code: Some(code), - storage_heap_pages: None, - code_merkle_value: None, - closest_ancestor_excluding: None, - }, - }) + slot_duration, + authorities, + vm, + code, + storage_heap_pages, + ) } // Fetch the included parachain head from a finalized relay chain block. @@ -1486,7 +1409,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, @@ -1509,24 +1433,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, @@ -1588,73 +1495,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, @@ -1667,6 +1520,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 { @@ -1685,9 +1645,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, }, From 1a100a74a7b85ed55784fbc01ad4ea07b390295a Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:52:34 +0000 Subject: [PATCH 07/11] fix(sync-service): anchor warm-start :code to state root Fetch :code alongside :heappages in the warm-start storage proof and verify cached runtime bytes (or their blake2_256 hash, for state v1 value-stripped proofs) against the on-chain trie node. On mismatch the warm path fails and falls back to cold bootstrap, preventing a stale or substituted runtime from passing the Aura-output sanity check unnoticed. --- light-base/src/sync_service/parachain.rs | 49 ++++++++++++++++++------ 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 192acbace8..5b9b15c6ea 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -1195,8 +1195,8 @@ async fn warm_bootstrap( let peer_id = wait_for_peer(network_service).await; - // Fetch :heappages (tiny) and both Aura call proofs in parallel. - let (heap_pages_proof, slot_duration_proof, authorities_proof) = future::try_join3( + // Fetch :code+:heappages and both Aura call proofs in parallel. + let (code_hp_proof, slot_duration_proof, authorities_proof) = future::try_join3( async { network_service .clone() @@ -1204,12 +1204,12 @@ async fn warm_bootstrap( peer_id.clone(), codec::StorageProofRequestConfig { block_hash, - keys: [&b":heappages"[..]].into_iter(), + keys: [&b":code"[..], &b":heappages"[..]].into_iter(), }, Duration::from_secs(16), ) .await - .map_err(|e| format!(":heappages storage proof request failed: {e}")) + .map_err(|e| format!(":code/:heappages storage proof request failed: {e}")) }, fetch_call_proof( network_service, @@ -1221,14 +1221,41 @@ async fn warm_bootstrap( ) .await?; - // Decode :heappages from the storage proof. - let decoded_hp_proof = - trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { - proof: heap_pages_proof.decode().to_vec(), - }) - .map_err(|e| format!("Failed to decode :heappages proof: {e}"))?; + let decoded_proof = trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { + proof: code_hp_proof.decode().to_vec(), + }) + .map_err(|e| format!("Failed to decode :code/:heappages proof: {e}"))?; + + // Anchor the cached runtime to the finalized state root. The peer may return + // either the full :code value (state v0, or a v1 proof that included it) or + // only its blake2_256 hash (state v1, value-stripped). Both must match the + // cached bytes; any mismatch fails this path and 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::Known { value, .. } => { + if value != code.as_slice() { + return Err(String::from("cached :code does not match on-chain bytes")); + } + } + trie::proof_decode::StorageValue::HashKnownValueMissing(on_chain_hash) => { + 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::None => { + return Err(String::from(":code missing in on-chain state")); + } + } - let heap_pages_raw = decoded_hp_proof + 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()); From 3c566d2a33bb6464307309e86daec93c0ed8d958 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:25:14 +0000 Subject: [PATCH 08/11] Revert "fix(sync-service): use initial finalized block for parachain head fetch" This reverts commit a4c054903e595ade04e5df5b5665cbe4376e9395. --- light-base/src/sync_service/parachain.rs | 40 +++++++++--------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 5b9b15c6ea..cd9a97ced5 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -1309,11 +1309,6 @@ async fn fetch_parachain_head_from_relay( .subscribe_all(32, NonZero::::new(usize::MAX).unwrap()) .await; - // Try the already-finalized block first before waiting for new notifications. - // On warm restart the relay chain may already be synced, so there's no reason - // to wait for the next finalization round. - let mut try_initial_finalized = true; - log!( platform, Info, @@ -1322,27 +1317,20 @@ async fn fetch_parachain_head_from_relay( ); loop { - let finalized_hash = if try_initial_finalized { - try_initial_finalized = false; - header::hash_from_scale_encoded_header( - &subscription.finalized_block_scale_encoded_header, - ) - } else { - loop { - match subscription.new_blocks.next().await { - Some(runtime_service::Notification::Finalized { hash, .. }) => { - break hash; - } - Some(_) => continue, - None => { - // Subscription died. Re-subscribe. - subscription = relay_chain_sync - .subscribe_all(32, NonZero::::new(usize::MAX).unwrap()) - .await; - break header::hash_from_scale_encoded_header( - &subscription.finalized_block_scale_encoded_header, - ); - } + let finalized_hash = loop { + match subscription.new_blocks.next().await { + Some(runtime_service::Notification::Finalized { hash, .. }) => { + break hash; + } + Some(_) => continue, + None => { + // Subscription died. Re-subscribe. + subscription = relay_chain_sync + .subscribe_all(32, NonZero::::new(usize::MAX).unwrap()) + .await; + break header::hash_from_scale_encoded_header( + &subscription.finalized_block_scale_encoded_header, + ); } } }; From 57d7a1beb73380acffd676ae8193fe7766e0023d Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:44:44 +0000 Subject: [PATCH 09/11] chore(sync-service): log warm-start :code anchor branch Debug-level log of which match arm verified cached :code and the proof byte size, to diagnose whether peers strip the value. --- light-base/src/sync_service/parachain.rs | 26 ++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index cd9a97ced5..9380d9b77e 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -1221,8 +1221,10 @@ async fn warm_bootstrap( ) .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: code_hp_proof.decode().to_vec(), + proof: proof_bytes, }) .map_err(|e| format!("Failed to decode :code/:heappages proof: {e}"))?; @@ -1237,12 +1239,32 @@ async fn warm_bootstrap( ) .map_err(|_| String::from("Proof missing :code path"))?; match code_info.storage_value { - trie::proof_decode::StorageValue::Known { value, .. } => { + trie::proof_decode::StorageValue::Known { value, inline } => { + log!( + platform, + Debug, + log_target, + format!( + "Warm-start :code anchor: branch=Known proof_bytes={} value_bytes={} inline={}", + proof_byte_len, + value.len(), + inline, + ) + ); if value != code.as_slice() { return Err(String::from("cached :code does not match on-chain bytes")); } } 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( From aa76b73da28fe29ec6189209748c4c875d8bdd62 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:12:54 +0000 Subject: [PATCH 10/11] perf(sync-service): probe ":code\0" to skip runtime value in proof Request the non-existent strict descendant `:code\0` so the absence proof traverses through `:code`'s leaf without loading its 2 MiB value. For state v1 chains the leaf encoding then carries only `Hashed(blake2_256(value))`, which we already verify against the cached bytes via blake2. Falls back to byte-equality check if the peer bundles the value anyway. --- light-base/src/sync_service/parachain.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index 9380d9b77e..a961da9bc5 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -1195,7 +1195,15 @@ async fn warm_bootstrap( let peer_id = wait_for_peer(network_service).await; - // Fetch :code+:heappages and both Aura call proofs in parallel. + // Fetch a "near :code" absence proof + :heappages, plus both Aura call proofs in parallel. + // + // We probe `:code\0` (a non-existent strict descendant of `:code`) instead of `:code` + // itself. The absence proof must walk through `:code`'s leaf to prove the descendant + // doesn't exist, so the leaf node is in the response. But because no value is being + // *read* at the queried key, substrate's prove_read should not bundle `:code`'s 2 MiB + // value into the proof's value table. For state v1 (modern parachains) the leaf + // encoding only carries `Hashed(blake2_256(value))`, which `trie_node_info` exposes as + // `HashKnownValueMissing(hash)` — enough to verify cached bytes via blake2. let (code_hp_proof, slot_duration_proof, authorities_proof) = future::try_join3( async { network_service @@ -1204,12 +1212,12 @@ async fn warm_bootstrap( peer_id.clone(), codec::StorageProofRequestConfig { block_hash, - keys: [&b":code"[..], &b":heappages"[..]].into_iter(), + keys: [&b":code\0"[..], &b":heappages"[..]].into_iter(), }, Duration::from_secs(16), ) .await - .map_err(|e| format!(":code/:heappages storage proof request failed: {e}")) + .map_err(|e| format!(":code\\0/:heappages storage proof request failed: {e}")) }, fetch_call_proof( network_service, @@ -1226,7 +1234,7 @@ async fn warm_bootstrap( let decoded_proof = trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { proof: proof_bytes, }) - .map_err(|e| format!("Failed to decode :code/:heappages proof: {e}"))?; + .map_err(|e| format!("Failed to decode :code\\0/:heappages proof: {e}"))?; // Anchor the cached runtime to the finalized state root. The peer may return // either the full :code value (state v0, or a v1 proof that included it) or From ec51130c5b2a2a4daf3f9c6f6bd4833f9c6a7fd5 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:37:33 +0000 Subject: [PATCH 11/11] chore(sync-service): clean up warm-start logging and comments Drop the "DB decode result" Warn log left over from benching, downgrade the parachain warm-start availability log to Debug, and tighten the warm_bootstrap comments. Hoist the `:code\0` probe key into a named constant `CODE_ANCHOR_PROBE_KEY` and simplify the storage-proof error messages to drop the escape-noisy key list. --- light-base/src/lib.rs | 15 +---- light-base/src/sync_service/parachain.rs | 70 ++++++++++++------------ 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/light-base/src/lib.rs b/light-base/src/lib.rs index 4aa75b9d8b..1f3162437e 100644 --- a/light-base/src/lib.rs +++ b/light-base/src/lib.rs @@ -566,19 +566,6 @@ impl Client { } }; - log!( - &self.platform, - Warn, - "smoldot", - format!( - "DB decode result: chain_info={} used_db={} runtime_code={} hint={}", - chain_information.is_some(), - used_database_chain_information, - saved_runtime_code.as_ref().map(|c| c.len()).unwrap_or(0), - runtime_code_hint.is_some(), - ) - ); - // If the chain specification specifies a parachain, find the corresponding relay chain // in the list of potential relay chains passed by the user. // If no relay chain can be found, the chain creation fails. Exactly one matching relay @@ -753,7 +740,7 @@ impl Client { if let Some(code) = &saved_runtime_code { log!( &self.platform, - Info, + Debug, "smoldot", format!( "Parachain warm-start available: cached runtime={}KB", diff --git a/light-base/src/sync_service/parachain.rs b/light-base/src/sync_service/parachain.rs index a961da9bc5..2ba7416457 100644 --- a/light-base/src/sync_service/parachain.rs +++ b/light-base/src/sync_service/parachain.rs @@ -71,12 +71,13 @@ pub(super) async fn start_parachain( // Phase 2: Obtain Aura consensus parameters and the compiled runtime. // - // Warm path: if the database contains cached runtime code, compile it locally - // and verify against the network via lightweight `:heappages` + Aura call proofs - // (~few KB total). Skips the ~2 MiB `:code` P2P download. + // 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. + // 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, @@ -1166,6 +1167,11 @@ async fn cold_bootstrap_loop( } } +/// 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. @@ -1195,15 +1201,9 @@ async fn warm_bootstrap( let peer_id = wait_for_peer(network_service).await; - // Fetch a "near :code" absence proof + :heappages, plus both Aura call proofs in parallel. - // - // We probe `:code\0` (a non-existent strict descendant of `:code`) instead of `:code` - // itself. The absence proof must walk through `:code`'s leaf to prove the descendant - // doesn't exist, so the leaf node is in the response. But because no value is being - // *read* at the queried key, substrate's prove_read should not bundle `:code`'s 2 MiB - // value into the proof's value table. For state v1 (modern parachains) the leaf - // encoding only carries `Hashed(blake2_256(value))`, which `trie_node_info` exposes as - // `HashKnownValueMissing(hash)` — enough to verify cached bytes via blake2. + // 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 @@ -1212,12 +1212,12 @@ async fn warm_bootstrap( peer_id.clone(), codec::StorageProofRequestConfig { block_hash, - keys: [&b":code\0"[..], &b":heappages"[..]].into_iter(), + keys: [CODE_ANCHOR_PROBE_KEY, &b":heappages"[..]].into_iter(), }, Duration::from_secs(16), ) .await - .map_err(|e| format!(":code\\0/:heappages storage proof request failed: {e}")) + .map_err(|e| format!("Storage proof request failed: {e}")) }, fetch_call_proof( network_service, @@ -1234,12 +1234,14 @@ async fn warm_bootstrap( let decoded_proof = trie::proof_decode::decode_and_verify_proof(trie::proof_decode::Config { proof: proof_bytes, }) - .map_err(|e| format!("Failed to decode :code\\0/:heappages proof: {e}"))?; + .map_err(|e| format!("Failed to decode storage proof: {e}"))?; - // Anchor the cached runtime to the finalized state root. The peer may return - // either the full :code value (state v0, or a v1 proof that included it) or - // only its blake2_256 hash (state v1, value-stripped). Both must match the - // cached bytes; any mismatch fails this path and falls back to cold bootstrap. + // 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, @@ -1247,37 +1249,35 @@ async fn warm_bootstrap( ) .map_err(|_| String::from("Proof missing :code path"))?; match code_info.storage_value { - trie::proof_decode::StorageValue::Known { value, inline } => { + trie::proof_decode::StorageValue::HashKnownValueMissing(on_chain_hash) => { log!( platform, Debug, log_target, format!( - "Warm-start :code anchor: branch=Known proof_bytes={} value_bytes={} inline={}", + "Warm-start :code anchor: branch=HashKnownValueMissing proof_bytes={}", proof_byte_len, - value.len(), - inline, ) ); - if value != code.as_slice() { - return Err(String::from("cached :code does not match on-chain bytes")); + 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::HashKnownValueMissing(on_chain_hash) => { + trie::proof_decode::StorageValue::Known { value, inline: _ } => { log!( platform, - Debug, + Warn, log_target, format!( - "Warm-start :code anchor: branch=HashKnownValueMissing proof_bytes={}", + "Warm-start :code anchor: branch=Known proof_bytes={}, unexpected branch hit", 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", - )); + if value != code.as_slice() { + return Err(String::from("cached :code does not match on-chain bytes")); } } trie::proof_decode::StorageValue::None => {