diff --git a/.github/workflows/zombienet.yml b/.github/workflows/zombienet.yml index d6dc16a9b7..3c8ceb6057 100644 --- a/.github/workflows/zombienet.yml +++ b/.github/workflows/zombienet.yml @@ -56,16 +56,22 @@ jobs: fail-fast: false matrix: test: - - job-name: "zombienet-smoldot-0000-smoke" - test: "smoke" + - job-name: "zombienet-smoldot-0001-smoke_fresh" + test: "smoke_fresh" runner-type: "default" - - job-name: "zombienet-smoldot-0001-statement_store_submission" + - job-name: "zombienet-smoldot-0002-smoke_cold" + test: "smoke_cold" + runner-type: "default" + - job-name: "zombienet-smoldot-0003-smoke_warm" + test: "smoke_warm" + runner-type: "default" + - job-name: "zombienet-smoldot-0004-statement_store_submission" test: "statement_store_submission" runner-type: "default" - - job-name: "zombienet-smoldot-0002-statement_store_reception" + - job-name: "zombienet-smoldot-0005-statement_store_reception" test: "statement_store_reception" runner-type: "default" - - job-name: "zombienet-smoldot-0003-statement_store_peer_connection" + - job-name: "zombienet-smoldot-0006-statement_store_peer_connection" test: "statement_store_peer_connection" runner-type: "default" - job-name: "zombienet-smoldot-0004-statement_store_browser" diff --git a/e2e-tests/docs/smoke-scenarios.md b/e2e-tests/docs/smoke-scenarios.md new file mode 100644 index 0000000000..0aa11919ee --- /dev/null +++ b/e2e-tests/docs/smoke-scenarios.md @@ -0,0 +1,101 @@ +# Smoldot smoke-test scenarios + +Three smoke tests exercise distinct smoldot startup conditions: + +| Test | Network | Smoldot spec | Smoldot DB | +|-----------------|------------------------|-------------------------------------------|---------------------------| +| `smoke_fresh` | spawned from genesis | vanilla | none | +| `smoke_cold` | spawned from snapshot | with `lightSyncState` + `stateRootHash` | none | +| `smoke_warm` | spawned from snapshot | with `lightSyncState` + `stateRootHash` | preloaded `databaseContent` | + +Cold/warm both rely on smoldot's real warp sync (gap from `lightSyncState` to current head exceeds `warp_sync_minimum_gap=32`) so authority-set rotations along the way are handled by warp-sync proof fragments. + +Chain: `westend-local` relay + `people-westend-local` parachain. Same as fresh, so all three scenarios are directly comparable. + +## Artifact bundle + +Single GCS object per version: + +``` +gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/{ARTIFACTS_VERSION}/bundle.tar.gz +``` + +Contains: + +- `relaychain-db.tgz`, `parachain-db.tgz` — node DB snapshots; keystore stripped +- `relay-spec.json`, `para-spec.json` — full chain specs (substrate side) +- `relay-spec-lightSyncState.json`, `para-spec-lightSyncState.json` — slim chain specs (smoldot side, `genesis.stateRootHash` instead of `genesis.raw`) +- `smoldot-db/relay.json`, `smoldot-db/para.json` — `chainHead_unstable_finalizedDatabase` dumps for warm + +`ARTIFACTS_VERSION` and `BUNDLE_SHA256` live in `e2e-tests/src/snapshot.rs`. On first use the bundle is downloaded into `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/`, SHA-verified, and extracted in place. + +For local iteration: `ARTIFACTS_DIR_OVERRIDE=/path/to/dir` skips download/verify and uses files directly from that directory. + +## Regenerating the artifact bundle + +Triggered when: +- Runtime/binary changes invalidate the snapshot DB (genesis hash mismatch or block-format break). +- Adjusting `--target-finalized` / `--spec-at-finalized` to change the warp-sync gap or chain age. +- Upgrading smoldot in a way that changes its `databaseContent` format. + +Steps (from `e2e-tests/`): + +1. **Bump version** in `src/snapshot.rs`: + ```rust + pub const ARTIFACTS_VERSION: &str = "v2"; // or whatever + ``` + +2. **Run the generator test** to produce a fresh bundle. Either start from genesis (~3 h for `TARGET_FINALIZED=2000`) or resume from an existing source DB (~50 min): + + ```bash + # from genesis: + ZOMBIE_PROVIDER=native \ + SMOKE_SNAPSHOT_OUT=/tmp/smoldot-snap-v2 \ + SMOKE_SNAPSHOT_TARGET_FINALIZED=2500 \ + SMOKE_SNAPSHOT_SPEC_AT_FINALIZED=1250 \ + cargo test --release --test smoke_generate_snapshots -- --ignored --nocapture + + # or resume: + ZOMBIE_PROVIDER=native \ + SMOKE_SNAPSHOT_OUT=/tmp/smoldot-snap-v2 \ + SMOKE_SNAPSHOT_TARGET_FINALIZED=2000 \ + SMOKE_SNAPSHOT_SPEC_AT_FINALIZED=1525 \ + SMOKE_SNAPSHOT_RELAY_DB=/path/to/old/relaychain-db.tgz \ + SMOKE_SNAPSHOT_PARA_DB=/path/to/old/parachain-db.tgz \ + cargo test --release --test smoke_generate_snapshots -- --ignored --nocapture + ``` + + Required: `ZOMBIE_PROVIDER=native`, polkadot/polkadot-parachain on `PATH`. The module-level docstring in `tests/smoke_generate_snapshots.rs` lists every env var. + + It produces `bundle.tar.gz` under `SMOKE_SNAPSHOT_OUT` and prints the SHA256 in the manifest at the end. + +3. **Verify locally** before publishing: + + ```bash + ARTIFACTS_DIR_OVERRIDE=/tmp/smoldot-snap-v2 cargo test --test smoke_cold -- --nocapture + ARTIFACTS_DIR_OVERRIDE=/tmp/smoldot-snap-v2 cargo test --test smoke_warm -- --nocapture + ``` + + Both must pass. If they don't, it's almost certainly the chain spec / runtime version or the `--spec-at-finalized` choice — fix and retry before uploading. + +4. **Publish**: + ```bash + gsutil cp /tmp/smoldot-snap-v2/bundle.tar.gz \ + gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/v2/bundle.tar.gz + ``` + +5. **Pin the new SHA** in `src/snapshot.rs` (copy the value from the generator manifest): + ```rust + const BUNDLE_SHA256: &str = ""; + ``` + +6. **CI cache key** invalidates automatically — the workflow's cache step keys on `hashFiles('e2e-tests/src/snapshot.rs')`, so bumping the constant is enough. + +7. Commit, open PR, run cold/warm tests in CI to confirm GCS download + extract path works end-to-end. + +## Notes on common pitfalls + +- **Sibling nodes with identical session keys equivocate.** The generator excludes `keystore/` when tarring; zombienet inserts per-node keys via `author_insertKey` after startup. Don't add keystore back into the snapshot. +- **Same tarball path passed to multiple zombienet nodes** triggers a TOCTOU race in zombienet-provider's `with_db_snapshot` cache. The generator and `network::stage_per_node_snapshots` work around this by copying the tarball once per consuming node. +- **Spec-at-finalized too close to target-finalized**: gap ≤ 32 means smoldot uses follow-forward instead of warp sync, which can't traverse GRANDPA rotations. Default `M = N/2` keeps it safe. +- **Bootnode multiaddrs are per-spawn** (zombienet picks free ports). The committed specs ship with empty `bootNodes`; `network::prepare_runtime_specs` injects current multiaddrs into a runtime copy before handing the spec to smoldot. diff --git a/e2e-tests/js/helpers.js b/e2e-tests/js/helpers.js index 9580c3d1b4..81d1ebf8ed 100644 --- a/e2e-tests/js/helpers.js +++ b/e2e-tests/js/helpers.js @@ -25,7 +25,7 @@ export function createSmoldotClient() { logCallback: (level, target, message) => { const labels = { 1: "ERROR", 2: "WARN", 3: "INFO", 4: "DEBUG", 5: "TRACE" }; const label = labels[level] ?? `L${level}`; - console.error(`[${label}] [${target}] ${message}`); + console.error(`[${new Date().toISOString()}] [${label}] [${target}] ${message}`); }, }); } @@ -35,6 +35,12 @@ export async function addChainFromSpec(client, specPath, opts = {}) { return client.addChain({ chainSpec, ...opts }); } +export function readDbContentIfSet(envVar) { + const path = process.env[envVar]; + if (!path) return undefined; + return fs.readFileSync(path, "utf8"); +} + let nextId = 1; export function sendRpc(chain, method, params = []) { @@ -132,10 +138,11 @@ export async function readJsonRpcUntil(chain, predicate, deadlineMs) { export function report(name, passed, detail) { const suffix = detail ? `: ${detail}` : ""; + const ts = new Date().toISOString(); if (passed) { - console.log(`PASS: ${name}${suffix}`); + console.log(`[${ts}] PASS: ${name}${suffix}`); } else { - console.log(`FAIL: ${name}${suffix}`); + console.log(`[${ts}] FAIL: ${name}${suffix}`); process.exitCode = 1; } } diff --git a/e2e-tests/js/smoke.js b/e2e-tests/js/smoke.js index 53ca24d1d8..69599dce1c 100644 --- a/e2e-tests/js/smoke.js +++ b/e2e-tests/js/smoke.js @@ -15,17 +15,22 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +import * as fs from "node:fs"; import { createSmoldotClient, addChainFromSpec, + readDbContentIfSet, sendRpc, readJsonRpcUntil, + sendRpcAndWait, report, } from "./helpers.js"; const relaySpecPath = process.env.RELAY_CHAIN_SPEC; const paraSpecPath = process.env.PARA_CHAIN_SPEC; const requiredBlocks = Number.parseInt(process.env.REQUIRED_BLOCKS, 10); +const expectedInitialFinalized = Number.parseInt(process.env.EXPECTED_INITIAL_FINALIZED ?? "0", 10); +const dbDumpDir = process.env.SMOLDOT_DB_DUMP_DIR; if (!relaySpecPath || !paraSpecPath || !Number.isFinite(requiredBlocks)) { console.error( @@ -34,20 +39,108 @@ if (!relaySpecPath || !paraSpecPath || !Number.isFinite(requiredBlocks)) { process.exit(1); } +// Decodes the block number from a hex SCALE-encoded substrate header. +// Layout: parent_hash (32 B) | compact-encoded number | rest. The compact +// modes 0/1/2 cover block numbers up to 2^30; that's the only range we'll +// ever assert against. +function decodeHeaderNumber(hexStr) { + const stripped = hexStr.startsWith("0x") ? hexStr.slice(2) : hexStr; + const bytes = Buffer.from(stripped, "hex"); + if (bytes.length < 33) throw new Error(`header hex too short: ${bytes.length} bytes`); + const off = 32; + const b0 = bytes[off]; + const mode = b0 & 0b11; + if (mode === 0) return b0 >>> 2; + if (mode === 1) return (b0 | (bytes[off + 1] << 8)) >>> 2; + if (mode === 2) { + return ( + (b0 | (bytes[off + 1] << 8) | (bytes[off + 2] << 16) | (bytes[off + 3] << 24)) >>> 2 + ); + } + throw new Error(`compact mode 3 not supported in decodeHeaderNumber`); +} + const client = createSmoldotClient(); let relay; let para; let passed = true; try { - relay = await addChainFromSpec(client, relaySpecPath); + const relayDbContent = readDbContentIfSet("SMOLDOT_DB_RELAY"); + const paraDbContent = readDbContentIfSet("SMOLDOT_DB_PARA"); + + relay = await addChainFromSpec(client, relaySpecPath, { + databaseContent: relayDbContent, + }); report("addChain relay", true); para = await addChainFromSpec(client, paraSpecPath, { + databaseContent: paraDbContent, potentialRelayChains: [relay], }); report("addChain parachain", true); + // Assert smoldot's first reported finalized block ≥ expected. Uses + // chainHead_v1: subscribe on the relay, wait for the `initialized` event + // (which fires only after warp sync) and decode the newest finalized + // header's number. Legacy `chain_getFinalizedHead` would race the + // warp-sync gate — smoldot blocks legacy RPCs until the gate opens. + if (expectedInitialFinalized > 0) { + const relayFollowReqId = sendRpc(relay, "chainHead_v1_follow", [false]).toString(); + const relaySubId = await readJsonRpcUntil( + relay, + (msg) => { + if (msg.id === relayFollowReqId) { + if (msg.error) + throw new Error( + `relay chainHead_v1_follow failed: ${JSON.stringify(msg.error)}`, + ); + return msg.result; + } + return undefined; + }, + Date.now() + 30_000, + ); + if (typeof relaySubId !== "string" || !relaySubId) { + throw new Error("Unexpected relay follow subscription id"); + } + const finalizedHash = await readJsonRpcUntil( + relay, + (msg) => { + if (msg.method !== "chainHead_v1_followEvent") return undefined; + if (msg.params?.subscription !== relaySubId) return undefined; + const r = msg.params.result; + if (r?.event === "initialized") { + const hashes = r.finalizedBlockHashes ?? []; + return hashes[hashes.length - 1]; + } + if (r?.event === "stop") throw new Error("relay chainHead follow stopped"); + return undefined; + }, + Date.now() + 120_000, + ); + if (typeof finalizedHash !== "string") { + throw new Error("relay chainHead never reported initialized"); + } + const headerHex = await sendRpcAndWait( + relay, + "chainHead_v1_header", + [relaySubId, finalizedHash], + 30_000, + ); + const num = decodeHeaderNumber(headerHex); + const ok = num >= expectedInitialFinalized; + report( + "relay finalized at-or-past expected_initial_finalized", + ok, + `finalized=#${num} expected=#${expectedInitialFinalized}`, + ); + if (!ok) + throw new Error( + `relay finalized #${num} below expected_initial_finalized #${expectedInitialFinalized}`, + ); + } + const followReqId = sendRpc(para, "chainHead_v1_follow", [false]).toString(); const subId = await readJsonRpcUntil( para, @@ -68,7 +161,10 @@ try { } report("chainHead_v1_follow accepted", true, `subId=${subId}`); - const initialBlocks = new Set(); + // Skip the initial `newBlock` burst (replay of already-known blocks); the + // first `bestBlockChanged` marks its end. Otherwise a warm-started smoldot + // would satisfy the threshold from cached state alone. + let burstDone = false; let newBlocks = 0; await readJsonRpcUntil( para, @@ -76,16 +172,16 @@ try { if (msg.method !== "chainHead_v1_followEvent") return undefined; if (msg.params?.subscription !== subId) return undefined; const result = msg.params.result; - if (result?.event === "initialized") { - for (const h of result.finalizedBlockHashes ?? []) initialBlocks.add(h); - } else if (result?.event === "newBlock" && !initialBlocks.has(result.blockHash)) { + if (result?.event === "bestBlockChanged") { + burstDone = true; + } else if (result?.event === "newBlock" && burstDone) { if (++newBlocks >= requiredBlocks) return true; } else if (result?.event === "stop") { throw new Error("chainHead follow stopped unexpectedly"); } return undefined; }, - Date.now() + 120_000, + Date.now() + 180_000, ); const ok = newBlocks >= requiredBlocks; @@ -95,15 +191,29 @@ try { `count=${newBlocks}/${requiredBlocks}`, ); if (!ok) passed = false; + + if (passed && dbDumpDir) { + fs.mkdirSync(dbDumpDir, { recursive: true }); + const relayDb = await sendRpcAndWait( + relay, + "chainHead_unstable_finalizedDatabase", + [], + 30_000, + ); + const paraDb = await sendRpcAndWait( + para, + "chainHead_unstable_finalizedDatabase", + [], + 30_000, + ); + fs.writeFileSync(`${dbDumpDir}/relay.json`, relayDb); + fs.writeFileSync(`${dbDumpDir}/para.json`, paraDb); + report("dumped smoldot databaseContent", true, dbDumpDir); + } } catch (e) { report("smoke", false, e.message); passed = false; -} finally { - try { - await client.terminate(); - } catch (_) {} } -if (!passed || process.exitCode) { - process.exit(1); -} +// Finish as soon as the result is known +process.exit(passed && !process.exitCode ? 0 : 1); diff --git a/e2e-tests/js/statement_store_peer_connection.js b/e2e-tests/js/statement_store_peer_connection.js index c1cbae6a8c..52ff2f4b31 100644 --- a/e2e-tests/js/statement_store_peer_connection.js +++ b/e2e-tests/js/statement_store_peer_connection.js @@ -124,12 +124,7 @@ try { } catch (e) { report("statement_store_peer_connection", false, e.message); passed = false; -} finally { - try { - await client.terminate(); - } catch (_) {} } -if (!passed || process.exitCode) { - process.exit(1); -} +// Finish as soon as the result is known +process.exit(passed && !process.exitCode ? 0 : 1); diff --git a/e2e-tests/js/statement_store_reception.js b/e2e-tests/js/statement_store_reception.js index 106a528055..aa1863ff75 100644 --- a/e2e-tests/js/statement_store_reception.js +++ b/e2e-tests/js/statement_store_reception.js @@ -145,12 +145,7 @@ try { } catch (e) { report("statement_store_reception", false, e.message); passed = false; -} finally { - try { - await client.terminate(); - } catch (_) {} } -if (!passed || process.exitCode) { - process.exit(1); -} +// Finish as soon as the result is known +process.exit(passed && !process.exitCode ? 0 : 1); diff --git a/e2e-tests/js/statement_store_submission.js b/e2e-tests/js/statement_store_submission.js index 57b2dc10ee..8c8d775be2 100644 --- a/e2e-tests/js/statement_store_submission.js +++ b/e2e-tests/js/statement_store_submission.js @@ -81,6 +81,11 @@ try { report("statement_store_submission", false, e.message); passed = false; } finally { + // Unlike the other statement-store JS tests we cannot exit immediately + // here: statement_submit returns once smoldot has accepted the statement + // locally, but gossip to peers happens asynchronously. Awaiting + // client.terminate() keeps smoldot alive long enough for that propagation + // to reach the collators, which is what Rust is asserting on. try { await client.terminate(); } catch (_) {} diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index fcdeb1eb61..49684423f5 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -18,8 +18,15 @@ use std::path::{Path, PathBuf}; pub mod bulletin; +pub mod network; +pub mod snapshot; pub mod statement; +pub use network::{ + run_smoke_js, spawn_scenario, spawned_chain_spec_paths, LiveNetwork, Scenario, SmoldotDbPaths, + SnapshotPaths, BEST_METRIC, FINALIZED_METRIC, PARA_ID, +}; + /// A file-backed Rust → JS message channel. Rust appends newline-terminated /// messages with [`SyncFile::send`]; JS polls the file and waits for a given /// line via the `waitForMessage` helper in `e2e-tests/js/helpers.js`. The diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs new file mode 100644 index 0000000000..452c1ec656 --- /dev/null +++ b/e2e-tests/src/network.rs @@ -0,0 +1,454 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Smoke-test scenario plumbing. +//! +//! Three scenarios share this module: +//! - **Fresh**: network from genesis, vanilla spec, no smoldot DB. +//! - **Cold**: network from snapshot, spec with `lightSyncState`, no smoldot DB. +//! - **Warm**: network from snapshot, spec with `lightSyncState`, smoldot DB preloaded. +//! +//! Cold/warm consume the artifact set produced by `smoke_generate_snapshots`; see +//! `e2e-tests/docs/smoke-scenarios.md` and `crate::snapshot`. + +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; +use serde_json::Value; +use zombienet_sdk::{LocalFileSystem, Network, NetworkConfig, NetworkConfigBuilder}; + +/// `BlockNumber` width on substrate-based chains used here (westend, people-westend). +const BLOCK_NUMBER_BYTES: usize = 4; + +pub const PARA_ID: u32 = 1004; +pub const PARA_CHAIN: &str = "people-westend-local"; +pub const FINALIZED_METRIC: &str = "block_height{status=\"finalized\"}"; +pub const BEST_METRIC: &str = "block_height{status=\"best\"}"; + +/// Timeout for the fresh-scenario gate that waits for the relay to produce +/// its first finalized block before launching smoldot. Confirms GrandPa is +/// alive; failure here surfaces as a clear gate-failure rather than a +/// downstream smoldot timeout. +const RELAY_FIRST_FINALIZED_TIMEOUT_SECS: u64 = 120; + +pub struct SnapshotPaths { + /// Substrate-node DB tarballs. + pub relay_db_tgz: PathBuf, + pub para_db_tgz: PathBuf, + /// Full chain spec with `genesis.raw`. Passed to substrate via + /// `with_chain_spec_path` so node DB extraction matches. + pub relay_full_spec: PathBuf, + pub para_full_spec: PathBuf, + /// Smoldot-dedicated specs (not what substrate loads): `genesis.stateRootHash` + /// only (no full state) plus the `lightSyncState` checkpoint. Faster init, + /// smaller artifact than the full spec. + pub smoldot_relay_spec: PathBuf, + pub smoldot_para_spec: PathBuf, +} + +pub struct SmoldotDbPaths { + pub relay_db_json: PathBuf, + pub para_db_json: PathBuf, +} + +pub enum Scenario { + /// Network from genesis, vanilla spec, no smoldot DB. + Fresh, + /// Network from snapshot, spec with `lightSyncState`, no smoldot DB. + Cold(SnapshotPaths), + /// Network from snapshot, spec with `lightSyncState`, smoldot DB preloaded. + Warm { + snapshot: SnapshotPaths, + smoldot_db: SmoldotDbPaths, + }, +} + +impl Scenario { + fn snapshot(&self) -> Option<&SnapshotPaths> { + match self { + Scenario::Fresh => None, + Scenario::Cold(s) | Scenario::Warm { snapshot: s, .. } => Some(s), + } + } + + fn smoldot_db(&self) -> Option<&SmoldotDbPaths> { + match self { + Scenario::Warm { smoldot_db, .. } => Some(smoldot_db), + _ => None, + } + } +} + +pub struct LiveNetwork { + pub network: Network, + pub relay_spec: PathBuf, + pub para_spec: PathBuf, + /// Lower bound on the first finalized block smoldot reports after init. + /// Asserts smoldot honoured the artifact checkpoint (didn't fall back + /// to genesis). Fresh: 0. Cold: from `lightSyncState`. Warm: + /// max(cold, persisted DB). + pub expected_initial_finalized: u64, +} + +/// Spawns the network described by `cfg` and returns the artifacts smoldot +/// needs (spec paths, expected initial finalized). Builds smoldot + JS deps +/// in parallel with node startup so the test is ready to drive smoldot as +/// soon as the network is up. +pub async fn spawn_scenario( + cfg: &Scenario, + base_dir_str: &str, +) -> Result { + let config = build_network_config(cfg, base_dir_str)?; + + log::info!("spawning zombienet network"); + let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); + let network = spawn_fn(config).await?; + network.detach().await; + + log::info!("building smoldot + installing JS deps"); + crate::ensure_smoldot_built(); + crate::ensure_js_deps_installed(); + + network.wait_until_is_up(120).await?; + log::info!("network is up"); + + if matches!(cfg, Scenario::Fresh) { + wait_for_relay_first_finalized(&network).await?; + } + + let (relay_spec, para_spec) = match cfg.snapshot() { + None => spawned_chain_spec_paths(&network)?, + // Light-sync-state specs (genesis.stateRootHash + lightSyncState) are + // what smoldot loads. Published artifacts have empty `bootNodes`; + // inject current multiaddrs into runtime copies. + Some(s) => prepare_runtime_specs( + &network, + &s.smoldot_relay_spec, + &s.smoldot_para_spec, + base_dir_str, + )?, + }; + + let mut expected_initial_finalized = match cfg.snapshot() { + None => 0, + // lightSyncState is in both full and light-sync-state specs; use the + // smaller one. + Some(s) => parse_finalized_height_from_spec(&s.smoldot_relay_spec)?, + }; + if let Some(db) = cfg.smoldot_db() { + let persisted = parse_finalized_height_from_db(&db.relay_db_json)?; + expected_initial_finalized = expected_initial_finalized.max(persisted); + } + + Ok(LiveNetwork { + network, + relay_spec, + para_spec, + expected_initial_finalized, + }) +} + +fn build_network_config( + cfg: &Scenario, + base_dir_str: &str, +) -> Result { + let images = zombienet_sdk::environment::get_images_from_env(); + + // Per-node copies of the snapshot tarballs work around a TOCTOU race in + // zombienet-provider's `with_db_snapshot` cache: sibling nodes sharing one + // source path corrupt the partially-written file. Each per-node copy gets + // its own cache slot (sha256 keyed on path string). + let staged = match cfg.snapshot() { + None => StagedSnapshots::default(), + Some(s) => stage_per_node_snapshots(base_dir_str, &s.relay_db_tgz, &s.para_db_tgz)?, + }; + // Substrate gets the *full* spec — it needs `genesis.raw` to bootstrap. + let (relay_spec_path, para_spec_path) = match cfg.snapshot() { + None => (None, None), + Some(s) => ( + Some(s.relay_full_spec.to_str().expect("UTF-8 path").to_owned()), + Some(s.para_full_spec.to_str().expect("UTF-8 path").to_owned()), + ), + }; + + let builder = NetworkConfigBuilder::new() + .with_relaychain(|r| { + let r = r + .with_chain("westend-local") + .with_default_command("polkadot") + .with_default_image(images.polkadot.as_str()); + let r = match relay_spec_path.as_deref() { + None => r, + Some(p) => r.with_chain_spec_path(p), + }; + r.with_validator(|n| { + let n = n.with_name("validator-0").bootnode(true); + match staged.validator_0.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + .with_validator(|n| { + let n = n.with_name("validator-1").bootnode(true); + match staged.validator_1.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + }) + .with_parachain(|p| { + let p = p + .with_id(PARA_ID) + .with_default_command("polkadot-parachain") + .with_default_image(images.cumulus.as_str()) + .with_chain(PARA_CHAIN) + .with_default_args(vec![ + "--force-authoring".into(), + "--authoring=slot-based".into(), + ]); + let p = match para_spec_path.as_deref() { + None => p, + Some(path) => p.with_chain_spec_path(path), + }; + p.with_collator(|n| { + let n = n.with_name("alice").bootnode(true); + match staged.alice.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + .with_collator(|n| { + let n = n.with_name("bob").bootnode(true); + match staged.bob.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + }) + .with_global_settings(|g| g.with_base_dir(base_dir_str)); + + builder.build().map_err(|errs| { + anyhow!( + "config errors: {}", + errs.into_iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + ) + }) +} + +async fn wait_for_relay_first_finalized( + network: &Network, +) -> Result<(), anyhow::Error> { + let validator = network.get_node("validator-0")?; + log::info!("waiting for relay to produce its first finalized block"); + validator + .wait_metric_with_timeout( + FINALIZED_METRIC, + |h| h >= 1.0, + RELAY_FIRST_FINALIZED_TIMEOUT_SECS, + ) + .await + .map_err(|e| anyhow!("relay did not finalize any block: {e}"))?; + log::info!("relay produced its first finalized block"); + Ok(()) +} + +#[derive(Default)] +struct StagedSnapshots { + validator_0: Option, + validator_1: Option, + alice: Option, + bob: Option, +} + +fn stage_per_node_snapshots( + base_dir_str: &str, + relay_db: &Path, + para_db: &Path, +) -> Result { + let stage_dir = PathBuf::from(base_dir_str).join("staged-snapshots"); + std::fs::create_dir_all(&stage_dir)?; + let stage = |src: &Path, name: &str| -> Result { + let dst = stage_dir.join(format!("{name}.tgz")); + std::fs::copy(src, &dst) + .map_err(|e| anyhow!("copy {} -> {}: {e}", src.display(), dst.display()))?; + Ok(dst.to_str().expect("UTF-8 path").to_owned()) + }; + Ok(StagedSnapshots { + validator_0: Some(stage(relay_db, "relay-validator-0")?), + validator_1: Some(stage(relay_db, "relay-validator-1")?), + alice: Some(stage(para_db, "para-alice")?), + bob: Some(stage(para_db, "para-bob")?), + }) +} + +/// Reads `committed_relay` / `committed_para` (port-agnostic artifacts with +/// empty `bootNodes`), injects current bootnode multiaddrs, and writes +/// runtime copies under `{base_dir}/smoldot-runtime-specs/`. +fn prepare_runtime_specs( + network: &Network, + committed_relay: &Path, + committed_para: &Path, + base_dir_str: &str, +) -> Result<(PathBuf, PathBuf), anyhow::Error> { + let runtime_dir = PathBuf::from(base_dir_str).join("smoldot-runtime-specs"); + std::fs::create_dir_all(&runtime_dir)?; + + let relay_multi = collect_multiaddrs(network, &["validator-0", "validator-1"])?; + let para_multi = collect_multiaddrs(network, &["alice", "bob"])?; + + let relay_runtime = runtime_dir.join("relay-spec.json"); + let para_runtime = runtime_dir.join("para-spec.json"); + write_spec_with_bootnodes(committed_relay, &relay_runtime, &relay_multi)?; + write_spec_with_bootnodes(committed_para, ¶_runtime, ¶_multi)?; + log::info!( + "prepared runtime specs (relay={}, para={})", + relay_runtime.display(), + para_runtime.display() + ); + Ok((relay_runtime, para_runtime)) +} + +fn collect_multiaddrs( + network: &Network, + names: &[&str], +) -> Result, anyhow::Error> { + names + .iter() + .map(|n| { + network + .get_node(*n) + .map(|node| node.multiaddr().to_string()) + }) + .collect::, _>>() +} + +fn write_spec_with_bootnodes( + src: &Path, + dst: &Path, + multiaddrs: &[String], +) -> Result<(), anyhow::Error> { + let mut spec: Value = serde_json::from_slice(&std::fs::read(src)?)?; + let array = multiaddrs + .iter() + .map(|m| Value::String(m.clone())) + .collect(); + if let Some(obj) = spec.as_object_mut() { + obj.insert("bootNodes".to_string(), Value::Array(array)); + } + std::fs::write(dst, serde_json::to_string_pretty(&spec)?)?; + Ok(()) +} + +/// Returns the relay & parachain chain-spec files zombienet emits under +/// `network.base_dir()` after spawn. Both already include the bootnodes — +/// no patching required. +pub fn spawned_chain_spec_paths( + network: &Network, +) -> Result<(PathBuf, PathBuf), anyhow::Error> { + let zombienet_base = PathBuf::from( + network + .base_dir() + .ok_or_else(|| anyhow!("network has no base_dir"))?, + ); + let relay_spec = zombienet_base.join(format!("{}.json", network.relaychain().chain())); + // zombienet_sdk::Parachain does not expose chain() getter, so we use const here + let para_spec = zombienet_base.join(format!("{PARA_CHAIN}.json")); + + log::info!( + "Resolved chain-spec paths: relay={}, para={}", + relay_spec.display(), + para_spec.display() + ); + Ok((relay_spec, para_spec)) +} + +fn parse_finalized_height_from_spec(path: &Path) -> Result { + let spec: Value = serde_json::from_slice(&std::fs::read(path)?)?; + let header_hex = spec + .pointer("/lightSyncState/finalizedBlockHeader") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!( + "{}: missing lightSyncState.finalizedBlockHeader", + path.display() + ) + })?; + decode_header_number(header_hex).map_err(|e| anyhow!("{}: {e}", path.display())) +} + +fn parse_finalized_height_from_db(path: &Path) -> Result { + let db: Value = serde_json::from_slice(&std::fs::read(path)?)?; + let header_hex = db + .pointer("/chain/finalized_block_header") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("{}: missing chain.finalized_block_header", path.display()))?; + decode_header_number(header_hex).map_err(|e| anyhow!("{}: {e}", path.display())) +} + +/// Decodes a hex SCALE-encoded substrate header and returns its block number. +/// Accepts either a `0x`-prefixed string (chain spec lightSyncState format) or +/// raw hex (smoldot databaseContent format). Uses smoldot's own header +/// decoder. +fn decode_header_number(hex_str: &str) -> Result { + let stripped = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(stripped).map_err(|e| anyhow!("invalid hex: {e}"))?; + let header = smoldot::header::decode(&bytes, BLOCK_NUMBER_BYTES) + .map_err(|e| anyhow!("smoldot header decode: {e}"))?; + Ok(header.number) +} + +/// Runs `js/smoke.js` against a live network. Env-injects spec paths, the +/// expected-initial-finalized floor, and (warm only) smoldot DB content +/// paths. +pub async fn run_smoke_js( + live: &LiveNetwork, + cfg: &Scenario, + required_blocks: u32, +) -> Result<(), anyhow::Error> { + let relay_spec_str = live.relay_spec.to_str().expect("UTF-8 path"); + let para_spec_str = live.para_spec.to_str().expect("UTF-8 path"); + let required = required_blocks.to_string(); + let expected_finalized = live.expected_initial_finalized.to_string(); + + let smoldot_db_paths = cfg.smoldot_db().map(|db| { + ( + db.relay_db_json.to_str().expect("UTF-8 path").to_owned(), + db.para_db_json.to_str().expect("UTF-8 path").to_owned(), + ) + }); + + let mut env_vars: Vec<(&str, &str)> = vec![ + ("RELAY_CHAIN_SPEC", relay_spec_str), + ("PARA_CHAIN_SPEC", para_spec_str), + ("REQUIRED_BLOCKS", required.as_str()), + ("EXPECTED_INITIAL_FINALIZED", expected_finalized.as_str()), + ]; + if let Some((relay_db, para_db)) = smoldot_db_paths.as_ref() { + env_vars.push(("SMOLDOT_DB_RELAY", relay_db.as_str())); + env_vars.push(("SMOLDOT_DB_PARA", para_db.as_str())); + } + + log::info!( + "running smoldot JS smoke test (relay_spec={relay_spec_str}, para_spec={para_spec_str}, required_blocks={required_blocks}, expected_initial_finalized={expected_finalized})" + ); + crate::run_js_test("js/smoke.js", &env_vars) + .await + .map_err(|e| anyhow!("JS test failed: {e}")) +} diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs new file mode 100644 index 0000000000..b83df2d4e7 --- /dev/null +++ b/e2e-tests/src/snapshot.rs @@ -0,0 +1,206 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Resolves the artifact set consumed by `smoke_cold` / `smoke_warm`. +//! +//! Everything ships as a single bundle on GCS: +//! `gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/{ARTIFACTS_VERSION}/bundle.tar.gz`. +//! On first use the bundle is downloaded into +//! `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/`, SHA256-verified, and +//! extracted in place. A marker file (`.extracted-sha`) records which +//! version is currently extracted; mismatch triggers re-download. +//! +//! For local iteration set `ARTIFACTS_DIR_OVERRIDE` to a directory laid out +//! exactly like the generator output (`relaychain-db.tgz`, `relay-spec.json`, +//! `smoldot-db/relay.json`, …). All resolvers point inside it; no download +//! or verification. +//! +//! See `e2e-tests/docs/smoke-scenarios.md` for the full layout and the +//! regeneration procedure. + +use std::path::PathBuf; + +use anyhow::anyhow; + +pub const ARTIFACTS_VERSION: &str = "v1"; + +const GCS_BASE: &str = + "https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db"; + +const BUNDLE_FILE: &str = "bundle.tar.gz"; +const ARTIFACTS_DIR_OVERRIDE_ENV: &str = "ARTIFACTS_DIR_OVERRIDE"; + +/// SHA256 of the published bundle for `ARTIFACTS_VERSION`. Empty means not +/// yet pinned — in that case the resolver requires `ARTIFACTS_DIR_OVERRIDE`. +const BUNDLE_SHA256: &str = "abea526d527c13aac54b4e1874602c04963046f7d3c3bc3e0adc217573bdc6da"; + +pub fn relay_db() -> Result { + resolve("relaychain-db.tgz") +} + +pub fn para_db() -> Result { + resolve("parachain-db.tgz") +} + +pub fn relay_spec() -> Result { + resolve("relay-spec.json") +} + +pub fn para_spec() -> Result { + resolve("para-spec.json") +} + +pub fn relay_spec_light_sync_state() -> Result { + resolve("relay-spec-lightSyncState.json") +} + +pub fn para_spec_light_sync_state() -> Result { + resolve("para-spec-lightSyncState.json") +} + +pub fn smoldot_db_relay() -> Result { + resolve("smoldot-db/relay.json") +} + +pub fn smoldot_db_para() -> Result { + resolve("smoldot-db/para.json") +} + +fn resolve(rel: &str) -> Result { + let dir = ensure_bundle_extracted()?; + let p = dir.join(rel); + if !p.is_file() { + return Err(anyhow!( + "expected {} in artifact bundle, missing", + p.display() + )); + } + Ok(p) +} + +fn ensure_bundle_extracted() -> Result { + if let Ok(dir) = std::env::var(ARTIFACTS_DIR_OVERRIDE_ENV) { + let p = PathBuf::from(dir); + if !p.is_dir() { + return Err(anyhow!( + "{ARTIFACTS_DIR_OVERRIDE_ENV}: {} is not a directory", + p.display() + )); + } + log::info!("snapshot: using local override {}", p.display()); + return Ok(p); + } + + let cache = cache_dir()?; + let marker = cache.join(".extracted-sha"); + let extracted_ok = std::fs::read_to_string(&marker) + .map(|s| s.trim() == BUNDLE_SHA256) + .unwrap_or(false); + if extracted_ok { + log::info!("snapshot: cache hit ({})", cache.display()); + return Ok(cache); + } + + if BUNDLE_SHA256.is_empty() { + return Err(anyhow!( + "BUNDLE_SHA256 not pinned for {ARTIFACTS_VERSION} (placeholder); \ + set {ARTIFACTS_DIR_OVERRIDE_ENV} to a local artifact directory" + )); + } + + let bundle_path = cache.join(BUNDLE_FILE); + let url = format!("{GCS_BASE}/{ARTIFACTS_VERSION}/{BUNDLE_FILE}"); + log::info!("snapshot: downloading {url}"); + download(&url, &bundle_path)?; + verify_sha256(&bundle_path, BUNDLE_SHA256)?; + log::info!( + "snapshot: extracting {} into {}", + bundle_path.display(), + cache.display() + ); + extract_tarball(&bundle_path, &cache)?; + std::fs::write(&marker, BUNDLE_SHA256)?; + let _ = std::fs::remove_file(&bundle_path); + Ok(cache) +} + +fn cache_dir() -> Result { + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .ok_or_else(|| anyhow!("neither XDG_CACHE_HOME nor HOME is set"))?; + let dir = base.join("smoldot-e2e").join(ARTIFACTS_VERSION); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn download(url: &str, dst: &std::path::Path) -> Result<(), anyhow::Error> { + let tmp = dst.with_extension("partial"); + let status = std::process::Command::new("curl") + .arg("-fL") + .arg("--retry") + .arg("3") + .arg("-o") + .arg(&tmp) + .arg(url) + .status()?; + if !status.success() { + let _ = std::fs::remove_file(&tmp); + return Err(anyhow!("curl failed for {url} (exit {status})")); + } + std::fs::rename(&tmp, dst)?; + Ok(()) +} + +fn extract_tarball(tarball: &std::path::Path, dst: &std::path::Path) -> Result<(), anyhow::Error> { + let status = std::process::Command::new("tar") + .arg("-xzf") + .arg(tarball) + .arg("-C") + .arg(dst) + .status()?; + if !status.success() { + return Err(anyhow!( + "tar -xzf failed for {} (exit {status})", + tarball.display() + )); + } + Ok(()) +} + +fn verify_sha256(path: &std::path::Path, expected: &str) -> Result<(), anyhow::Error> { + let output = std::process::Command::new("sha256sum").arg(path).output()?; + if !output.status.success() { + return Err(anyhow!( + "sha256sum failed for {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + )); + } + let stdout = String::from_utf8(output.stdout)?; + let actual = stdout + .split_whitespace() + .next() + .ok_or_else(|| anyhow!("empty sha256sum output for {}", path.display()))?; + if actual != expected { + return Err(anyhow!( + "{}: SHA256 mismatch (expected {expected}, got {actual})", + path.display() + )); + } + Ok(()) +} diff --git a/e2e-tests/src/statement.rs b/e2e-tests/src/statement.rs index 3eb6a7916f..d14076c076 100644 --- a/e2e-tests/src/statement.rs +++ b/e2e-tests/src/statement.rs @@ -15,23 +15,25 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::{ - path::{Path, PathBuf}, - time::Duration, -}; use anyhow::anyhow; use ed25519_dalek::{Signer, SigningKey}; use log::info; use serde_json::Value; -use smoldot::network::codec::{Proof, Statement, encode_statement}; +use smoldot::network::codec::{encode_statement, Proof, Statement}; +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; use zombienet_sdk::{ - LocalFileSystem, Network, NetworkConfigBuilder, NetworkNode, subxt::{ backend::rpc::RpcClient, ext::subxt_rpcs::{client::RpcSubscription, rpc_params}, }, + LocalFileSystem, Network, NetworkConfigBuilder, NetworkNode, }; +use crate::network::PARA_CHAIN; + /// Para id used by the statement-store e2e fixture. Zombienet writes the /// final chain-spec (with bootnodes patched in) to `/.json`. pub const PARA_ID: u32 = 1004; @@ -103,7 +105,10 @@ pub async fn spawn_network( para_spec_path: &Path, ) -> Result, anyhow::Error> { let images = zombienet_sdk::environment::get_images_from_env(); - let base_dir_str = base_dir.to_str().expect("base_dir is valid UTF-8").to_owned(); + let base_dir_str = base_dir + .to_str() + .expect("base_dir is valid UTF-8") + .to_owned(); let config = NetworkConfigBuilder::new() .with_relaychain(|r| { @@ -116,12 +121,13 @@ pub async fn spawn_network( }) .with_parachain(|p| { p.with_id(PARA_ID) + .with_chain(PARA_CHAIN) .with_chain_spec_path(para_spec_path.to_str().expect("Valid UTF-8 path")) .with_default_command("polkadot-parachain") .with_default_image(images.cumulus.as_str()) .with_default_args({ - let log_filter = std::env::var("SMOLDOT_E2E_COLLATOR_LOG") - .unwrap_or_else(|_| { + let log_filter = + std::env::var("SMOLDOT_E2E_COLLATOR_LOG").unwrap_or_else(|_| { "info,statement-store=info,statement-gossip=info".to_string() }); let log_arg = format!("-l{log_filter}"); @@ -160,34 +166,6 @@ pub async fn spawn_network( Ok(network) } -/// Returns the chain-spec files zombienet emits for the relay chain and the -/// statement-store parachain. Both already include the bootnodes — no patching -/// required. Paths live under `network.base_dir()`. -pub fn spawned_chain_spec_paths( - network: &Network, -) -> Result<(PathBuf, PathBuf), anyhow::Error> { - let base_dir = PathBuf::from( - network - .base_dir() - .ok_or_else(|| anyhow!("network has no base_dir"))?, - ); - - let relay_chain = network.relaychain().chain(); - let relay_path = base_dir.join(format!("{relay_chain}.json")); - - let para = network - .parachain(PARA_ID) - .ok_or_else(|| anyhow!("parachain {PARA_ID} not found"))?; - let para_path = base_dir.join(format!("{}.json", para.unique_id())); - - info!( - "Resolved chain-spec paths: relay={}, para={}", - relay_path.display(), - para_path.display() - ); - Ok((relay_path, para_path)) -} - /// Returns a deterministic Ed25519 keypair (seed, public key) for testing. pub fn test_keypair() -> ([u8; 32], [u8; 32]) { let seed = [1u8; 32]; @@ -245,9 +223,7 @@ pub async fn submit_statement( } /// Subscribes to all statements on a full node. -pub async fn subscribe_any( - rpc: &RpcClient, -) -> Result, anyhow::Error> { +pub async fn subscribe_any(rpc: &RpcClient) -> Result, anyhow::Error> { let subscription = rpc .subscribe::( "statement_subscribeStatement", @@ -289,10 +265,7 @@ pub async fn receive_statements( .map_err(|e| anyhow!("Subscription error: {e}"))?; // StatementEvent is { "event": "newStatements", "data": { "statements": [...], ... } } - if let Some(arr) = item - .pointer("/data/statements") - .and_then(|s| s.as_array()) - { + if let Some(arr) = item.pointer("/data/statements").and_then(|s| s.as_array()) { for v in arr { if let Some(s) = v.as_str() { collected.push(s.to_string()); diff --git a/e2e-tests/tests/smoke.rs b/e2e-tests/tests/smoke.rs deleted file mode 100644 index 65a9ea6069..0000000000 --- a/e2e-tests/tests/smoke.rs +++ /dev/null @@ -1,114 +0,0 @@ -// Smoldot -// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -use std::path::PathBuf; - -use anyhow::anyhow; -use smoldot_e2e_tests::*; -use zombienet_sdk::NetworkConfigBuilder; - -const PARA_ID: u32 = 1004; -const REQUIRED_BLOCKS: u32 = 5; - -/// Smoke test: spawn westend-local + people-westend-local (both built-in -/// chains of `polkadot` and `polkadot-parachain`), then run smoldot and -/// assert it sees new parachain blocks. -#[tokio::test(flavor = "multi_thread")] -async fn smoke() -> Result<(), anyhow::Error> { - let _ = env_logger::try_init_from_env( - env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), - ); - - let base_dir = resolve_base_dir()?; - let images = zombienet_sdk::environment::get_images_from_env(); - let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); - - let config = NetworkConfigBuilder::new() - .with_relaychain(|r| { - r.with_chain("westend-local") - .with_default_command("polkadot") - .with_default_image(images.polkadot.as_str()) - .with_validator(|n| n.with_name("validator-0").bootnode(true)) - .with_validator(|n| n.with_name("validator-1").bootnode(true)) - }) - .with_parachain(|p| { - p.with_id(PARA_ID) - .with_default_command("polkadot-parachain") - .with_default_image(images.cumulus.as_str()) - .with_chain("people-westend-local") - .with_default_args(vec![ - "--force-authoring".into(), - "--authoring=slot-based".into(), - ]) - .with_collator(|n| n.with_name("alice").bootnode(true)) - .with_collator(|n| n.with_name("bob").bootnode(true)) - }) - .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) - .build() - .map_err(|errs| { - anyhow!( - "config errors: {}", - errs.into_iter() - .map(|e| e.to_string()) - .collect::>() - .join(", ") - ) - })?; - - let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); - let network = spawn_fn(config).await?; - network.detach().await; - network.wait_until_is_up(120).await?; - - network - .get_node("alice")? - .wait_metric_with_timeout( - "block_height{status=\"best\"}", - |h| h >= REQUIRED_BLOCKS as f64, - 300u64, - ) - .await - .map_err(|e| anyhow!("alice did not produce parachain blocks: {e}"))?; - - let zombienet_base = PathBuf::from( - network - .base_dir() - .ok_or_else(|| anyhow!("network has no base_dir"))?, - ); - let relay_spec = zombienet_base.join(format!("{}.json", network.relaychain().chain())); - let parachain = network - .parachain(PARA_ID) - .ok_or_else(|| anyhow!("parachain {PARA_ID} not found"))?; - let para_spec_name = parachain.chain_id().unwrap_or(parachain.unique_id()); - let para_spec = zombienet_base.join(format!("{para_spec_name}.json")); - - ensure_smoldot_built(); - ensure_js_deps_installed(); - let required_blocks = REQUIRED_BLOCKS.to_string(); - run_js_test( - "js/smoke.js", - &[ - ("RELAY_CHAIN_SPEC", relay_spec.to_str().expect("UTF-8 path")), - ("PARA_CHAIN_SPEC", para_spec.to_str().expect("UTF-8 path")), - ("REQUIRED_BLOCKS", required_blocks.as_str()), - ], - ) - .await - .map_err(|e| anyhow!("JS test failed: {e}"))?; - - Ok(()) -} diff --git a/e2e-tests/tests/smoke_cold.rs b/e2e-tests/tests/smoke_cold.rs new file mode 100644 index 0000000000..b3181ed0cc --- /dev/null +++ b/e2e-tests/tests/smoke_cold.rs @@ -0,0 +1,62 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use anyhow::anyhow; +use smoldot_e2e_tests::*; + +const REQUIRED_BLOCKS: u32 = 5; + +/// Cold-startup smoke: spawn westend-local + people-westend-local from +/// committed DB snapshots, hand smoldot a chain spec carrying +/// `lightSyncState` (no persisted DB), assert it warp-syncs from the +/// checkpoint and sees new parachain blocks. +#[tokio::test(flavor = "multi_thread")] +async fn smoke_cold() -> Result<(), anyhow::Error> { + let _ = env_logger::try_init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), + ); + + let base_dir = resolve_base_dir()?; + let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); + + let cfg = Scenario::Cold(SnapshotPaths { + relay_db_tgz: snapshot::relay_db()?, + para_db_tgz: snapshot::para_db()?, + relay_full_spec: snapshot::relay_spec()?, + para_full_spec: snapshot::para_spec()?, + smoldot_relay_spec: snapshot::relay_spec_light_sync_state()?, + smoldot_para_spec: snapshot::para_spec_light_sync_state()?, + }); + let live = spawn_scenario(&cfg, &base_dir_str).await?; + + log::info!("checking that alice has produced post-snapshot parachain blocks (best)"); + let alice = live.network.get_node("alice")?; + let baseline = alice.reports(BEST_METRIC).await? as u32; + let target = baseline + REQUIRED_BLOCKS; + alice + .wait_metric_with_timeout(BEST_METRIC, |h| h >= target as f64, 180u64) + .await + .map_err(|e| { + anyhow!( + "alice did not produce {REQUIRED_BLOCKS} parachain blocks past #{baseline}: {e}" + ) + })?; + log::info!("alice reached #{target} (>= baseline+{REQUIRED_BLOCKS})"); + + run_smoke_js(&live, &cfg, REQUIRED_BLOCKS).await?; + Ok(()) +} diff --git a/e2e-tests/tests/smoke_fresh.rs b/e2e-tests/tests/smoke_fresh.rs new file mode 100644 index 0000000000..e2c52e1137 --- /dev/null +++ b/e2e-tests/tests/smoke_fresh.rs @@ -0,0 +1,47 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use anyhow::anyhow; +use smoldot_e2e_tests::*; + +const REQUIRED_BLOCKS: u32 = 5; + +/// Fresh-startup smoke: spawn westend-local + people-westend-local from +/// genesis and assert smoldot warp-syncs and sees new parachain blocks. +#[tokio::test(flavor = "multi_thread")] +async fn smoke_fresh() -> Result<(), anyhow::Error> { + let _ = env_logger::try_init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), + ); + + let base_dir = resolve_base_dir()?; + let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); + + let cfg = Scenario::Fresh; + let live = spawn_scenario(&cfg, &base_dir_str).await?; + + log::info!("checking that alice has ≥{REQUIRED_BLOCKS} parachain blocks (best)"); + live.network + .get_node("alice")? + .wait_metric_with_timeout(BEST_METRIC, |h| h >= REQUIRED_BLOCKS as f64, 180u64) + .await + .map_err(|e| anyhow!("alice did not produce parachain blocks: {e}"))?; + log::info!("alice has ≥{REQUIRED_BLOCKS} parachain blocks"); + + run_smoke_js(&live, &cfg, REQUIRED_BLOCKS).await?; + Ok(()) +} diff --git a/e2e-tests/tests/smoke_generate_snapshots.rs b/e2e-tests/tests/smoke_generate_snapshots.rs new file mode 100644 index 0000000000..480e499bfc --- /dev/null +++ b/e2e-tests/tests/smoke_generate_snapshots.rs @@ -0,0 +1,660 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Snapshot generator for the smoldot smoke scenarios. +//! +//! Builds the artifact set consumed by `smoke_cold` / `smoke_warm` (network +//! DB tarballs, chain specs with `lightSyncState`, smoldot databaseContent +//! dumps). Marked `#[ignore]`: only runs when invoked explicitly with +//! `cargo test … -- --ignored`. +//! +//! Driven by env vars (set when invoking `cargo test`): +//! * `SMOKE_SNAPSHOT_OUT` — output directory (required) +//! * `SMOKE_SNAPSHOT_TARGET_FINALIZED` — snapshot block height (default 100) +//! * `SMOKE_SNAPSHOT_SPEC_AT_FINALIZED` — `lightSyncState` block (default target/2) +//! * `SMOKE_SNAPSHOT_RELAY_DB` — resume validators from this tarball +//! * `SMOKE_SNAPSHOT_PARA_DB` — resume collators from this tarball +//! +//! See `e2e-tests/docs/smoke-scenarios.md` for the produced layout and +//! the regeneration procedure. + +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; +use serde_json::Value; +use smoldot_e2e_tests::{ + ensure_js_deps_installed, ensure_smoldot_built, resolve_base_dir, run_js_test, + FINALIZED_METRIC, PARA_ID, +}; +use zombienet_sdk::{ + subxt::ext::subxt_rpcs::rpc_params, LocalFileSystem, Network, NetworkConfig, + NetworkConfigBuilder, NetworkNode, +}; + +const DEFAULT_TARGET_FINALIZED: u32 = 2500; + +/// Smoldot triggers real warp sync (vs follow-forward) when the gap between +/// `lightSyncState` and current head exceeds this many blocks. +const WARP_SYNC_MINIMUM_GAP: u32 = 32; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "produces large DB snapshots and must be run manually"] +async fn smoke_generate_snapshots() -> Result<(), anyhow::Error> { + let _ = env_logger::try_init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), + ); + + let args = Args::from_env()?; + log::info!( + "smoke_generate_snapshots: out={} spec_at=#{} target_finalized=#{} relay_snap={:?} para_snap={:?}", + args.out.display(), + args.spec_at_finalized, + args.target_finalized, + args.relay_db_snapshot, + args.para_db_snapshot, + ); + + std::fs::create_dir_all(&args.out)?; + let base_dir = resolve_base_dir()?; + let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); + + // Workaround: zombienet caches `with_db_snapshot` by sha256(path) and races + // when two sibling nodes share the same source path (TOCTOU between + // `exists()` and the copy). Pre-stage per-node copies with distinct + // filenames so each gets its own copy. + let staged = stage_per_node_snapshots( + &args.out, + args.relay_db_snapshot.as_deref(), + args.para_db_snapshot.as_deref(), + )?; + + let config = build_config(&base_dir_str, &staged)?; + + log::info!("spawning zombienet network"); + let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); + let network = spawn_fn(config).await?; + + network.wait_until_is_up(120).await?; + log::info!("network is up"); + + let validator = network.get_node("validator-0")?; + + // Step 1: capture the spec when finalized reaches `spec_at_finalized`. + // Doing this earlier than the snapshot makes the gap between + // `lightSyncState` and current head wide enough to trigger smoldot's + // real warp sync (gap > WARP_SYNC_MINIMUM_GAP), which handles + // GRANDPA authority-set changes via fragments — what follow-forward + // does not do. + wait_for_finalized(validator, args.spec_at_finalized).await?; + gen_sync_spec( + network.get_node("validator-0")?, + &args.out.join("relay-spec.json"), + ) + .await?; + write_light_sync_state_spec( + network.get_node("validator-0")?, + &args.out.join("relay-spec.json"), + &args.out.join("relay-spec-lightSyncState.json"), + ) + .await?; + // Cumulus parachains don't expose `sync_state_genSyncSpec` — there's no + // independent finality on a parachain, so there's no `lightSyncState` + // to bake. Smoldot's cold/warm path for the parachain is automatic + // given the relay's `lightSyncState`. Copy the zombienet-emitted raw + // spec verbatim. + let network_base = PathBuf::from( + network + .base_dir() + .ok_or_else(|| anyhow!("no network base_dir"))?, + ); + let parachain = network + .parachain(PARA_ID) + .ok_or_else(|| anyhow!("parachain {PARA_ID} not found"))?; + let para_chain_name = parachain.chain_id().unwrap_or(parachain.unique_id()); + let para_spec_src = network_base.join(format!("{para_chain_name}.json")); + let para_spec_dst = args.out.join("para-spec.json"); + copy_spec_stripping_bootnodes(¶_spec_src, ¶_spec_dst)?; + log::info!( + "copied para spec {} -> {} (bootnodes stripped, {} bytes)", + para_spec_src.display(), + para_spec_dst.display(), + std::fs::metadata(¶_spec_dst)?.len() + ); + write_light_sync_state_spec( + network.get_node("alice")?, + ¶_spec_dst, + &args.out.join("para-spec-lightSyncState.json"), + ) + .await?; + + // Step 2: keep the network running until finalized reaches + // `target_finalized`, then run smoldot to capture its `databaseContent` + // dump while the network is still advancing. + wait_for_finalized(validator, args.target_finalized).await?; + + dump_smoldot_db(&args.out, &network).await?; + + // Step 3: snapshot validator-0 + alice DBs *after* the dump. Tarring + // before would freeze the network DBs at an earlier point than smoldot's + // persisted finalized — when the test later spawns from the snapshot, + // smoldot's persisted block wouldn't yet exist in the validator's DB and + // smoldot would hang on `storage-proof-request-error`. + pause_and_tar( + &network, + "validator-0", + &network_base, + &args.out.join("relaychain-db.tgz"), + ) + .await?; + pause_and_tar( + &network, + "alice", + &network_base, + &args.out.join("parachain-db.tgz"), + ) + .await?; + + create_bundle(&args.out)?; + print_manifest(&args.out)?; + log::info!("done"); + Ok(()) +} + +/// Bundles every artifact in `out` (DB tarballs + full specs + +/// light-sync-state specs + smoldot-db dumps) into a single +/// `bundle.tar.gz`, consumed by `snapshot::ensure_bundle_extracted` at +/// test time. +fn create_bundle(out: &Path) -> Result<(), anyhow::Error> { + let bundle = out.join("bundle.tar.gz"); + log::info!("bundling artifacts -> {}", bundle.display()); + let status = std::process::Command::new("tar") + .arg("-czf") + .arg(&bundle) + .arg("-C") + .arg(out) + .arg("relaychain-db.tgz") + .arg("parachain-db.tgz") + .arg("relay-spec.json") + .arg("para-spec.json") + .arg("relay-spec-lightSyncState.json") + .arg("para-spec-lightSyncState.json") + .arg("smoldot-db") + .status()?; + if !status.success() { + return Err(anyhow!("tar bundle failed (exit {status})")); + } + log::info!( + "wrote {} ({} bytes)", + bundle.display(), + std::fs::metadata(&bundle)?.len() + ); + Ok(()) +} + +/// Writes a light-sync-state copy of `full_spec_path` to `lss_spec_path`: +/// replaces `genesis.raw` (full state KV pairs, MB-sized) with +/// `genesis.stateRootHash` (single hash) so smoldot can load it without +/// computing the genesis state root from scratch. Smoldot logs an INFO line +/// suggesting this exact optimization. Substrate nodes still need the full +/// spec. +/// +/// The state root is fetched from the genesis header on `node`; matches what +/// smoldot computes internally. +async fn write_light_sync_state_spec( + node: &NetworkNode, + full_spec_path: &Path, + lss_spec_path: &Path, +) -> Result<(), anyhow::Error> { + let rpc = node.rpc().await?; + let genesis_hash: Value = rpc + .request("chain_getBlockHash", rpc_params![0_u32]) + .await + .map_err(|e| anyhow!("chain_getBlockHash(0) on {} failed: {e}", node.name()))?; + let genesis_hash_str = genesis_hash + .as_str() + .ok_or_else(|| anyhow!("chain_getBlockHash returned non-string"))?; + let header: Value = rpc + .request("chain_getHeader", rpc_params![genesis_hash_str]) + .await + .map_err(|e| anyhow!("chain_getHeader on {} failed: {e}", node.name()))?; + let state_root = header + .get("stateRoot") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("chain_getHeader missing stateRoot"))? + .to_owned(); + + let mut spec: Value = serde_json::from_slice(&std::fs::read(full_spec_path)?)?; + let genesis = spec + .get_mut("genesis") + .and_then(Value::as_object_mut) + .ok_or_else(|| anyhow!("{}: missing genesis object", full_spec_path.display()))?; + genesis.remove("raw"); + genesis.insert( + "stateRootHash".to_string(), + Value::String(state_root.clone()), + ); + std::fs::write(lss_spec_path, serde_json::to_string_pretty(&spec)?)?; + log::info!( + "wrote {} (stateRootHash={state_root}, {} bytes)", + lss_spec_path.display(), + std::fs::metadata(lss_spec_path)?.len() + ); + Ok(()) +} + +/// Reads `src` as JSON, sets `bootNodes` to `[]`, and writes the result to +/// `dst`. The committed artifact must be port-agnostic (per-spawn ports +/// would invalidate it), so all bootnodes are stripped at generation time +/// and re-injected at consumption time. +fn copy_spec_stripping_bootnodes(src: &Path, dst: &Path) -> Result<(), anyhow::Error> { + let mut spec: Value = serde_json::from_slice(&std::fs::read(src)?)?; + if let Some(obj) = spec.as_object_mut() { + obj.insert("bootNodes".to_string(), Value::Array(Vec::new())); + } + std::fs::write(dst, serde_json::to_string_pretty(&spec)?)?; + Ok(()) +} + +/// Reads `src` as JSON and writes a copy to `dst` with `bootNodes` set to +/// `multiaddrs`. Used to prepare a runtime spec for smoldot from the +/// committed (port-agnostic) artifact. +fn copy_spec_with_bootnodes( + src: &Path, + dst: &Path, + multiaddrs: &[String], +) -> Result<(), anyhow::Error> { + let mut spec: Value = serde_json::from_slice(&std::fs::read(src)?)?; + let array = multiaddrs + .iter() + .map(|m| Value::String(m.clone())) + .collect(); + if let Some(obj) = spec.as_object_mut() { + obj.insert("bootNodes".to_string(), Value::Array(array)); + } + std::fs::write(dst, serde_json::to_string_pretty(&spec)?)?; + Ok(()) +} + +/// Runs `js/smoke.js` against the live network with the freshly produced +/// specs and `SMOLDOT_DB_DUMP_DIR` set, capturing smoldot's persisted +/// `databaseContent` for both chains. Builds runtime spec copies with +/// current bootnode multiaddrs since the committed artifacts have empty +/// `bootNodes`. +async fn dump_smoldot_db( + out: &Path, + network: &Network, +) -> Result<(), anyhow::Error> { + log::info!("building smoldot + JS deps for dump"); + ensure_smoldot_built(); + ensure_js_deps_installed(); + + let smoldot_db_dir = out.join("smoldot-db"); + std::fs::create_dir_all(&smoldot_db_dir)?; + + let relay_bootnodes: Vec = ["validator-0", "validator-1"] + .into_iter() + .map(|n| network.get_node(n).map(|node| node.multiaddr().to_string())) + .collect::>()?; + let para_bootnodes: Vec = ["alice", "bob"] + .into_iter() + .map(|n| network.get_node(n).map(|node| node.multiaddr().to_string())) + .collect::>()?; + log::info!("relay bootnodes: {relay_bootnodes:?}"); + log::info!("para bootnodes: {para_bootnodes:?}"); + + let relay_runtime_spec = out.join("relay-spec.runtime.json"); + let para_runtime_spec = out.join("para-spec.runtime.json"); + copy_spec_with_bootnodes( + &out.join("relay-spec.json"), + &relay_runtime_spec, + &relay_bootnodes, + )?; + copy_spec_with_bootnodes( + &out.join("para-spec.json"), + ¶_runtime_spec, + ¶_bootnodes, + )?; + + let relay_spec_str = relay_runtime_spec.to_str().expect("UTF-8 path").to_owned(); + let para_spec_str = para_runtime_spec.to_str().expect("UTF-8 path").to_owned(); + let dump_str = smoldot_db_dir.to_str().expect("UTF-8 path").to_owned(); + + log::info!( + "running smoldot smoke.js to dump databaseContent into {}", + smoldot_db_dir.display() + ); + run_js_test( + "js/smoke.js", + &[ + ("RELAY_CHAIN_SPEC", relay_spec_str.as_str()), + ("PARA_CHAIN_SPEC", para_spec_str.as_str()), + ("REQUIRED_BLOCKS", "5"), + ("EXPECTED_INITIAL_FINALIZED", "0"), + ("SMOLDOT_DB_DUMP_DIR", dump_str.as_str()), + ], + ) + .await + .map_err(|e| anyhow!("smoldot dump failed: {e}"))?; + + for name in ["relay.json", "para.json"] { + let p = smoldot_db_dir.join(name); + if !p.is_file() { + return Err(anyhow!("smoldot dump missing {}", p.display())); + } + log::info!( + "dump {} ({} bytes)", + p.display(), + std::fs::metadata(&p)?.len() + ); + } + + // Runtime spec copies were a per-spawn aid; not part of the artifact + // set. Remove them so the out dir contains only committable files. + for p in [&relay_runtime_spec, ¶_runtime_spec] { + let _ = std::fs::remove_file(p); + } + Ok(()) +} + +/// Computes a manifest of the artifact files (sha256 + size) and prints +/// suggested constant lines for `e2e-tests/src/snapshot.rs`. Uses +/// `sha256sum` from coreutils. +fn print_manifest(out: &Path) -> Result<(), anyhow::Error> { + let bundle = out.join("bundle.tar.gz"); + if !bundle.is_file() { + return Err(anyhow!("manifest: bundle.tar.gz missing")); + } + let size = std::fs::metadata(&bundle)?.len(); + let hash = sha256_of(&bundle)?; + + println!("\n=== artifact manifest ==="); + println!(" bundle.tar.gz {size:>10} bytes {hash}"); + println!("\n=== snapshot.rs constants ==="); + println!("pub const ARTIFACTS_VERSION: &str = \"v1\";"); + println!("const BUNDLE_SHA256: &str = \"{hash}\";"); + Ok(()) +} + +fn sha256_of(path: &Path) -> Result { + let output = std::process::Command::new("sha256sum").arg(path).output()?; + if !output.status.success() { + return Err(anyhow!( + "sha256sum failed for {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + )); + } + let stdout = String::from_utf8(output.stdout)?; + let hex = stdout + .split_whitespace() + .next() + .ok_or_else(|| anyhow!("empty sha256sum output for {}", path.display()))?; + Ok(hex.to_string()) +} + +async fn wait_for_finalized(node: &NetworkNode, height: u32) -> Result<(), anyhow::Error> { + let target = height as f64; + let timeout_secs = (height as u64 * 12).max(120); + log::info!( + "waiting for {} finalized to reach #{height} (timeout={timeout_secs}s)", + node.name() + ); + node.wait_metric_with_timeout(FINALIZED_METRIC, |h| h >= target, timeout_secs) + .await + .map_err(|e| anyhow!("{} did not reach finalized #{height}: {e}", node.name()))?; + log::info!("{} finalized reached #{height}", node.name()); + Ok(()) +} + +/// Calls `sync_state_genSyncSpec(true)` on `node` and writes the returned +/// raw chain spec (with `lightSyncState`) to `out_path`. +async fn gen_sync_spec(node: &NetworkNode, out_path: &Path) -> Result<(), anyhow::Error> { + log::info!( + "generating sync spec from {} -> {}", + node.name(), + out_path.display() + ); + let rpc = node.rpc().await?; + let spec: Value = rpc + .request("sync_state_genSyncSpec", rpc_params![true]) + .await + .map_err(|e| anyhow!("sync_state_genSyncSpec on {} failed: {e}", node.name()))?; + if spec.get("lightSyncState").is_none() { + return Err(anyhow!( + "spec from {} has no lightSyncState field", + node.name() + )); + } + std::fs::write(out_path, serde_json::to_string_pretty(&spec)?)?; + let size = std::fs::metadata(out_path)?.len(); + log::info!("wrote {} ({} bytes)", out_path.display(), size); + Ok(()) +} + +/// Pauses `node_name`, tars its `data/` dir into `out_tgz`, and resumes it. +/// `network_base` is the zombienet namespace base dir. +async fn pause_and_tar( + network: &Network, + node_name: &str, + network_base: &Path, + out_tgz: &Path, +) -> Result<(), anyhow::Error> { + let node = network.get_node(node_name)?; + log::info!("pausing {node_name} for snapshot"); + node.pause().await?; + + let node_base = network_base.join(node_name); + let data_dir = node_base.join("data"); + if !data_dir.is_dir() { + return Err(anyhow!( + "{node_name} data dir missing at {}", + data_dir.display() + )); + } + log::info!( + "tarring {} -> {} (excluding keystore/)", + data_dir.display(), + out_tgz.display() + ); + // Exclude `keystore/` so a sibling node consuming this snapshot doesn't end + // up with the source node's session keys on top of its own (zombienet + // inserts per-node keys via author_insertKey at startup). Otherwise BOTH + // nodes can author for the same slot and the chain stalls under + // equivocation. + let status = std::process::Command::new("tar") + .arg("-czf") + .arg(out_tgz) + .arg("--exclude=keystore") + .arg("-C") + .arg(&node_base) + .arg("data") + .status()?; + if !status.success() { + return Err(anyhow!("tar failed for {node_name} (exit {status})")); + } + let size = std::fs::metadata(out_tgz)?.len(); + log::info!("wrote {} ({} bytes)", out_tgz.display(), size); + + log::info!("resuming {node_name}"); + node.resume().await?; + Ok(()) +} + +struct Args { + out: PathBuf, + target_finalized: u32, + spec_at_finalized: u32, + relay_db_snapshot: Option, + para_db_snapshot: Option, +} + +impl Args { + fn from_env() -> Result { + let out = std::env::var("SMOKE_SNAPSHOT_OUT") + .map(PathBuf::from) + .map_err(|_| anyhow!("SMOKE_SNAPSHOT_OUT is required (output directory)"))?; + + let target_finalized = + parse_env_u32("SMOKE_SNAPSHOT_TARGET_FINALIZED")?.unwrap_or(DEFAULT_TARGET_FINALIZED); + let spec_at_finalized = + parse_env_u32("SMOKE_SNAPSHOT_SPEC_AT_FINALIZED")?.unwrap_or(target_finalized / 2); + if spec_at_finalized > target_finalized { + return Err(anyhow!( + "SMOKE_SNAPSHOT_SPEC_AT_FINALIZED (#{spec_at_finalized}) must be ≤ \ + SMOKE_SNAPSHOT_TARGET_FINALIZED (#{target_finalized})" + )); + } + let gap = target_finalized.saturating_sub(spec_at_finalized); + if gap > 0 && gap < WARP_SYNC_MINIMUM_GAP { + log::warn!( + "spec→target gap = {gap} < smoldot's warp_sync_minimum_gap ({WARP_SYNC_MINIMUM_GAP}); \ + smoldot will use follow-forward and may stall on GRANDPA rotations" + ); + } + + let relay_db_snapshot = std::env::var("SMOKE_SNAPSHOT_RELAY_DB") + .ok() + .map(PathBuf::from); + let para_db_snapshot = std::env::var("SMOKE_SNAPSHOT_PARA_DB") + .ok() + .map(PathBuf::from); + + Ok(Self { + out, + target_finalized, + spec_at_finalized, + relay_db_snapshot, + para_db_snapshot, + }) + } +} + +fn parse_env_u32(key: &str) -> Result, anyhow::Error> { + match std::env::var(key) { + Ok(v) => { + Ok(Some(v.parse().map_err(|e| { + anyhow!("{key} must be a positive integer: {e}") + })?)) + } + Err(_) => Ok(None), + } +} + +struct StagedSnapshots { + validator_0: Option, + validator_1: Option, + alice: Option, + bob: Option, +} + +fn stage_per_node_snapshots( + out: &Path, + relay_db: Option<&Path>, + para_db: Option<&Path>, +) -> Result { + let stage_dir = out.join("staged-snapshots"); + if relay_db.is_some() || para_db.is_some() { + std::fs::create_dir_all(&stage_dir)?; + } + let stage = |src: &Path, name: &str| -> Result { + let dst = stage_dir.join(format!("{name}.tgz")); + std::fs::copy(src, &dst) + .map_err(|e| anyhow!("copy {} -> {}: {e}", src.display(), dst.display()))?; + Ok(dst.to_str().expect("UTF-8 path").to_owned()) + }; + let (validator_0, validator_1) = match relay_db { + Some(p) => ( + Some(stage(p, "relay-validator-0")?), + Some(stage(p, "relay-validator-1")?), + ), + None => (None, None), + }; + let (alice, bob) = match para_db { + Some(p) => (Some(stage(p, "para-alice")?), Some(stage(p, "para-bob")?)), + None => (None, None), + }; + Ok(StagedSnapshots { + validator_0, + validator_1, + alice, + bob, + }) +} + +fn build_config( + base_dir_str: &str, + staged: &StagedSnapshots, +) -> Result { + let images = zombienet_sdk::environment::get_images_from_env(); + NetworkConfigBuilder::new() + .with_relaychain(|r| { + let r = r + .with_chain("westend-local") + .with_default_command("polkadot") + .with_default_image(images.polkadot.as_str()); + r.with_validator(|n| { + let n = n.with_name("validator-0").bootnode(true); + match staged.validator_0.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + .with_validator(|n| { + let n = n.with_name("validator-1").bootnode(true); + match staged.validator_1.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + }) + .with_parachain(|p| { + let p = p + .with_id(PARA_ID) + .with_default_command("polkadot-parachain") + .with_default_image(images.cumulus.as_str()) + .with_chain("people-westend-local") + .with_default_args(vec![ + "--force-authoring".into(), + "--authoring=slot-based".into(), + ]); + p.with_collator(|n| { + let n = n.with_name("alice").bootnode(true); + match staged.alice.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + .with_collator(|n| { + let n = n.with_name("bob").bootnode(true); + match staged.bob.as_deref() { + Some(p) => n.with_db_snapshot(p), + None => n, + } + }) + }) + .with_global_settings(|g| g.with_base_dir(base_dir_str)) + .build() + .map_err(|errs| { + anyhow!( + "config errors: {}", + errs.into_iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + ) + }) +} diff --git a/e2e-tests/tests/smoke_warm.rs b/e2e-tests/tests/smoke_warm.rs new file mode 100644 index 0000000000..faabcb272a --- /dev/null +++ b/e2e-tests/tests/smoke_warm.rs @@ -0,0 +1,69 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use anyhow::anyhow; +use smoldot_e2e_tests::*; + +const REQUIRED_BLOCKS: u32 = 5; + +/// Warm-startup smoke: spawn westend-local + people-westend-local from +/// committed DB snapshots, hand smoldot a chain spec carrying +/// `lightSyncState` AND a persisted `databaseContent` from the prior +/// session, assert it resumes past the persisted finalized block and +/// sees new parachain blocks. +#[tokio::test(flavor = "multi_thread")] +async fn smoke_warm() -> Result<(), anyhow::Error> { + let _ = env_logger::try_init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), + ); + + let base_dir = resolve_base_dir()?; + let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); + + let cfg = Scenario::Warm { + snapshot: SnapshotPaths { + relay_db_tgz: snapshot::relay_db()?, + para_db_tgz: snapshot::para_db()?, + relay_full_spec: snapshot::relay_spec()?, + para_full_spec: snapshot::para_spec()?, + smoldot_relay_spec: snapshot::relay_spec_light_sync_state()?, + smoldot_para_spec: snapshot::para_spec_light_sync_state()?, + }, + smoldot_db: SmoldotDbPaths { + relay_db_json: snapshot::smoldot_db_relay()?, + para_db_json: snapshot::smoldot_db_para()?, + }, + }; + let live = spawn_scenario(&cfg, &base_dir_str).await?; + + log::info!("checking that alice has produced post-snapshot parachain blocks (best)"); + let alice = live.network.get_node("alice")?; + let baseline = alice.reports(BEST_METRIC).await? as u32; + let target = baseline + REQUIRED_BLOCKS; + alice + .wait_metric_with_timeout(BEST_METRIC, |h| h >= target as f64, 180u64) + .await + .map_err(|e| { + anyhow!( + "alice did not produce {REQUIRED_BLOCKS} parachain blocks past #{baseline}: {e}" + ) + })?; + log::info!("alice reached #{target} (>= baseline+{REQUIRED_BLOCKS})"); + + run_smoke_js(&live, &cfg, REQUIRED_BLOCKS).await?; + Ok(()) +} diff --git a/e2e-tests/tests/statement_store_peer_connection.rs b/e2e-tests/tests/statement_store_peer_connection.rs index 69b4c0057a..19ea2ba1cb 100644 --- a/e2e-tests/tests/statement_store_peer_connection.rs +++ b/e2e-tests/tests/statement_store_peer_connection.rs @@ -39,7 +39,10 @@ async fn recovers_statement_delivery_after_peer_restart() -> Result<(), anyhow:: let base_dir = resolve_base_dir()?; let para_spec_path = create_para_chain_spec_with_allowances(&[pubkey], &base_dir)?; - info!("Parachain chain spec created at {}", para_spec_path.display()); + info!( + "Parachain chain spec created at {}", + para_spec_path.display() + ); let network = spawn_network(&base_dir, ¶_spec_path).await?; info!("Network spawned"); @@ -90,8 +93,7 @@ async fn recovers_statement_delivery_after_peer_restart() -> Result<(), anyhow:: let bob = network.get_node("bob")?; info!("Restarting bob"); - bob - .restart(None) + bob.restart(None) .await .map_err(|e| anyhow::anyhow!("restart(bob) failed: {e}"))?; wait_until_peered(bob, 2, 120).await?; diff --git a/e2e-tests/tests/statement_store_reception.rs b/e2e-tests/tests/statement_store_reception.rs index 33359008ee..c269f22050 100644 --- a/e2e-tests/tests/statement_store_reception.rs +++ b/e2e-tests/tests/statement_store_reception.rs @@ -45,7 +45,10 @@ async fn receives_only_subscribed_statements() -> Result<(), anyhow::Error> { let base_dir = resolve_base_dir()?; let para_spec_path = create_para_chain_spec_with_allowances(&[pubkey], &base_dir)?; - info!("Parachain chain spec created at {}", para_spec_path.display()); + info!( + "Parachain chain spec created at {}", + para_spec_path.display() + ); let network = spawn_network(&base_dir, ¶_spec_path).await?; info!("Network spawned"); diff --git a/e2e-tests/tests/statement_store_submission.rs b/e2e-tests/tests/statement_store_submission.rs index 9e89c4c4ea..9dde31696f 100644 --- a/e2e-tests/tests/statement_store_submission.rs +++ b/e2e-tests/tests/statement_store_submission.rs @@ -16,8 +16,8 @@ // along with this program. If not, see . use log::info; -use smoldot_e2e_tests::*; use smoldot_e2e_tests::statement::*; +use smoldot_e2e_tests::*; /// A statement submitted by smoldot propagates to the full-node network. /// @@ -36,7 +36,10 @@ async fn statement_reaches_full_node() -> Result<(), anyhow::Error> { let base_dir = resolve_base_dir()?; let para_spec_path = create_para_chain_spec_with_allowances(&[pubkey], &base_dir)?; - info!("Parachain chain spec created at {}", para_spec_path.display()); + info!( + "Parachain chain spec created at {}", + para_spec_path.display() + ); let network = spawn_network(&base_dir, ¶_spec_path).await?; info!("Network spawned"); @@ -47,7 +50,10 @@ async fn statement_reaches_full_node() -> Result<(), anyhow::Error> { let topic = [0u8; 32]; let data = b"light-node-submission-test"; let statement_hex = create_test_statement(&seed, &topic, data); - info!("Test statement created ({} bytes encoded)", statement_hex.len() / 2); + info!( + "Test statement created ({} bytes encoded)", + statement_hex.len() / 2 + ); // Subscribe on both collators let alice_rpc = network.get_node("alice")?.rpc().await?;