From 689a8c9a07c34aed9552ff5fc6fddbd5ca68089a Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:18:42 +0000 Subject: [PATCH 01/25] test(smoke): scenario helpers; drop warp-gap wait Refactor smoke.rs onto a ScenarioConfig API admitting fresh/cold/warm cases (cold/warm stubbed). Replace the 50-block warp-gap pre-wait with a first-finalized gate; smoldot now resolves the warp-sync gate via normal finality (6b3cfe2a). Plan in e2e-tests/docs/smoke-scenarios.md. --- e2e-tests/docs/smoke-scenarios.md | 256 ++++++++++++++++++++++++++ e2e-tests/js/helpers.js | 6 + e2e-tests/js/smoke.js | 45 ++++- e2e-tests/src/lib.rs | 6 + e2e-tests/src/network.rs | 292 ++++++++++++++++++++++++++++++ e2e-tests/tests/smoke.rs | 85 +-------- 6 files changed, 613 insertions(+), 77 deletions(-) create mode 100644 e2e-tests/docs/smoke-scenarios.md create mode 100644 e2e-tests/src/network.rs diff --git a/e2e-tests/docs/smoke-scenarios.md b/e2e-tests/docs/smoke-scenarios.md new file mode 100644 index 0000000000..939bea2710 --- /dev/null +++ b/e2e-tests/docs/smoke-scenarios.md @@ -0,0 +1,256 @@ +# Smoldot smoke-test scenarios + +Plan for extending `e2e-tests/tests/smoke.rs` from a single fresh-network case to three scenarios that better reflect real smoldot startup conditions. + +Status: planning. Nothing in this doc is implemented yet. + +## Scenarios + +| Scenario | Chain spec | Smoldot DB | Network | +|---|---|---|---| +| Fresh | vanilla (no `lightSyncState`) | none | spawned from genesis | +| Cold | with `lightSyncState` | none | spawned from DB snapshot | +| Warm | with `lightSyncState` | preloaded `databaseContent` | spawned from DB snapshot | + +- **Fresh** = current `smoke.rs` behaviour. Full warp sync from genesis against a young chain. +- **Cold** = first visit, but spec carries a checkpoint. Smoldot honours `lightSyncState` and warp-syncs from there to current head. +- **Warm** = returning user. Smoldot resumes from persisted `databaseContent` and warp-syncs the gap to current head. + +Note on warm: warp sync still runs in warm. `databaseContent` only persists the latest known finalized header + GrandPa authority set; it does not bridge to the current head. The difference vs fresh is the warp-sync **starting point**, not the presence of warp. + +## Chain choice + +`westend-local` relay + `people-westend-local` parachain, same as the existing fresh smoke. Keeps the three scenarios comparable (only start state differs). Real runtimes, not synthetic ones — closer to what ships to users. + +The `full_node_warp_sync` test in polkadot-sdk uses `rococo-local` + `cumulus-test-runtime`; not adopted here because we want production-shape runtimes. + +## Test structure + +Three separate `#[tokio::test]` functions, sharing helpers: + +- `e2e-tests/tests/smoke_fresh.rs` — current `smoke.rs` slimmed down +- `e2e-tests/tests/smoke_cold.rs` +- `e2e-tests/tests/smoke_warm.rs` + +Reasoning: different setup costs, isolated failure attribution, can run individually via `nextest run -- smoke_warm`. + +All three are must-run in CI. Cold/warm depend on snapshot artifacts being available; failures are intentional loud signals, not `#[ignore]`-gated. + +## Artifact set + +Versioned as a unit. Bumping the version is a single-line change. + +``` +SNAPSHOT_VERSION = "v1" +``` + +### Hosted on GCS (large) + +Bucket: `zombienet-db-snaps` +Prefix: `zombienet/smoldot_smoke_db/v1/` + +- `relaychain-db.tgz` +- `parachain-db.tgz` + +URLs: +- `https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db/v1/relaychain-db.tgz` +- `https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db/v1/parachain-db.tgz` + +### Committed in repo (small) + +Under `e2e-tests/artifacts/v1/`: + +- `relay-spec.json` — westend-local raw spec with `lightSyncState` +- `para-spec.json` — people-westend-local raw spec with `lightSyncState` +- `smoldot-db-relay.json` — `client.databaseContent(relay)` output +- `smoldot-db-para.json` — `client.databaseContent(para)` output + +Specs and smoldot-db JSONs are produced together with the GCS tarballs and must match. Mismatch surfaces as a sync failure — that is the right signal. + +### Local override (dev) + +Env vars skip the GCS download and SHA check: + +- `DB_SNAPSHOT_RELAY_OVERRIDE` — path to local `relaychain-db.tgz` +- `DB_SNAPSHOT_PARA_OVERRIDE` — path to local `parachain-db.tgz` + +Mirrors polkadot-sdk's `full_node_warp_sync` convention. + +## Rust helper surface + +New module `e2e-tests/src/network.rs` (or extend `lib.rs`): + +```rust +pub enum StartMode { + Fresh, + FromSnapshot { relay_db_tgz: PathBuf, para_db_tgz: PathBuf }, +} + +pub enum SpecMode { + Vanilla, + WithLightSyncState { relay: PathBuf, para: PathBuf }, +} + +pub enum SmoldotState { + None, + FromDb { relay_db_json: PathBuf, para_db_json: PathBuf }, +} + +pub struct ScenarioConfig { + pub start: StartMode, + pub spec: SpecMode, + pub smoldot: SmoldotState, +} + +pub struct LiveNetwork { + pub network: zombienet_sdk::Network, + pub relay_spec: PathBuf, + pub para_spec: PathBuf, + pub finalized_floor: u64, +} + +pub async fn spawn_scenario(cfg: &ScenarioConfig) -> Result; + +pub async fn run_smoke_js( + live: &LiveNetwork, + cfg: &ScenarioConfig, + required_blocks: u32, +) -> Result<(), anyhow::Error>; +``` + +Behaviour of `spawn_scenario`: + +1. Build `NetworkConfig`. For `FromSnapshot`, attach `with_db_snapshot()` per node (mirror `full_node_warp_sync/common.rs`). For `Fresh`, current `smoke.rs:46-77` shape. +2. Spawn, `wait_until_is_up`. +3. For `Fresh`, run the existing `WARP_SYNC_GAP` wait against `validator-0`. Skipped for snapshot starts (history is already aged). +4. Resolve `relay_spec` / `para_spec`: + - `Vanilla` → zombienet-emitted JSON from `network.base_dir()`. + - `WithLightSyncState { relay, para }` → use the artifact specs as-is. +5. Compute `finalized_floor`: + - Fresh: `0`. + - Cold: `lightSyncState.finalized_block_height` parsed from the spec. + - Warm: `max(lightSyncState height, finalized height encoded in the smoldot-db JSON)`. + +New module `e2e-tests/src/snapshot.rs`: + +```rust +pub const ARTIFACTS_VERSION: &str = "v1"; + +const GCS_BASE: &str = + "https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db"; +const RELAY_DB_URL: &str = /* GCS_BASE/v1/relaychain-db.tgz */; +const PARA_DB_URL: &str = /* GCS_BASE/v1/parachain-db.tgz */; +const RELAY_DB_SHA256: &str = ""; +const PARA_DB_SHA256: &str = ""; + +pub fn relay_db() -> Result; // override env -> cache -> download+sha-verify +pub fn para_db() -> Result; +pub fn relay_spec() -> PathBuf; // committed artifact +pub fn para_spec() -> PathBuf; +pub fn smoldot_db_relay() -> PathBuf; // committed artifact +pub fn smoldot_db_para() -> PathBuf; +``` + +## JS smoke harness changes + +`e2e-tests/js/smoke.js` gains three optional behaviours, all controlled by env: + +- `SMOLDOT_DB_RELAY` / `SMOLDOT_DB_PARA` — paths read into `databaseContent` and forwarded to `addChain` (warm). +- `FINALIZED_FLOOR` — number; on the `chainHead_v1_follow` `initialized` event, fetch the first finalised header and assert `header.number >= FINALIZED_FLOOR`. Fresh runs with `0` (no-op). +- `SMOLDOT_DB_DUMP_DIR` — generator-only. After seeing the required blocks, write `client.databaseContent(relay)` and `client.databaseContent(para)` into the dir. + +`e2e-tests/js/helpers.js`: `addChainFromSpec` forwards a `databaseContent` option (one-line spread of `opts`). + +## Env-var matrix passed to `js/smoke.js` + +| Env | Fresh | Cold | Warm | +|---|---|---|---| +| `RELAY_CHAIN_SPEC` | zombienet-emitted | artifact spec | artifact spec | +| `PARA_CHAIN_SPEC` | zombienet-emitted | artifact spec | artifact spec | +| `REQUIRED_BLOCKS` | 5 | 5 | 5 | +| `FINALIZED_FLOOR` | 0 | from spec | from db json | +| `SMOLDOT_DB_RELAY` | — | — | committed json | +| `SMOLDOT_DB_PARA` | — | — | committed json | + +## Snapshot generator + +`e2e-tests/src/bin/generate_snapshots.rs`. Manual / scheduled job, never invoked from `cargo test`. + +Outline: + +1. Spawn `westend-local` + `people-westend-local` from genesis with **archive pruning** on the nodes whose DBs will be snapshotted. +2. `wait_until_is_up`, then poll relay finalised height until `--target-finalized` (default ≥1500 — past 2 sessions, covers an authority-set rotation). +3. Pause all relay validators and parachain collators. +4. `tar -czf {out}/relaychain-db.tgz` over each snapshot-source node's data dir under `network.base_dir()`. Same for parachain. +5. Call `state_genSyncSpec(true)` (substrate JSON-RPC) against a still-RPC-reachable full node. Write returned spec — already raw, already containing `lightSyncState` — to `{out}/relay-spec.json`. Repeat for parachain. +6. Resume one relay node + one collator. Run `js/smoke.js` with `SMOLDOT_DB_DUMP_DIR={out}/smoldot-db`, `REQUIRED_BLOCKS=5`. JS dumps `relay.json` / `para.json`. +7. Print manifest: file list + sha256s + the `SNAPSHOT_VERSION` string the test code should pin. + +A `generate-snapshots.sh` wrapper handles `cargo build` + invocation + GCS upload. Upload step is manual — generator binary never pushes to GCS itself. + +## Regeneration procedure + +When runtimes change in a way that breaks the pinned snapshots: + +1. Bump `ARTIFACTS_VERSION` in code (e.g. `v1` → `v2`). +2. Run `generate-snapshots.sh` locally. Inspect the manifest. +3. Upload the two `.tgz` files to `gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/v2/`. +4. Replace committed artifacts under `e2e-tests/artifacts/v2/`. +5. Update `RELAY_DB_SHA256` / `PARA_DB_SHA256` in `snapshot.rs`. +6. Delete the previous `e2e-tests/artifacts/v{N-1}/` dir. + +## CI integration + +Existing setup (`.github/workflows/zombienet.yml` + `.github/zombienet-env`): + +- GitHub Actions, Parity self-hosted runners (`parity-zombienet-native-default` / `-large`). +- Tests run inside the `paritytech/ci-unified` container. +- `actions/cache@v4` already used for cargo registry/target and JS `node_modules`. Keys derived via `hashFiles(...)` of relevant lockfiles. +- 4-job matrix: `smoke` + 3 statement-store tests. + +### Cache strategy + +Add one cache step to the test job, before "Run test": + +```yaml +- name: Cache smoldot e2e snapshots + uses: actions/cache@v4 + with: + path: ~/.cache/smoldot-e2e + key: smoldot-e2e-snapshots-${{ hashFiles('e2e-tests/src/snapshot.rs') }} +``` + +- Key derived from `snapshot.rs` — bumping `ARTIFACTS_VERSION` or any SHA constant invalidates the cache automatically. Same pattern as the existing cargo-cache step. +- Path is inside the container's `~`. Container filesystem is ephemeral per job, so the explicit cache step is needed even on self-hosted runners. +- Matrix-wide step. Statement-store jobs never touch the path; their cache save is empty/no-op. +- No `restore-keys`: a stale partial restore could mask a SHA mismatch. + +Test matrix grows by two entries (`smoke_cold`, `smoke_warm`); rename existing `smoke` → `smoke_fresh` for symmetry. + +### Zombienet-sdk caching — confirmed not sufficient + +Inspected `zombienet-provider-0.4.9/src/native/node.rs:278-337` (`initialize_db_snapshot`): + +- Tarball stored at `{namespace_base_dir}/{sha256(URL_or_path)}.tgz`. Skipped if present. +- `namespace_base_dir` is per-spawn → no cross-run reuse. +- No SHA verification of contents — only the URL string is hashed for the cache-path key. +- `ZOMBIE_RM_TGZ_AFTER_EXTRACT=1` deletes the tarball post-extract. + +So the explicit `actions/cache@v4` step is needed, and our resolver should: + +1. Download the tarball into `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/{relaychain-db,parachain-db}.tgz` if missing. +2. Verify SHA256 against the pinned constant (zombienet doesn't). +3. Hand the local path to `with_db_snapshot()` via `AssetLocation::FilePath(...)`, not the URL. + +## Landing sequence + +1. Helpers + JS changes + refactor existing smoke onto the new shape. Verifiable now — fresh path passes, no snapshots required. +2. Run the generator binary locally → produce v1 artifact set. +3. Commit specs + smoldot-db JSONs under `artifacts/v1/`. Upload tarballs to GCS. Pin SHAs and `ARTIFACTS_VERSION` in `snapshot.rs`. +4. Add `smoke_cold` + `smoke_warm` tests + workflow cache step + matrix entries. +5. Short README under `e2e-tests/artifacts/` documenting regen. + +## Open items + +- Confirm write access to `gs://zombienet-db-snaps/`. +- Future: add a 4th case exercising a runtime upgrade scheduled mid-snapshot-generation. diff --git a/e2e-tests/js/helpers.js b/e2e-tests/js/helpers.js index 9580c3d1b4..04ce36aff6 100644 --- a/e2e-tests/js/helpers.js +++ b/e2e-tests/js/helpers.js @@ -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 = []) { diff --git a/e2e-tests/js/smoke.js b/e2e-tests/js/smoke.js index 53ca24d1d8..f55b2a366d 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 finalizedFloor = Number.parseInt(process.env.FINALIZED_FLOOR ?? "0", 10); +const dbDumpDir = process.env.SMOLDOT_DB_DUMP_DIR; if (!relaySpecPath || !paraSpecPath || !Number.isFinite(requiredBlocks)) { console.error( @@ -40,14 +45,33 @@ 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); + if (finalizedFloor > 0) { + const head = await sendRpcAndWait(relay, "chain_getFinalizedHead", [], 30_000); + const header = await sendRpcAndWait(relay, "chain_getHeader", [head], 30_000); + const num = Number.parseInt(header.number, 16); + const ok = Number.isFinite(num) && num >= finalizedFloor; + report( + "relay finalized clears floor", + ok, + `finalized=#${num} floor=#${finalizedFloor}`, + ); + if (!ok) throw new Error(`relay finalized #${num} below floor #${finalizedFloor}`); + } + const followReqId = sendRpc(para, "chainHead_v1_follow", [false]).toString(); const subId = await readJsonRpcUntil( para, @@ -95,6 +119,25 @@ 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; diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 1f596b27c6..ff7713149c 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -17,8 +17,14 @@ use std::path::{Path, PathBuf}; +pub mod network; pub mod statement; +pub use network::{ + run_smoke_js, spawn_scenario, LiveNetwork, ScenarioConfig, SmoldotState, SpecMode, StartMode, + 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..65433987ca --- /dev/null +++ b/e2e-tests/src/network.rs @@ -0,0 +1,292 @@ +// 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 depend on artifacts not yet committed; their branches are stubbed +//! with `todo!()` and will land alongside `tests/smoke_cold.rs` / +//! `tests/smoke_warm.rs`. See `e2e-tests/docs/smoke-scenarios.md`. + +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; +use zombienet_sdk::{LocalFileSystem, Network, NetworkConfig, NetworkConfigBuilder}; + +pub const PARA_ID: u32 = 1004; +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 enum StartMode { + Fresh, + FromSnapshot { + relay_db_tgz: PathBuf, + para_db_tgz: PathBuf, + }, +} + +pub enum SpecMode { + Vanilla, + WithLightSyncState { relay: PathBuf, para: PathBuf }, +} + +pub enum SmoldotState { + None, + FromDb { + relay_db_json: PathBuf, + para_db_json: PathBuf, + }, +} + +pub struct ScenarioConfig { + pub start: StartMode, + pub spec: SpecMode, + pub smoldot: SmoldotState, +} + +impl ScenarioConfig { + pub fn fresh() -> Self { + Self { + start: StartMode::Fresh, + spec: SpecMode::Vanilla, + smoldot: SmoldotState::None, + } + } +} + +pub struct LiveNetwork { + pub network: Network, + pub relay_spec: PathBuf, + pub para_spec: PathBuf, + /// Floor that smoldot's first reported finalized block must clear. + /// Fresh: 0. Cold: from `lightSyncState`. Warm: max(cold, persisted DB). + pub finalized_floor: u64, +} + +/// Spawns the network described by `cfg` and returns the artifacts smoldot +/// needs (spec paths, finalized floor). 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: &ScenarioConfig, + 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.start, StartMode::Fresh) { + wait_for_relay_first_finalized(&network).await?; + } + + let (relay_spec, para_spec) = match &cfg.spec { + SpecMode::Vanilla => extract_emitted_specs(&network)?, + SpecMode::WithLightSyncState { relay, para } => (relay.clone(), para.clone()), + }; + + let mut finalized_floor = match &cfg.spec { + SpecMode::Vanilla => 0, + SpecMode::WithLightSyncState { relay, .. } => parse_finalized_height_from_spec(relay)?, + }; + if let SmoldotState::FromDb { relay_db_json, .. } = &cfg.smoldot { + let persisted = parse_finalized_height_from_db(relay_db_json)?; + finalized_floor = finalized_floor.max(persisted); + } + + Ok(LiveNetwork { + network, + relay_spec, + para_spec, + finalized_floor, + }) +} + +fn build_network_config( + cfg: &ScenarioConfig, + base_dir_str: &str, +) -> Result { + let images = zombienet_sdk::environment::get_images_from_env(); + + let (relay_db_path, para_db_path) = match &cfg.start { + StartMode::Fresh => (None, None), + StartMode::FromSnapshot { + relay_db_tgz, + para_db_tgz, + } => ( + Some(relay_db_tgz.to_str().expect("UTF-8 path").to_owned()), + Some(para_db_tgz.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()); + match relay_db_path.as_deref() { + None => r + .with_validator(|n| n.with_name("validator-0").bootnode(true)) + .with_validator(|n| n.with_name("validator-1").bootnode(true)), + Some(path) => r + .with_validator(|n| { + n.with_name("validator-0") + .bootnode(true) + .with_db_snapshot(path) + }) + .with_validator(|n| { + n.with_name("validator-1") + .bootnode(true) + .with_db_snapshot(path) + }), + } + }) + .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(), + ]); + match para_db_path.as_deref() { + None => p + .with_collator(|n| n.with_name("alice").bootnode(true)) + .with_collator(|n| n.with_name("bob").bootnode(true)), + Some(path) => p + .with_collator(|n| n.with_name("alice").bootnode(true).with_db_snapshot(path)) + .with_collator(|n| n.with_name("bob").bootnode(true).with_db_snapshot(path)), + } + }) + .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(()) +} + +fn extract_emitted_specs( + 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())); + 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")); + Ok((relay_spec, para_spec)) +} + +fn parse_finalized_height_from_spec(_path: &Path) -> Result { + todo!("cold/warm scenarios — implemented when artifacts/v1 lands") +} + +fn parse_finalized_height_from_db(_path: &Path) -> Result { + todo!("warm scenario — implemented when artifacts/v1 lands") +} + +/// Runs `js/smoke.js` against a live network. Env-injects spec paths, the +/// finalized floor, and (warm only) smoldot DB content paths. +pub async fn run_smoke_js( + live: &LiveNetwork, + cfg: &ScenarioConfig, + 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 floor = live.finalized_floor.to_string(); + + let smoldot_db_paths = match &cfg.smoldot { + SmoldotState::None => None, + SmoldotState::FromDb { + relay_db_json, + para_db_json, + } => Some(( + relay_db_json.to_str().expect("UTF-8 path").to_owned(), + 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()), + ("FINALIZED_FLOOR", floor.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}, floor={floor})" + ); + crate::run_js_test("js/smoke.js", &env_vars) + .await + .map_err(|e| anyhow!("JS test failed: {e}")) +} diff --git a/e2e-tests/tests/smoke.rs b/e2e-tests/tests/smoke.rs index 65a9ea6069..39e92d7fae 100644 --- a/e2e-tests/tests/smoke.rs +++ b/e2e-tests/tests/smoke.rs @@ -15,18 +15,13 @@ // 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. +/// 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() -> Result<(), anyhow::Error> { let _ = env_logger::try_init_from_env( @@ -34,81 +29,19 @@ async fn smoke() -> Result<(), anyhow::Error> { ); 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?; + let cfg = ScenarioConfig::fresh(); + let live = spawn_scenario(&cfg, &base_dir_str).await?; - network + log::info!("checking that alice has ≥{REQUIRED_BLOCKS} parachain blocks (best)"); + live.network .get_node("alice")? - .wait_metric_with_timeout( - "block_height{status=\"best\"}", - |h| h >= REQUIRED_BLOCKS as f64, - 300u64, - ) + .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"); - 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}"))?; - + run_smoke_js(&live, &cfg, REQUIRED_BLOCKS).await?; Ok(()) } From 0d5af4803540c6ea07a0230b807abccb7ba7d7b7 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:18:52 +0000 Subject: [PATCH 02/25] style(e2e): apply cargo +nightly fmt --- e2e-tests/src/statement.rs | 30 +++++++++---------- .../tests/statement_store_peer_connection.rs | 8 +++-- e2e-tests/tests/statement_store_reception.rs | 5 +++- e2e-tests/tests/statement_store_submission.rs | 12 ++++++-- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/e2e-tests/src/statement.rs b/e2e-tests/src/statement.rs index 3eb6a7916f..c14b9d60ba 100644 --- a/e2e-tests/src/statement.rs +++ b/e2e-tests/src/statement.rs @@ -15,21 +15,21 @@ // 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, }; /// Para id used by the statement-store e2e fixture. Zombienet writes the @@ -103,7 +103,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| { @@ -120,8 +123,8 @@ pub async fn spawn_network( .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}"); @@ -245,9 +248,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 +290,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/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?; From 13b127643f7c1f5e997bd835a172cda34ab4a0e7 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:52:18 +0000 Subject: [PATCH 03/25] test(snapshots): add generator binary for cold/warm artifacts Spawns westend-local + people-westend-local from genesis, waits for the relay to reach a target finalized block, then produces: - relaychain-db.tgz / parachain-db.tgz (validator-0 / alice data dirs) - relay-spec.json (sync_state_genSyncSpec; bootNodes stripped) - para-spec.json (zombienet-emitted; bootNodes stripped) - smoldot-db/{relay,para}.json (chainHead_unstable_finalizedDatabase) - sha256 manifest with snapshot.rs constants Run with `cargo run --bin generate_snapshots -- --out DIR --target-finalized N`. Defaults to N=100; bump to ~1500 for the real v1 artifact set. Validated end-to-end at N=20 in ~5 min. --- e2e-tests/src/bin/generate_snapshots.rs | 452 ++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 e2e-tests/src/bin/generate_snapshots.rs diff --git a/e2e-tests/src/bin/generate_snapshots.rs b/e2e-tests/src/bin/generate_snapshots.rs new file mode 100644 index 0000000000..8a0d168b5e --- /dev/null +++ b/e2e-tests/src/bin/generate_snapshots.rs @@ -0,0 +1,452 @@ +// 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). Run manually; never invoked from `cargo test`. +//! +//! 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 = 100; + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> 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::parse()?; + log::info!( + "generate_snapshots: out={} target_finalized=#{}", + args.out.display(), + args.target_finalized + ); + + 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(); + + let config = build_config(&base_dir_str)?; + + 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")?; + let target = args.target_finalized as f64; + let timeout_secs = (args.target_finalized as u64 * 12).max(120); + log::info!( + "waiting for relay finalized to reach #{} (timeout={timeout_secs}s)", + args.target_finalized + ); + validator + .wait_metric_with_timeout(FINALIZED_METRIC, |h| h >= target, timeout_secs) + .await + .map_err(|e| { + anyhow!( + "relay did not reach target finalized #{}: {e}", + args.target_finalized + ) + })?; + log::info!("relay finalized reached #{}", args.target_finalized); + + let network_base = PathBuf::from( + network + .base_dir() + .ok_or_else(|| anyhow!("no network base_dir"))?, + ); + + 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?; + + gen_sync_spec( + network.get_node("validator-0")?, + &args.out.join("relay-spec.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 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() + ); + + dump_smoldot_db(&args.out, &network).await?; + + print_manifest(&args.out)?; + log::info!("done"); + 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"), + ("FINALIZED_FLOOR", "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 entries = [ + ("relaychain-db.tgz", "RELAY_DB_SHA256"), + ("parachain-db.tgz", "PARA_DB_SHA256"), + ("relay-spec.json", "RELAY_SPEC_SHA256"), + ("para-spec.json", "PARA_SPEC_SHA256"), + ("smoldot-db/relay.json", "SMOLDOT_DB_RELAY_SHA256"), + ("smoldot-db/para.json", "SMOLDOT_DB_PARA_SHA256"), + ]; + + println!("\n=== artifact manifest ==="); + let mut consts = String::new(); + for (rel, const_name) in entries { + let path = out.join(rel); + if !path.is_file() { + return Err(anyhow!("manifest: missing {}", path.display())); + } + let size = std::fs::metadata(&path)?.len(); + let hash = sha256_of(&path)?; + println!(" {rel:30} {size:>10} bytes {hash}"); + consts.push_str(&format!("const {const_name}: &str = \"{hash}\";\n")); + } + println!("\n=== snapshot.rs constants ==="); + println!("pub const ARTIFACTS_VERSION: &str = \"v1\";"); + println!("{consts}"); + 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()) +} + +/// 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 {} -> {}", data_dir.display(), out_tgz.display()); + let status = std::process::Command::new("tar") + .arg("-czf") + .arg(out_tgz) + .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, +} + +impl Args { + fn parse() -> Result { + let mut out: Option = None; + let mut target_finalized: Option = None; + + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--out" => { + let v = iter.next().ok_or_else(|| anyhow!("--out needs a value"))?; + out = Some(PathBuf::from(v)); + } + "--target-finalized" => { + let v = iter + .next() + .ok_or_else(|| anyhow!("--target-finalized needs a value"))?; + target_finalized = Some(v.parse().map_err(|e| { + anyhow!("--target-finalized must be a positive integer: {e}") + })?); + } + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + other => return Err(anyhow!("unknown argument: {other}")), + } + } + + Ok(Self { + out: out.ok_or_else(|| anyhow!("--out is required"))?, + target_finalized: target_finalized.unwrap_or(DEFAULT_TARGET_FINALIZED), + }) + } +} + +fn print_help() { + println!( + "usage: generate_snapshots --out [--target-finalized N]\n\ + \n\ + Spawns westend-local + people-westend-local from genesis and waits for\n\ + the relay to reach the target finalized block. Slice A only — produces\n\ + no artifacts yet.\n\ + \n\ + options:\n\ + --out Artifact output directory (created if missing).\n\ + --target-finalized N Target relay finalized block. Default: {}.", + DEFAULT_TARGET_FINALIZED + ); +} + +fn build_config(base_dir_str: &str) -> Result { + let images = zombienet_sdk::environment::get_images_from_env(); + 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)) + .build() + .map_err(|errs| { + anyhow!( + "config errors: {}", + errs.into_iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + ) + }) +} From 37f2b0e1ed0b7984902b20f9cd180905af260c69 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:36:39 +0000 Subject: [PATCH 04/25] test(smoke): wire cold/warm scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the cold/warm branches that were stubbed in the scenario module: - Parse finalized height from chain-spec lightSyncState and from smoldot databaseContent JSONs (smoldot::header::decode). - Inject current bootnode multiaddrs into runtime spec copies, since the committed artifacts have empty bootNodes (port-agnostic by design). - Pass committed specs to zombienet via with_chain_spec_path. Add e2e-tests/src/snapshot.rs as the artifact resolver: env-override path takes priority, then ~/.cache/smoldot-e2e/{version} cache, then GCS download with sha256 verification. SHA constants are placeholder (empty) until the v1 artifact set is published — until then only env override is accepted. Add tests/smoke_cold.rs and tests/smoke_warm.rs scaffolds. --- e2e-tests/src/lib.rs | 1 + e2e-tests/src/network.rs | 125 ++++++++++++++++++++++-- e2e-tests/src/snapshot.rs | 178 ++++++++++++++++++++++++++++++++++ e2e-tests/tests/smoke_cold.rs | 65 +++++++++++++ e2e-tests/tests/smoke_warm.rs | 69 +++++++++++++ 5 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 e2e-tests/src/snapshot.rs create mode 100644 e2e-tests/tests/smoke_cold.rs create mode 100644 e2e-tests/tests/smoke_warm.rs diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index ff7713149c..7580b56c05 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -18,6 +18,7 @@ use std::path::{Path, PathBuf}; pub mod network; +pub mod snapshot; pub mod statement; pub use network::{ diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 65433987ca..be2720c601 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -22,15 +22,18 @@ //! - **Cold**: network from snapshot, spec with `lightSyncState`, no smoldot DB. //! - **Warm**: network from snapshot, spec with `lightSyncState`, smoldot DB preloaded. //! -//! Cold/Warm depend on artifacts not yet committed; their branches are stubbed -//! with `todo!()` and will land alongside `tests/smoke_cold.rs` / -//! `tests/smoke_warm.rs`. See `e2e-tests/docs/smoke-scenarios.md`. +//! Cold/warm consume the artifact set produced by `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 FINALIZED_METRIC: &str = "block_height{status=\"finalized\"}"; pub const BEST_METRIC: &str = "block_height{status=\"best\"}"; @@ -115,7 +118,12 @@ pub async fn spawn_scenario( let (relay_spec, para_spec) = match &cfg.spec { SpecMode::Vanilla => extract_emitted_specs(&network)?, - SpecMode::WithLightSyncState { relay, para } => (relay.clone(), para.clone()), + SpecMode::WithLightSyncState { relay, para } => { + // Committed artifacts have empty `bootNodes` (they're per-spawn + // and would invalidate the artifact). Inject current multiaddrs + // into runtime copies that smoldot will load. + prepare_runtime_specs(&network, relay, para, base_dir_str)? + } }; let mut finalized_floor = match &cfg.spec { @@ -151,6 +159,13 @@ fn build_network_config( Some(para_db_tgz.to_str().expect("UTF-8 path").to_owned()), ), }; + let (relay_spec_path, para_spec_path) = match &cfg.spec { + SpecMode::Vanilla => (None, None), + SpecMode::WithLightSyncState { relay, para } => ( + Some(relay.to_str().expect("UTF-8 path").to_owned()), + Some(para.to_str().expect("UTF-8 path").to_owned()), + ), + }; let builder = NetworkConfigBuilder::new() .with_relaychain(|r| { @@ -158,6 +173,10 @@ fn build_network_config( .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), + }; match relay_db_path.as_deref() { None => r .with_validator(|n| n.with_name("validator-0").bootnode(true)) @@ -185,6 +204,10 @@ fn build_network_config( "--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), + }; match para_db_path.as_deref() { None => p .with_collator(|n| n.with_name("alice").bootnode(true)) @@ -224,6 +247,64 @@ async fn wait_for_relay_first_finalized( Ok(()) } +/// 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(()) +} + fn extract_emitted_specs( network: &Network, ) -> Result<(PathBuf, PathBuf), anyhow::Error> { @@ -241,12 +322,40 @@ fn extract_emitted_specs( Ok((relay_spec, para_spec)) } -fn parse_finalized_height_from_spec(_path: &Path) -> Result { - todo!("cold/warm scenarios — implemented when artifacts/v1 lands") +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())) } -fn parse_finalized_height_from_db(_path: &Path) -> Result { - todo!("warm scenario — implemented when artifacts/v1 lands") +/// Decodes a 0x-prefixed hex SCALE-encoded substrate header and returns its +/// block number. Uses smoldot's own header decoder, kept consistent with what +/// smoldot does when consuming the same artifact at runtime. +fn decode_header_number(hex_with_prefix: &str) -> Result { + let stripped = hex_with_prefix + .strip_prefix("0x") + .ok_or_else(|| anyhow!("header hex missing 0x prefix"))?; + 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 diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs new file mode 100644 index 0000000000..784204c5fd --- /dev/null +++ b/e2e-tests/src/snapshot.rs @@ -0,0 +1,178 @@ +// 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`. +//! +//! Two artifact classes: +//! +//! - **GCS-hosted** (network DB tarballs, ~few-MB each): downloaded into +//! `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/`, SHA256-verified against +//! pinned constants. Bypassed by `DB_SNAPSHOT_*_OVERRIDE` env vars for +//! local generator iteration. +//! - **Committed** (chain specs with `lightSyncState`, smoldot +//! `databaseContent` JSONs): live under +//! `e2e-tests/artifacts/{ARTIFACTS_VERSION}/` and are referenced by path. +//! +//! See `e2e-tests/docs/smoke-scenarios.md` for the production / regeneration +//! procedure and the full layout. + +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"; + +// SHA256 constants are filled in when the corresponding `vN` artifact set +// is published. An empty string means the artifact set hasn't been pinned +// yet — in that case the resolver requires `DB_SNAPSHOT_*_OVERRIDE` env +// vars and refuses to download from GCS. +const RELAY_DB_SHA256: &str = ""; +const PARA_DB_SHA256: &str = ""; + +const RELAY_DB_FILE: &str = "relaychain-db.tgz"; +const PARA_DB_FILE: &str = "parachain-db.tgz"; + +const RELAY_DB_OVERRIDE_ENV: &str = "DB_SNAPSHOT_RELAY_OVERRIDE"; +const PARA_DB_OVERRIDE_ENV: &str = "DB_SNAPSHOT_PARA_OVERRIDE"; + +/// Cached DB tarballs (env override → cache → download + SHA-verify). +pub fn relay_db() -> Result { + resolve_db(RELAY_DB_FILE, RELAY_DB_SHA256, RELAY_DB_OVERRIDE_ENV) +} + +pub fn para_db() -> Result { + resolve_db(PARA_DB_FILE, PARA_DB_SHA256, PARA_DB_OVERRIDE_ENV) +} + +/// Committed chain spec files. Paths are absolute via `CARGO_MANIFEST_DIR`. +pub fn relay_spec() -> PathBuf { + artifacts_dir().join("relay-spec.json") +} + +pub fn para_spec() -> PathBuf { + artifacts_dir().join("para-spec.json") +} + +/// Committed smoldot `databaseContent` dumps used by the warm scenario. +pub fn smoldot_db_relay() -> PathBuf { + artifacts_dir().join("smoldot-db/relay.json") +} + +pub fn smoldot_db_para() -> PathBuf { + artifacts_dir().join("smoldot-db/para.json") +} + +fn artifacts_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("artifacts") + .join(ARTIFACTS_VERSION) +} + +fn resolve_db(file: &str, sha256: &str, override_env: &str) -> Result { + if let Ok(path) = std::env::var(override_env) { + let p = PathBuf::from(path); + if !p.is_file() { + return Err(anyhow!( + "{override_env} points at non-existent file: {}", + p.display() + )); + } + log::info!("snapshot {file}: using local override {}", p.display()); + return Ok(p); + } + + if sha256.is_empty() { + return Err(anyhow!( + "{file} SHA256 not pinned for {ARTIFACTS_VERSION} (placeholder); \ + set {override_env} to a local file" + )); + } + + let cached = cache_dir()?.join(file); + if cached.is_file() { + match verify_sha256(&cached, sha256) { + Ok(()) => { + log::info!("snapshot {file}: cache hit ({})", cached.display()); + return Ok(cached); + } + Err(e) => { + log::warn!("snapshot {file}: cached SHA mismatch ({e}); re-downloading"); + let _ = std::fs::remove_file(&cached); + } + } + } + + let url = format!("{GCS_BASE}/{ARTIFACTS_VERSION}/{file}"); + log::info!("snapshot {file}: downloading {url}"); + download(&url, &cached)?; + verify_sha256(&cached, sha256)?; + Ok(cached) +} + +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("tgz.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 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/tests/smoke_cold.rs b/e2e-tests/tests/smoke_cold.rs new file mode 100644 index 0000000000..389584ad3d --- /dev/null +++ b/e2e-tests/tests/smoke_cold.rs @@ -0,0 +1,65 @@ +// 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 = ScenarioConfig { + start: StartMode::FromSnapshot { + relay_db_tgz: snapshot::relay_db()?, + para_db_tgz: snapshot::para_db()?, + }, + spec: SpecMode::WithLightSyncState { + relay: snapshot::relay_spec(), + para: snapshot::para_spec(), + }, + smoldot: SmoldotState::None, + }; + 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_warm.rs b/e2e-tests/tests/smoke_warm.rs new file mode 100644 index 0000000000..4f7df191af --- /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 = ScenarioConfig { + start: StartMode::FromSnapshot { + relay_db_tgz: snapshot::relay_db()?, + para_db_tgz: snapshot::para_db()?, + }, + spec: SpecMode::WithLightSyncState { + relay: snapshot::relay_spec(), + para: snapshot::para_spec(), + }, + smoldot: SmoldotState::FromDb { + 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(()) +} From 845d308ef5ea8396c145fd293225e78b644a51e9 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:30:19 +0000 Subject: [PATCH 05/25] test(snapshots): split spec/snapshot blocks; resume from existing DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --spec-at-finalized M; capture relay spec at #M, snapshot DBs at #N. Default M = N/2 so the M..N gap exceeds smoldot's warp_sync_minimum_gap (32) and smoldot exercises real warp sync — needed to traverse GRANDPA authority-set rotations between checkpoint and snapshot. - Add --relay-db-snapshot / --para-db-snapshot to resume from a prior run's tarballs instead of starting from genesis. - Pre-stage per-node copies of the snapshot tarballs to dodge the TOCTOU race in zombienet-provider's `with_db_snapshot` cache (sibling nodes sharing one source path corrupt the partially-written file). --- e2e-tests/src/bin/generate_snapshots.rs | 265 +++++++++++++++++++----- 1 file changed, 208 insertions(+), 57 deletions(-) diff --git a/e2e-tests/src/bin/generate_snapshots.rs b/e2e-tests/src/bin/generate_snapshots.rs index 8a0d168b5e..315536da9d 100644 --- a/e2e-tests/src/bin/generate_snapshots.rs +++ b/e2e-tests/src/bin/generate_snapshots.rs @@ -39,6 +39,10 @@ use zombienet_sdk::{ const DEFAULT_TARGET_FINALIZED: u32 = 100; +/// 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::main(flavor = "multi_thread")] async fn main() -> Result<(), anyhow::Error> { let _ = env_logger::try_init_from_env( @@ -47,16 +51,29 @@ async fn main() -> Result<(), anyhow::Error> { let args = Args::parse()?; log::info!( - "generate_snapshots: out={} target_finalized=#{}", + "generate_snapshots: out={} spec_at=#{} target_finalized=#{} relay_snap={:?} para_snap={:?}", args.out.display(), - args.target_finalized + 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(); - let config = build_config(&base_dir_str)?; + // 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 cache slot. + 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(); @@ -66,44 +83,14 @@ async fn main() -> Result<(), anyhow::Error> { log::info!("network is up"); let validator = network.get_node("validator-0")?; - let target = args.target_finalized as f64; - let timeout_secs = (args.target_finalized as u64 * 12).max(120); - log::info!( - "waiting for relay finalized to reach #{} (timeout={timeout_secs}s)", - args.target_finalized - ); - validator - .wait_metric_with_timeout(FINALIZED_METRIC, |h| h >= target, timeout_secs) - .await - .map_err(|e| { - anyhow!( - "relay did not reach target finalized #{}: {e}", - args.target_finalized - ) - })?; - log::info!("relay finalized reached #{}", args.target_finalized); - - let network_base = PathBuf::from( - network - .base_dir() - .ok_or_else(|| anyhow!("no network base_dir"))?, - ); - - 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?; + // 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"), @@ -114,6 +101,11 @@ async fn main() -> Result<(), anyhow::Error> { // 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"))?; @@ -128,6 +120,25 @@ async fn main() -> Result<(), anyhow::Error> { std::fs::metadata(¶_spec_dst)?.len() ); + // Step 2: keep the network running until finalized reaches + // `target_finalized`, then snapshot validator-0 + alice DBs. + wait_for_finalized(validator, args.target_finalized).await?; + + 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?; + dump_smoldot_db(&args.out, &network).await?; print_manifest(&args.out)?; @@ -297,6 +308,20 @@ fn sha256_of(path: &Path) -> Result { 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> { @@ -364,12 +389,18 @@ async fn pause_and_tar( struct Args { out: PathBuf, target_finalized: u32, + spec_at_finalized: u32, + relay_db_snapshot: Option, + para_db_snapshot: Option, } impl Args { fn parse() -> Result { let mut out: Option = None; let mut target_finalized: Option = None; + let mut spec_at_finalized: Option = None; + let mut relay_db_snapshot: Option = None; + let mut para_db_snapshot: Option = None; let mut iter = std::env::args().skip(1); while let Some(arg) = iter.next() { @@ -386,6 +417,26 @@ impl Args { anyhow!("--target-finalized must be a positive integer: {e}") })?); } + "--spec-at-finalized" => { + let v = iter + .next() + .ok_or_else(|| anyhow!("--spec-at-finalized needs a value"))?; + spec_at_finalized = Some(v.parse().map_err(|e| { + anyhow!("--spec-at-finalized must be a positive integer: {e}") + })?); + } + "--relay-db-snapshot" => { + let v = iter + .next() + .ok_or_else(|| anyhow!("--relay-db-snapshot needs a path"))?; + relay_db_snapshot = Some(PathBuf::from(v)); + } + "--para-db-snapshot" => { + let v = iter + .next() + .ok_or_else(|| anyhow!("--para-db-snapshot needs a path"))?; + para_db_snapshot = Some(PathBuf::from(v)); + } "-h" | "--help" => { print_help(); std::process::exit(0); @@ -394,49 +445,149 @@ impl Args { } } + let target_finalized = target_finalized.unwrap_or(DEFAULT_TARGET_FINALIZED); + let spec_at_finalized = spec_at_finalized.unwrap_or_else(|| target_finalized / 2); + if spec_at_finalized > target_finalized { + return Err(anyhow!( + "--spec-at-finalized (#{spec_at_finalized}) must be ≤ --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" + ); + } + Ok(Self { out: out.ok_or_else(|| anyhow!("--out is required"))?, - target_finalized: target_finalized.unwrap_or(DEFAULT_TARGET_FINALIZED), + target_finalized, + spec_at_finalized, + relay_db_snapshot, + para_db_snapshot, }) } } fn print_help() { println!( - "usage: generate_snapshots --out [--target-finalized N]\n\ + "usage: generate_snapshots --out [--target-finalized N] [--spec-at-finalized M]\n\ \n\ - Spawns westend-local + people-westend-local from genesis and waits for\n\ - the relay to reach the target finalized block. Slice A only — produces\n\ - no artifacts yet.\n\ + Spawns westend-local + people-westend-local from genesis. Captures the\n\ + relay sync spec (lightSyncState) at finalized #M, then continues until\n\ + finalized #N to snapshot the node DBs and run smoldot for a\n\ + `databaseContent` dump.\n\ + \n\ + The M..N gap should exceed smoldot's warp_sync_minimum_gap ({}) so\n\ + smoldot exercises real warp sync (handles GRANDPA rotations) rather\n\ + than follow-forward.\n\ \n\ options:\n\ - --out Artifact output directory (created if missing).\n\ - --target-finalized N Target relay finalized block. Default: {}.", - DEFAULT_TARGET_FINALIZED + --out Artifact output directory (created if missing).\n\ + --target-finalized N Snapshot block. Default: {}.\n\ + --spec-at-finalized M Spec lightSyncState block (M ≤ N). Default: N/2.\n\ + --relay-db-snapshot P Resume relay validators from this DB tarball.\n\ + --para-db-snapshot P Resume collators from this DB tarball.\n\ + \n\ + When the *-db-snapshot flags are passed, the network resumes from the\n\ + tarball'd state instead of starting at genesis — useful for extending\n\ + a prior run without paying the cost of re-syncing from #0.", + WARP_SYNC_MINIMUM_GAP, DEFAULT_TARGET_FINALIZED ); } -fn build_config(base_dir_str: &str) -> Result { +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| { - r.with_chain("westend-local") + let 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_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| { - p.with_id(PARA_ID) + 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(), - ]) - .with_collator(|n| n.with_name("alice").bootnode(true)) - .with_collator(|n| n.with_name("bob").bootnode(true)) + ]); + 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() From dd406e3362f9f80dca68a76d0f49bd68f9c3eb12 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 07:36:48 +0000 Subject: [PATCH 06/25] test(snapshots): exclude keystore/ when tarring node DBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling nodes consuming the same snapshot would end up with the source node's session keys in their keystore (zombienet inserts per-node keys via author_insertKey on top, but doesn't remove pre-existing ones). Multiple nodes then try to author for the same slot — chain stalls under BABE/Aura/GRANDPA equivocation. --- e2e-tests/src/bin/generate_snapshots.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/e2e-tests/src/bin/generate_snapshots.rs b/e2e-tests/src/bin/generate_snapshots.rs index 315536da9d..75fa13dbf5 100644 --- a/e2e-tests/src/bin/generate_snapshots.rs +++ b/e2e-tests/src/bin/generate_snapshots.rs @@ -367,10 +367,20 @@ async fn pause_and_tar( data_dir.display() )); } - log::info!("tarring {} -> {}", data_dir.display(), out_tgz.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") From 4d07bc8600640ea5b2a0ab55a8893160ad2bf11b Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 07:37:16 +0000 Subject: [PATCH 07/25] test(smoke): wire cold/warm against GCS-resolved artifacts - network.rs: stage per-node copies of the snapshot tarballs to dodge the TOCTOU race in zombienet's with_db_snapshot cache; tolerate smoldot-db hex headers without 0x prefix. - snapshot.rs: all six artifacts (tarballs, specs, smoldot-db JSONs) resolved via GCS + ~/.cache/smoldot-e2e + sha256 verify. SHA constants pinned for v1. Single ARTIFACTS_DIR_OVERRIDE env points at a local generator output dir for dev iteration. - smoke_{cold,warm}.rs: ? on the resolver calls. - docs/smoke-scenarios.md: updated layout and override docs. --- e2e-tests/docs/smoke-scenarios.md | 34 ++++------ e2e-tests/src/network.rs | 107 +++++++++++++++++++---------- e2e-tests/src/snapshot.rs | 108 ++++++++++++++---------------- e2e-tests/tests/smoke_cold.rs | 4 +- e2e-tests/tests/smoke_warm.rs | 8 +-- 5 files changed, 141 insertions(+), 120 deletions(-) diff --git a/e2e-tests/docs/smoke-scenarios.md b/e2e-tests/docs/smoke-scenarios.md index 939bea2710..d60ef83a09 100644 --- a/e2e-tests/docs/smoke-scenarios.md +++ b/e2e-tests/docs/smoke-scenarios.md @@ -44,37 +44,29 @@ Versioned as a unit. Bumping the version is a single-line change. SNAPSHOT_VERSION = "v1" ``` -### Hosted on GCS (large) +### Hosted on GCS (everything) Bucket: `zombienet-db-snaps` Prefix: `zombienet/smoldot_smoke_db/v1/` -- `relaychain-db.tgz` -- `parachain-db.tgz` +- `relaychain-db.tgz` — relay node DB (validator-0), keystore stripped +- `parachain-db.tgz` — para node DB (alice), keystore stripped +- `relay-spec.json` — westend-local raw spec with `lightSyncState` +- `para-spec.json` — people-westend-local raw spec +- `smoldot-db/relay.json` — smoldot `chainHead_unstable_finalizedDatabase` for relay +- `smoldot-db/para.json` — smoldot `chainHead_unstable_finalizedDatabase` for parachain -URLs: -- `https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db/v1/relaychain-db.tgz` -- `https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db/v1/parachain-db.tgz` - -### Committed in repo (small) - -Under `e2e-tests/artifacts/v1/`: - -- `relay-spec.json` — westend-local raw spec with `lightSyncState` -- `para-spec.json` — people-westend-local raw spec with `lightSyncState` -- `smoldot-db-relay.json` — `client.databaseContent(relay)` output -- `smoldot-db-para.json` — `client.databaseContent(para)` output - -Specs and smoldot-db JSONs are produced together with the GCS tarballs and must match. Mismatch surfaces as a sync failure — that is the right signal. +All six files are downloaded into `~/.cache/smoldot-e2e/v1/` on first use and SHA256-verified against constants pinned in `e2e-tests/src/snapshot.rs`. Repo carries no binary artifacts. ### Local override (dev) -Env vars skip the GCS download and SHA check: +`ARTIFACTS_DIR_OVERRIDE=/path/to/dir` makes every resolver point inside that directory (skipping GCS + SHA verification). The directory layout matches the generator output, so: -- `DB_SNAPSHOT_RELAY_OVERRIDE` — path to local `relaychain-db.tgz` -- `DB_SNAPSHOT_PARA_OVERRIDE` — path to local `parachain-db.tgz` +```bash +ARTIFACTS_DIR_OVERRIDE=/tmp/smoldot-snap-v1 cargo test --test smoke_cold +``` -Mirrors polkadot-sdk's `full_node_warp_sync` convention. +works directly against a fresh `generate_snapshots` run. ## Rust helper surface diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index be2720c601..1e7c627e31 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -149,15 +149,16 @@ fn build_network_config( ) -> Result { let images = zombienet_sdk::environment::get_images_from_env(); - let (relay_db_path, para_db_path) = match &cfg.start { - StartMode::Fresh => (None, None), + // 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.start { + StartMode::Fresh => StagedSnapshots::default(), StartMode::FromSnapshot { relay_db_tgz, para_db_tgz, - } => ( - Some(relay_db_tgz.to_str().expect("UTF-8 path").to_owned()), - Some(para_db_tgz.to_str().expect("UTF-8 path").to_owned()), - ), + } => stage_per_node_snapshots(base_dir_str, relay_db_tgz, para_db_tgz)?, }; let (relay_spec_path, para_spec_path) = match &cfg.spec { SpecMode::Vanilla => (None, None), @@ -177,22 +178,20 @@ fn build_network_config( None => r, Some(p) => r.with_chain_spec_path(p), }; - match relay_db_path.as_deref() { - None => r - .with_validator(|n| n.with_name("validator-0").bootnode(true)) - .with_validator(|n| n.with_name("validator-1").bootnode(true)), - Some(path) => r - .with_validator(|n| { - n.with_name("validator-0") - .bootnode(true) - .with_db_snapshot(path) - }) - .with_validator(|n| { - n.with_name("validator-1") - .bootnode(true) - .with_db_snapshot(path) - }), - } + 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 @@ -208,14 +207,20 @@ fn build_network_config( None => p, Some(path) => p.with_chain_spec_path(path), }; - match para_db_path.as_deref() { - None => p - .with_collator(|n| n.with_name("alice").bootnode(true)) - .with_collator(|n| n.with_name("bob").bootnode(true)), - Some(path) => p - .with_collator(|n| n.with_name("alice").bootnode(true).with_db_snapshot(path)) - .with_collator(|n| n.with_name("bob").bootnode(true).with_db_snapshot(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)); @@ -247,6 +252,35 @@ async fn wait_for_relay_first_finalized( 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/`. @@ -345,13 +379,12 @@ fn parse_finalized_height_from_db(path: &Path) -> Result { decode_header_number(header_hex).map_err(|e| anyhow!("{}: {e}", path.display())) } -/// Decodes a 0x-prefixed hex SCALE-encoded substrate header and returns its -/// block number. Uses smoldot's own header decoder, kept consistent with what -/// smoldot does when consuming the same artifact at runtime. -fn decode_header_number(hex_with_prefix: &str) -> Result { - let stripped = hex_with_prefix - .strip_prefix("0x") - .ok_or_else(|| anyhow!("header hex missing 0x prefix"))?; +/// 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}"))?; diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs index 784204c5fd..91b13d7237 100644 --- a/e2e-tests/src/snapshot.rs +++ b/e2e-tests/src/snapshot.rs @@ -17,18 +17,20 @@ //! Resolves the artifact set consumed by `smoke_cold` / `smoke_warm`. //! -//! Two artifact classes: +//! All files (DB tarballs, chain specs with `lightSyncState`, smoldot +//! `databaseContent` JSONs) are hosted on GCS under +//! `gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/{ARTIFACTS_VERSION}/`. +//! Resolvers cache them under `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/`, +//! SHA256-verified against the pinned constants below. //! -//! - **GCS-hosted** (network DB tarballs, ~few-MB each): downloaded into -//! `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/`, SHA256-verified against -//! pinned constants. Bypassed by `DB_SNAPSHOT_*_OVERRIDE` env vars for -//! local generator iteration. -//! - **Committed** (chain specs with `lightSyncState`, smoldot -//! `databaseContent` JSONs): live under -//! `e2e-tests/artifacts/{ARTIFACTS_VERSION}/` and are referenced by path. +//! For local iteration (e.g. running the generator and validating cold/warm +//! before publishing to GCS), 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; SHA verification is skipped. //! //! See `e2e-tests/docs/smoke-scenarios.md` for the production / regeneration -//! procedure and the full layout. +//! procedure. use std::path::PathBuf; @@ -39,105 +41,99 @@ pub const ARTIFACTS_VERSION: &str = "v1"; const GCS_BASE: &str = "https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db"; -// SHA256 constants are filled in when the corresponding `vN` artifact set -// is published. An empty string means the artifact set hasn't been pinned -// yet — in that case the resolver requires `DB_SNAPSHOT_*_OVERRIDE` env -// vars and refuses to download from GCS. -const RELAY_DB_SHA256: &str = ""; -const PARA_DB_SHA256: &str = ""; +const ARTIFACTS_DIR_OVERRIDE_ENV: &str = "ARTIFACTS_DIR_OVERRIDE"; -const RELAY_DB_FILE: &str = "relaychain-db.tgz"; -const PARA_DB_FILE: &str = "parachain-db.tgz"; +// SHA256 constants are filled in when the corresponding `vN` artifact set is +// published. Empty means not yet pinned — in that case the resolver requires +// `ARTIFACTS_DIR_OVERRIDE` and refuses to download from GCS. +const RELAY_DB_SHA256: &str = "eb05f3a037b54ae83e03a1531f4d94034aa9e2b4d4ff64537f5d64811dcc6623"; +const PARA_DB_SHA256: &str = "9314f2da74200ae2e1c6ec297a3ef4767c63611aa12873b4098eaf71a9bd8089"; +const RELAY_SPEC_SHA256: &str = "f8db9c83f097000121c115e5e6041bb1628490b0bd6ac2bc86ddeed0e023d318"; +const PARA_SPEC_SHA256: &str = "0e7b59f081c5e17e94d4a8d64a601f37955c2a23d95a94a462a29f53458d2a74"; +const SMOLDOT_DB_RELAY_SHA256: &str = + "871a06ff924d1f6ed603dbbf27788625a21b8e9324710fb5d7a286cf349daabd"; +const SMOLDOT_DB_PARA_SHA256: &str = + "6d202ea61aa192e911c4c3e5c91c9da2a4fa6adbe288f32ca40a247f5a50020e"; -const RELAY_DB_OVERRIDE_ENV: &str = "DB_SNAPSHOT_RELAY_OVERRIDE"; -const PARA_DB_OVERRIDE_ENV: &str = "DB_SNAPSHOT_PARA_OVERRIDE"; - -/// Cached DB tarballs (env override → cache → download + SHA-verify). pub fn relay_db() -> Result { - resolve_db(RELAY_DB_FILE, RELAY_DB_SHA256, RELAY_DB_OVERRIDE_ENV) + resolve("relaychain-db.tgz", RELAY_DB_SHA256) } pub fn para_db() -> Result { - resolve_db(PARA_DB_FILE, PARA_DB_SHA256, PARA_DB_OVERRIDE_ENV) -} - -/// Committed chain spec files. Paths are absolute via `CARGO_MANIFEST_DIR`. -pub fn relay_spec() -> PathBuf { - artifacts_dir().join("relay-spec.json") + resolve("parachain-db.tgz", PARA_DB_SHA256) } -pub fn para_spec() -> PathBuf { - artifacts_dir().join("para-spec.json") +pub fn relay_spec() -> Result { + resolve("relay-spec.json", RELAY_SPEC_SHA256) } -/// Committed smoldot `databaseContent` dumps used by the warm scenario. -pub fn smoldot_db_relay() -> PathBuf { - artifacts_dir().join("smoldot-db/relay.json") +pub fn para_spec() -> Result { + resolve("para-spec.json", PARA_SPEC_SHA256) } -pub fn smoldot_db_para() -> PathBuf { - artifacts_dir().join("smoldot-db/para.json") +pub fn smoldot_db_relay() -> Result { + resolve("smoldot-db/relay.json", SMOLDOT_DB_RELAY_SHA256) } -fn artifacts_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("artifacts") - .join(ARTIFACTS_VERSION) +pub fn smoldot_db_para() -> Result { + resolve("smoldot-db/para.json", SMOLDOT_DB_PARA_SHA256) } -fn resolve_db(file: &str, sha256: &str, override_env: &str) -> Result { - if let Ok(path) = std::env::var(override_env) { - let p = PathBuf::from(path); +fn resolve(rel: &str, sha256: &str) -> Result { + if let Ok(dir) = std::env::var(ARTIFACTS_DIR_OVERRIDE_ENV) { + let p = PathBuf::from(dir).join(rel); if !p.is_file() { return Err(anyhow!( - "{override_env} points at non-existent file: {}", + "{ARTIFACTS_DIR_OVERRIDE_ENV}: {} does not exist", p.display() )); } - log::info!("snapshot {file}: using local override {}", p.display()); + log::info!("snapshot {rel}: using local override {}", p.display()); return Ok(p); } if sha256.is_empty() { return Err(anyhow!( - "{file} SHA256 not pinned for {ARTIFACTS_VERSION} (placeholder); \ - set {override_env} to a local file" + "{rel} SHA256 not pinned for {ARTIFACTS_VERSION} (placeholder); \ + set {ARTIFACTS_DIR_OVERRIDE_ENV} to a local artifact directory" )); } - let cached = cache_dir()?.join(file); + let cached = cache_path(rel)?; if cached.is_file() { match verify_sha256(&cached, sha256) { Ok(()) => { - log::info!("snapshot {file}: cache hit ({})", cached.display()); + log::info!("snapshot {rel}: cache hit ({})", cached.display()); return Ok(cached); } Err(e) => { - log::warn!("snapshot {file}: cached SHA mismatch ({e}); re-downloading"); + log::warn!("snapshot {rel}: cached SHA mismatch ({e}); re-downloading"); let _ = std::fs::remove_file(&cached); } } } - let url = format!("{GCS_BASE}/{ARTIFACTS_VERSION}/{file}"); - log::info!("snapshot {file}: downloading {url}"); + let url = format!("{GCS_BASE}/{ARTIFACTS_VERSION}/{rel}"); + log::info!("snapshot {rel}: downloading {url}"); download(&url, &cached)?; verify_sha256(&cached, sha256)?; Ok(cached) } -fn cache_dir() -> Result { +fn cache_path(rel: &str) -> 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) + let path = base.join("smoldot-e2e").join(ARTIFACTS_VERSION).join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(path) } fn download(url: &str, dst: &std::path::Path) -> Result<(), anyhow::Error> { - let tmp = dst.with_extension("tgz.partial"); + let tmp = dst.with_extension("partial"); let status = std::process::Command::new("curl") .arg("-fL") .arg("--retry") diff --git a/e2e-tests/tests/smoke_cold.rs b/e2e-tests/tests/smoke_cold.rs index 389584ad3d..3d0d97bc40 100644 --- a/e2e-tests/tests/smoke_cold.rs +++ b/e2e-tests/tests/smoke_cold.rs @@ -39,8 +39,8 @@ async fn smoke_cold() -> Result<(), anyhow::Error> { para_db_tgz: snapshot::para_db()?, }, spec: SpecMode::WithLightSyncState { - relay: snapshot::relay_spec(), - para: snapshot::para_spec(), + relay: snapshot::relay_spec()?, + para: snapshot::para_spec()?, }, smoldot: SmoldotState::None, }; diff --git a/e2e-tests/tests/smoke_warm.rs b/e2e-tests/tests/smoke_warm.rs index 4f7df191af..b31ee02d3c 100644 --- a/e2e-tests/tests/smoke_warm.rs +++ b/e2e-tests/tests/smoke_warm.rs @@ -40,12 +40,12 @@ async fn smoke_warm() -> Result<(), anyhow::Error> { para_db_tgz: snapshot::para_db()?, }, spec: SpecMode::WithLightSyncState { - relay: snapshot::relay_spec(), - para: snapshot::para_spec(), + relay: snapshot::relay_spec()?, + para: snapshot::para_spec()?, }, smoldot: SmoldotState::FromDb { - relay_db_json: snapshot::smoldot_db_relay(), - para_db_json: snapshot::smoldot_db_para(), + relay_db_json: snapshot::smoldot_db_relay()?, + para_db_json: snapshot::smoldot_db_para()?, }, }; let live = spawn_scenario(&cfg, &base_dir_str).await?; From 7730e5ae9c28e7703b57029f8b66d410cd41bff1 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 07:38:30 +0000 Subject: [PATCH 08/25] test(smoke): rename smoke -> smoke_fresh for symmetry with cold/warm --- .github/workflows/zombienet.yml | 4 ++-- e2e-tests/tests/{smoke.rs => smoke_fresh.rs} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename e2e-tests/tests/{smoke.rs => smoke_fresh.rs} (97%) diff --git a/.github/workflows/zombienet.yml b/.github/workflows/zombienet.yml index 42aa7d104c..fa7fbabdd7 100644 --- a/.github/workflows/zombienet.yml +++ b/.github/workflows/zombienet.yml @@ -56,8 +56,8 @@ jobs: fail-fast: false matrix: test: - - job-name: "zombienet-smoldot-0000-smoke" - test: "smoke" + - job-name: "zombienet-smoldot-0000-smoke_fresh" + test: "smoke_fresh" runner-type: "default" - job-name: "zombienet-smoldot-0001-statement_store_submission" test: "statement_store_submission" diff --git a/e2e-tests/tests/smoke.rs b/e2e-tests/tests/smoke_fresh.rs similarity index 97% rename from e2e-tests/tests/smoke.rs rename to e2e-tests/tests/smoke_fresh.rs index 39e92d7fae..ca97de76fa 100644 --- a/e2e-tests/tests/smoke.rs +++ b/e2e-tests/tests/smoke_fresh.rs @@ -23,7 +23,7 @@ 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() -> Result<(), anyhow::Error> { +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"), ); From 15e1c3f9496e8ddf518baf19d01d4c9a57390248 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 08:24:10 +0000 Subject: [PATCH 09/25] test(snapshots): bundle artifacts; add lightSyncState specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generator now produces a light-sync-state copy of each chain spec (genesis.raw → genesis.stateRootHash) alongside the full one. Smoldot loads the small spec; substrate keeps using the full one. Cuts smoldot init time and shrinks the artifact ~30x for these specs. - All published artifacts are wrapped into a single bundle.tar.gz on GCS; snapshot.rs downloads + sha-verifies once per ARTIFACTS_VERSION and extracts in place. Single SHA constant replaces the per-file matrix. - Reset BUNDLE_SHA256 to placeholder; will be filled in once the regen with the new shape lands. - Trimmed docs/smoke-scenarios.md to scenario summary, bundle layout, and regeneration procedure (drops content already in source). --- e2e-tests/docs/smoke-scenarios.md | 281 ++++++------------------ e2e-tests/src/bin/generate_snapshots.rs | 123 +++++++++-- e2e-tests/src/network.rs | 49 ++++- e2e-tests/src/snapshot.rs | 154 ++++++++----- e2e-tests/tests/smoke_cold.rs | 6 +- e2e-tests/tests/smoke_warm.rs | 6 +- 6 files changed, 309 insertions(+), 310 deletions(-) diff --git a/e2e-tests/docs/smoke-scenarios.md b/e2e-tests/docs/smoke-scenarios.md index d60ef83a09..7912ca9150 100644 --- a/e2e-tests/docs/smoke-scenarios.md +++ b/e2e-tests/docs/smoke-scenarios.md @@ -1,248 +1,99 @@ # Smoldot smoke-test scenarios -Plan for extending `e2e-tests/tests/smoke.rs` from a single fresh-network case to three scenarios that better reflect real smoldot startup conditions. +Three smoke tests exercise distinct smoldot startup conditions: -Status: planning. Nothing in this doc is implemented yet. +| 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` | -## Scenarios +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. -| Scenario | Chain spec | Smoldot DB | Network | -|---|---|---|---| -| Fresh | vanilla (no `lightSyncState`) | none | spawned from genesis | -| Cold | with `lightSyncState` | none | spawned from DB snapshot | -| Warm | with `lightSyncState` | preloaded `databaseContent` | spawned from DB snapshot | +Chain: `westend-local` relay + `people-westend-local` parachain. Same as fresh, so all three scenarios are directly comparable. -- **Fresh** = current `smoke.rs` behaviour. Full warp sync from genesis against a young chain. -- **Cold** = first visit, but spec carries a checkpoint. Smoldot honours `lightSyncState` and warp-syncs from there to current head. -- **Warm** = returning user. Smoldot resumes from persisted `databaseContent` and warp-syncs the gap to current head. +## Artifact bundle -Note on warm: warp sync still runs in warm. `databaseContent` only persists the latest known finalized header + GrandPa authority set; it does not bridge to the current head. The difference vs fresh is the warp-sync **starting point**, not the presence of warp. +Single GCS object per version: -## Chain choice - -`westend-local` relay + `people-westend-local` parachain, same as the existing fresh smoke. Keeps the three scenarios comparable (only start state differs). Real runtimes, not synthetic ones — closer to what ships to users. - -The `full_node_warp_sync` test in polkadot-sdk uses `rococo-local` + `cumulus-test-runtime`; not adopted here because we want production-shape runtimes. - -## Test structure - -Three separate `#[tokio::test]` functions, sharing helpers: - -- `e2e-tests/tests/smoke_fresh.rs` — current `smoke.rs` slimmed down -- `e2e-tests/tests/smoke_cold.rs` -- `e2e-tests/tests/smoke_warm.rs` - -Reasoning: different setup costs, isolated failure attribution, can run individually via `nextest run -- smoke_warm`. - -All three are must-run in CI. Cold/warm depend on snapshot artifacts being available; failures are intentional loud signals, not `#[ignore]`-gated. - -## Artifact set - -Versioned as a unit. Bumping the version is a single-line change. - -``` -SNAPSHOT_VERSION = "v1" -``` - -### Hosted on GCS (everything) - -Bucket: `zombienet-db-snaps` -Prefix: `zombienet/smoldot_smoke_db/v1/` - -- `relaychain-db.tgz` — relay node DB (validator-0), keystore stripped -- `parachain-db.tgz` — para node DB (alice), keystore stripped -- `relay-spec.json` — westend-local raw spec with `lightSyncState` -- `para-spec.json` — people-westend-local raw spec -- `smoldot-db/relay.json` — smoldot `chainHead_unstable_finalizedDatabase` for relay -- `smoldot-db/para.json` — smoldot `chainHead_unstable_finalizedDatabase` for parachain - -All six files are downloaded into `~/.cache/smoldot-e2e/v1/` on first use and SHA256-verified against constants pinned in `e2e-tests/src/snapshot.rs`. Repo carries no binary artifacts. - -### Local override (dev) - -`ARTIFACTS_DIR_OVERRIDE=/path/to/dir` makes every resolver point inside that directory (skipping GCS + SHA verification). The directory layout matches the generator output, so: - -```bash -ARTIFACTS_DIR_OVERRIDE=/tmp/smoldot-snap-v1 cargo test --test smoke_cold -``` - -works directly against a fresh `generate_snapshots` run. - -## Rust helper surface - -New module `e2e-tests/src/network.rs` (or extend `lib.rs`): - -```rust -pub enum StartMode { - Fresh, - FromSnapshot { relay_db_tgz: PathBuf, para_db_tgz: PathBuf }, -} - -pub enum SpecMode { - Vanilla, - WithLightSyncState { relay: PathBuf, para: PathBuf }, -} - -pub enum SmoldotState { - None, - FromDb { relay_db_json: PathBuf, para_db_json: PathBuf }, -} - -pub struct ScenarioConfig { - pub start: StartMode, - pub spec: SpecMode, - pub smoldot: SmoldotState, -} - -pub struct LiveNetwork { - pub network: zombienet_sdk::Network, - pub relay_spec: PathBuf, - pub para_spec: PathBuf, - pub finalized_floor: u64, -} - -pub async fn spawn_scenario(cfg: &ScenarioConfig) -> Result; - -pub async fn run_smoke_js( - live: &LiveNetwork, - cfg: &ScenarioConfig, - required_blocks: u32, -) -> Result<(), anyhow::Error>; ``` - -Behaviour of `spawn_scenario`: - -1. Build `NetworkConfig`. For `FromSnapshot`, attach `with_db_snapshot()` per node (mirror `full_node_warp_sync/common.rs`). For `Fresh`, current `smoke.rs:46-77` shape. -2. Spawn, `wait_until_is_up`. -3. For `Fresh`, run the existing `WARP_SYNC_GAP` wait against `validator-0`. Skipped for snapshot starts (history is already aged). -4. Resolve `relay_spec` / `para_spec`: - - `Vanilla` → zombienet-emitted JSON from `network.base_dir()`. - - `WithLightSyncState { relay, para }` → use the artifact specs as-is. -5. Compute `finalized_floor`: - - Fresh: `0`. - - Cold: `lightSyncState.finalized_block_height` parsed from the spec. - - Warm: `max(lightSyncState height, finalized height encoded in the smoldot-db JSON)`. - -New module `e2e-tests/src/snapshot.rs`: - -```rust -pub const ARTIFACTS_VERSION: &str = "v1"; - -const GCS_BASE: &str = - "https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db"; -const RELAY_DB_URL: &str = /* GCS_BASE/v1/relaychain-db.tgz */; -const PARA_DB_URL: &str = /* GCS_BASE/v1/parachain-db.tgz */; -const RELAY_DB_SHA256: &str = ""; -const PARA_DB_SHA256: &str = ""; - -pub fn relay_db() -> Result; // override env -> cache -> download+sha-verify -pub fn para_db() -> Result; -pub fn relay_spec() -> PathBuf; // committed artifact -pub fn para_spec() -> PathBuf; -pub fn smoldot_db_relay() -> PathBuf; // committed artifact -pub fn smoldot_db_para() -> PathBuf; +gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/{ARTIFACTS_VERSION}/bundle.tar.gz ``` -## JS smoke harness changes - -`e2e-tests/js/smoke.js` gains three optional behaviours, all controlled by env: - -- `SMOLDOT_DB_RELAY` / `SMOLDOT_DB_PARA` — paths read into `databaseContent` and forwarded to `addChain` (warm). -- `FINALIZED_FLOOR` — number; on the `chainHead_v1_follow` `initialized` event, fetch the first finalised header and assert `header.number >= FINALIZED_FLOOR`. Fresh runs with `0` (no-op). -- `SMOLDOT_DB_DUMP_DIR` — generator-only. After seeing the required blocks, write `client.databaseContent(relay)` and `client.databaseContent(para)` into the dir. - -`e2e-tests/js/helpers.js`: `addChainFromSpec` forwards a `databaseContent` option (one-line spread of `opts`). - -## Env-var matrix passed to `js/smoke.js` +Contains: -| Env | Fresh | Cold | Warm | -|---|---|---|---| -| `RELAY_CHAIN_SPEC` | zombienet-emitted | artifact spec | artifact spec | -| `PARA_CHAIN_SPEC` | zombienet-emitted | artifact spec | artifact spec | -| `REQUIRED_BLOCKS` | 5 | 5 | 5 | -| `FINALIZED_FLOOR` | 0 | from spec | from db json | -| `SMOLDOT_DB_RELAY` | — | — | committed json | -| `SMOLDOT_DB_PARA` | — | — | committed json | +- `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 -## Snapshot generator +`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. -`e2e-tests/src/bin/generate_snapshots.rs`. Manual / scheduled job, never invoked from `cargo test`. +For local iteration: `ARTIFACTS_DIR_OVERRIDE=/path/to/dir` skips download/verify and uses files directly from that directory. -Outline: +## Regenerating the artifact bundle -1. Spawn `westend-local` + `people-westend-local` from genesis with **archive pruning** on the nodes whose DBs will be snapshotted. -2. `wait_until_is_up`, then poll relay finalised height until `--target-finalized` (default ≥1500 — past 2 sessions, covers an authority-set rotation). -3. Pause all relay validators and parachain collators. -4. `tar -czf {out}/relaychain-db.tgz` over each snapshot-source node's data dir under `network.base_dir()`. Same for parachain. -5. Call `state_genSyncSpec(true)` (substrate JSON-RPC) against a still-RPC-reachable full node. Write returned spec — already raw, already containing `lightSyncState` — to `{out}/relay-spec.json`. Repeat for parachain. -6. Resume one relay node + one collator. Run `js/smoke.js` with `SMOLDOT_DB_DUMP_DIR={out}/smoldot-db`, `REQUIRED_BLOCKS=5`. JS dumps `relay.json` / `para.json`. -7. Print manifest: file list + sha256s + the `SNAPSHOT_VERSION` string the test code should pin. +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. -A `generate-snapshots.sh` wrapper handles `cargo build` + invocation + GCS upload. Upload step is manual — generator binary never pushes to GCS itself. +Steps (from `e2e-tests/`): -## Regeneration procedure +1. **Bump version** in `src/snapshot.rs`: + ```rust + pub const ARTIFACTS_VERSION: &str = "v2"; // or whatever + ``` -When runtimes change in a way that breaks the pinned snapshots: +2. **Build the generator** and **run it** to produce a fresh bundle. Either start from genesis (~3 h for `--target-finalized=2000`) or resume from an existing source DB (~50 min): -1. Bump `ARTIFACTS_VERSION` in code (e.g. `v1` → `v2`). -2. Run `generate-snapshots.sh` locally. Inspect the manifest. -3. Upload the two `.tgz` files to `gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/v2/`. -4. Replace committed artifacts under `e2e-tests/artifacts/v2/`. -5. Update `RELAY_DB_SHA256` / `PARA_DB_SHA256` in `snapshot.rs`. -6. Delete the previous `e2e-tests/artifacts/v{N-1}/` dir. + ```bash + cargo build --release --bin generate_snapshots -## CI integration + # from genesis: + ./target/release/generate_snapshots \ + --out /tmp/smoldot-snap-v2 \ + --target-finalized 2000 -Existing setup (`.github/workflows/zombienet.yml` + `.github/zombienet-env`): - -- GitHub Actions, Parity self-hosted runners (`parity-zombienet-native-default` / `-large`). -- Tests run inside the `paritytech/ci-unified` container. -- `actions/cache@v4` already used for cargo registry/target and JS `node_modules`. Keys derived via `hashFiles(...)` of relevant lockfiles. -- 4-job matrix: `smoke` + 3 statement-store tests. - -### Cache strategy - -Add one cache step to the test job, before "Run test": - -```yaml -- name: Cache smoldot e2e snapshots - uses: actions/cache@v4 - with: - path: ~/.cache/smoldot-e2e - key: smoldot-e2e-snapshots-${{ hashFiles('e2e-tests/src/snapshot.rs') }} -``` + # or resume: + ./target/release/generate_snapshots \ + --out /tmp/smoldot-snap-v2 \ + --target-finalized 2000 --spec-at-finalized 1525 \ + --relay-db-snapshot /path/to/old/relaychain-db.tgz \ + --para-db-snapshot /path/to/old/parachain-db.tgz + ``` -- Key derived from `snapshot.rs` — bumping `ARTIFACTS_VERSION` or any SHA constant invalidates the cache automatically. Same pattern as the existing cargo-cache step. -- Path is inside the container's `~`. Container filesystem is ephemeral per job, so the explicit cache step is needed even on self-hosted runners. -- Matrix-wide step. Statement-store jobs never touch the path; their cache save is empty/no-op. -- No `restore-keys`: a stale partial restore could mask a SHA mismatch. + Required: `ZOMBIE_PROVIDER=native`, polkadot/polkadot-parachain on `PATH`. The generator's `--help` lists all flags. -Test matrix grows by two entries (`smoke_cold`, `smoke_warm`); rename existing `smoke` → `smoke_fresh` for symmetry. + It produces `bundle.tar.gz` under `--out` and prints the SHA256 in the manifest at the end. -### Zombienet-sdk caching — confirmed not sufficient +3. **Verify locally** before publishing: -Inspected `zombienet-provider-0.4.9/src/native/node.rs:278-337` (`initialize_db_snapshot`): + ```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 + ``` -- Tarball stored at `{namespace_base_dir}/{sha256(URL_or_path)}.tgz`. Skipped if present. -- `namespace_base_dir` is per-spawn → no cross-run reuse. -- No SHA verification of contents — only the URL string is hashed for the cache-path key. -- `ZOMBIE_RM_TGZ_AFTER_EXTRACT=1` deletes the tarball post-extract. + 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. -So the explicit `actions/cache@v4` step is needed, and our resolver should: +4. **Publish**: + ```bash + gsutil cp /tmp/smoldot-snap-v2/bundle.tar.gz \ + gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/v2/bundle.tar.gz + ``` -1. Download the tarball into `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/{relaychain-db,parachain-db}.tgz` if missing. -2. Verify SHA256 against the pinned constant (zombienet doesn't). -3. Hand the local path to `with_db_snapshot()` via `AssetLocation::FilePath(...)`, not the URL. +5. **Pin the new SHA** in `src/snapshot.rs` (copy the value from the generator manifest): + ```rust + const BUNDLE_SHA256: &str = ""; + ``` -## Landing sequence +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. -1. Helpers + JS changes + refactor existing smoke onto the new shape. Verifiable now — fresh path passes, no snapshots required. -2. Run the generator binary locally → produce v1 artifact set. -3. Commit specs + smoldot-db JSONs under `artifacts/v1/`. Upload tarballs to GCS. Pin SHAs and `ARTIFACTS_VERSION` in `snapshot.rs`. -4. Add `smoke_cold` + `smoke_warm` tests + workflow cache step + matrix entries. -5. Short README under `e2e-tests/artifacts/` documenting regen. +7. Commit, open PR, run cold/warm tests in CI to confirm GCS download + extract path works end-to-end. -## Open items +## Notes on common pitfalls -- Confirm write access to `gs://zombienet-db-snaps/`. -- Future: add a 4th case exercising a runtime upgrade scheduled mid-snapshot-generation. +- **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/src/bin/generate_snapshots.rs b/e2e-tests/src/bin/generate_snapshots.rs index 75fa13dbf5..6f75077f16 100644 --- a/e2e-tests/src/bin/generate_snapshots.rs +++ b/e2e-tests/src/bin/generate_snapshots.rs @@ -96,6 +96,12 @@ async fn main() -> Result<(), anyhow::Error> { &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 @@ -119,6 +125,12 @@ async fn main() -> Result<(), anyhow::Error> { 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 snapshot validator-0 + alice DBs. @@ -141,11 +153,94 @@ async fn main() -> Result<(), anyhow::Error> { dump_smoldot_db(&args.out, &network).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 @@ -264,30 +359,18 @@ async fn dump_smoldot_db( /// suggested constant lines for `e2e-tests/src/snapshot.rs`. Uses /// `sha256sum` from coreutils. fn print_manifest(out: &Path) -> Result<(), anyhow::Error> { - let entries = [ - ("relaychain-db.tgz", "RELAY_DB_SHA256"), - ("parachain-db.tgz", "PARA_DB_SHA256"), - ("relay-spec.json", "RELAY_SPEC_SHA256"), - ("para-spec.json", "PARA_SPEC_SHA256"), - ("smoldot-db/relay.json", "SMOLDOT_DB_RELAY_SHA256"), - ("smoldot-db/para.json", "SMOLDOT_DB_PARA_SHA256"), - ]; + 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 ==="); - let mut consts = String::new(); - for (rel, const_name) in entries { - let path = out.join(rel); - if !path.is_file() { - return Err(anyhow!("manifest: missing {}", path.display())); - } - let size = std::fs::metadata(&path)?.len(); - let hash = sha256_of(&path)?; - println!(" {rel:30} {size:>10} bytes {hash}"); - consts.push_str(&format!("const {const_name}: &str = \"{hash}\";\n")); - } + println!(" bundle.tar.gz {size:>10} bytes {hash}"); println!("\n=== snapshot.rs constants ==="); println!("pub const ARTIFACTS_VERSION: &str = \"v1\";"); - println!("{consts}"); + println!("const BUNDLE_SHA256: &str = \"{hash}\";"); Ok(()) } diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 1e7c627e31..1ef627779f 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -54,7 +54,17 @@ pub enum StartMode { pub enum SpecMode { Vanilla, - WithLightSyncState { relay: PathBuf, para: PathBuf }, + WithLightSyncState { + /// Full chain spec with `genesis.raw`. Passed to substrate via + /// `with_chain_spec_path` so node DB extraction matches. + relay_full: PathBuf, + para_full: PathBuf, + /// Spec with `genesis.stateRootHash` only (no full state) plus the + /// `lightSyncState` checkpoint — what smoldot loads. Faster init, + /// smaller artifact. + relay_light_sync_state: PathBuf, + para_light_sync_state: PathBuf, + }, } pub enum SmoldotState { @@ -118,17 +128,31 @@ pub async fn spawn_scenario( let (relay_spec, para_spec) = match &cfg.spec { SpecMode::Vanilla => extract_emitted_specs(&network)?, - SpecMode::WithLightSyncState { relay, para } => { - // Committed artifacts have empty `bootNodes` (they're per-spawn - // and would invalidate the artifact). Inject current multiaddrs - // into runtime copies that smoldot will load. - prepare_runtime_specs(&network, relay, para, base_dir_str)? + SpecMode::WithLightSyncState { + relay_light_sync_state, + para_light_sync_state, + .. + } => { + // Light-sync-state specs (genesis.stateRootHash + lightSyncState) + // are what smoldot loads. Published artifacts have empty + // `bootNodes`; inject current multiaddrs into runtime copies. + prepare_runtime_specs( + &network, + relay_light_sync_state, + para_light_sync_state, + base_dir_str, + )? } }; let mut finalized_floor = match &cfg.spec { SpecMode::Vanilla => 0, - SpecMode::WithLightSyncState { relay, .. } => parse_finalized_height_from_spec(relay)?, + // lightSyncState is in both full and light-sync-state specs; use the + // smaller one. + SpecMode::WithLightSyncState { + relay_light_sync_state, + .. + } => parse_finalized_height_from_spec(relay_light_sync_state)?, }; if let SmoldotState::FromDb { relay_db_json, .. } = &cfg.smoldot { let persisted = parse_finalized_height_from_db(relay_db_json)?; @@ -160,11 +184,16 @@ fn build_network_config( para_db_tgz, } => stage_per_node_snapshots(base_dir_str, relay_db_tgz, para_db_tgz)?, }; + // Substrate gets the *full* spec — it needs `genesis.raw` to bootstrap. let (relay_spec_path, para_spec_path) = match &cfg.spec { SpecMode::Vanilla => (None, None), - SpecMode::WithLightSyncState { relay, para } => ( - Some(relay.to_str().expect("UTF-8 path").to_owned()), - Some(para.to_str().expect("UTF-8 path").to_owned()), + SpecMode::WithLightSyncState { + relay_full, + para_full, + .. + } => ( + Some(relay_full.to_str().expect("UTF-8 path").to_owned()), + Some(para_full.to_str().expect("UTF-8 path").to_owned()), ), }; diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs index 91b13d7237..b0f703c816 100644 --- a/e2e-tests/src/snapshot.rs +++ b/e2e-tests/src/snapshot.rs @@ -17,20 +17,20 @@ //! Resolves the artifact set consumed by `smoke_cold` / `smoke_warm`. //! -//! All files (DB tarballs, chain specs with `lightSyncState`, smoldot -//! `databaseContent` JSONs) are hosted on GCS under -//! `gs://zombienet-db-snaps/zombienet/smoldot_smoke_db/{ARTIFACTS_VERSION}/`. -//! Resolvers cache them under `~/.cache/smoldot-e2e/{ARTIFACTS_VERSION}/`, -//! SHA256-verified against the pinned constants below. +//! 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 (e.g. running the generator and validating cold/warm -//! before publishing to GCS), 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; SHA verification is skipped. +//! 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 production / regeneration -//! procedure. +//! See `e2e-tests/docs/smoke-scenarios.md` for the full layout and the +//! regeneration procedure. use std::path::PathBuf; @@ -41,95 +41,111 @@ 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 constants are filled in when the corresponding `vN` artifact set is -// published. Empty means not yet pinned — in that case the resolver requires -// `ARTIFACTS_DIR_OVERRIDE` and refuses to download from GCS. -const RELAY_DB_SHA256: &str = "eb05f3a037b54ae83e03a1531f4d94034aa9e2b4d4ff64537f5d64811dcc6623"; -const PARA_DB_SHA256: &str = "9314f2da74200ae2e1c6ec297a3ef4767c63611aa12873b4098eaf71a9bd8089"; -const RELAY_SPEC_SHA256: &str = "f8db9c83f097000121c115e5e6041bb1628490b0bd6ac2bc86ddeed0e023d318"; -const PARA_SPEC_SHA256: &str = "0e7b59f081c5e17e94d4a8d64a601f37955c2a23d95a94a462a29f53458d2a74"; -const SMOLDOT_DB_RELAY_SHA256: &str = - "871a06ff924d1f6ed603dbbf27788625a21b8e9324710fb5d7a286cf349daabd"; -const SMOLDOT_DB_PARA_SHA256: &str = - "6d202ea61aa192e911c4c3e5c91c9da2a4fa6adbe288f32ca40a247f5a50020e"; +/// 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 = ""; pub fn relay_db() -> Result { - resolve("relaychain-db.tgz", RELAY_DB_SHA256) + resolve("relaychain-db.tgz") } pub fn para_db() -> Result { - resolve("parachain-db.tgz", PARA_DB_SHA256) + resolve("parachain-db.tgz") } pub fn relay_spec() -> Result { - resolve("relay-spec.json", RELAY_SPEC_SHA256) + resolve("relay-spec.json") } pub fn para_spec() -> Result { - resolve("para-spec.json", PARA_SPEC_SHA256) + 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", SMOLDOT_DB_RELAY_SHA256) + resolve("smoldot-db/relay.json") } pub fn smoldot_db_para() -> Result { - resolve("smoldot-db/para.json", SMOLDOT_DB_PARA_SHA256) + 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 resolve(rel: &str, sha256: &str) -> Result { +fn ensure_bundle_extracted() -> Result { if let Ok(dir) = std::env::var(ARTIFACTS_DIR_OVERRIDE_ENV) { - let p = PathBuf::from(dir).join(rel); - if !p.is_file() { + let p = PathBuf::from(dir); + if !p.is_dir() { return Err(anyhow!( - "{ARTIFACTS_DIR_OVERRIDE_ENV}: {} does not exist", + "{ARTIFACTS_DIR_OVERRIDE_ENV}: {} is not a directory", p.display() )); } - log::info!("snapshot {rel}: using local override {}", p.display()); + log::info!("snapshot: using local override {}", p.display()); return Ok(p); } - if sha256.is_empty() { + 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!( - "{rel} SHA256 not pinned for {ARTIFACTS_VERSION} (placeholder); \ + "BUNDLE_SHA256 not pinned for {ARTIFACTS_VERSION} (placeholder); \ set {ARTIFACTS_DIR_OVERRIDE_ENV} to a local artifact directory" )); } - let cached = cache_path(rel)?; - if cached.is_file() { - match verify_sha256(&cached, sha256) { - Ok(()) => { - log::info!("snapshot {rel}: cache hit ({})", cached.display()); - return Ok(cached); - } - Err(e) => { - log::warn!("snapshot {rel}: cached SHA mismatch ({e}); re-downloading"); - let _ = std::fs::remove_file(&cached); - } - } - } - - let url = format!("{GCS_BASE}/{ARTIFACTS_VERSION}/{rel}"); - log::info!("snapshot {rel}: downloading {url}"); - download(&url, &cached)?; - verify_sha256(&cached, sha256)?; - Ok(cached) + 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_path(rel: &str) -> Result { +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 path = base.join("smoldot-e2e").join(ARTIFACTS_VERSION).join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - Ok(path) + 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> { @@ -150,6 +166,22 @@ fn download(url: &str, dst: &std::path::Path) -> Result<(), anyhow::Error> { 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() { diff --git a/e2e-tests/tests/smoke_cold.rs b/e2e-tests/tests/smoke_cold.rs index 3d0d97bc40..2cafd9571f 100644 --- a/e2e-tests/tests/smoke_cold.rs +++ b/e2e-tests/tests/smoke_cold.rs @@ -39,8 +39,10 @@ async fn smoke_cold() -> Result<(), anyhow::Error> { para_db_tgz: snapshot::para_db()?, }, spec: SpecMode::WithLightSyncState { - relay: snapshot::relay_spec()?, - para: snapshot::para_spec()?, + relay_full: snapshot::relay_spec()?, + para_full: snapshot::para_spec()?, + relay_light_sync_state: snapshot::relay_spec_light_sync_state()?, + para_light_sync_state: snapshot::para_spec_light_sync_state()?, }, smoldot: SmoldotState::None, }; diff --git a/e2e-tests/tests/smoke_warm.rs b/e2e-tests/tests/smoke_warm.rs index b31ee02d3c..aa7b3b9313 100644 --- a/e2e-tests/tests/smoke_warm.rs +++ b/e2e-tests/tests/smoke_warm.rs @@ -40,8 +40,10 @@ async fn smoke_warm() -> Result<(), anyhow::Error> { para_db_tgz: snapshot::para_db()?, }, spec: SpecMode::WithLightSyncState { - relay: snapshot::relay_spec()?, - para: snapshot::para_spec()?, + relay_full: snapshot::relay_spec()?, + para_full: snapshot::para_spec()?, + relay_light_sync_state: snapshot::relay_spec_light_sync_state()?, + para_light_sync_state: snapshot::para_spec_light_sync_state()?, }, smoldot: SmoldotState::FromDb { relay_db_json: snapshot::smoldot_db_relay()?, From d6fbcaa3ac4aaa1c6883b7468d8fa3be0262714f Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 08:39:57 +0000 Subject: [PATCH 10/25] test(smoke): timestamp JS log lines ISO 8601 prefix on smoldot logCallback output and PASS/FAIL report lines, so cross-correlating with Rust-side env_logger entries during test debugging stops being a guessing game. --- e2e-tests/js/helpers.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e-tests/js/helpers.js b/e2e-tests/js/helpers.js index 04ce36aff6..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}`); }, }); } @@ -138,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; } } From 8816ac5c0055a33ef7b771f6fee6070dc19279f5 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 10:58:22 +0000 Subject: [PATCH 11/25] test(snapshots): dump smoldot before tarring node DBs Tarring before the smoldot dump captured the network DBs at an earlier point than smoldot's persisted finalized. When the test later spawned from the snapshot, smoldot's persisted block didn't yet exist in the validator's DB and smoldot stalled on storage-proof-request-error trying to fetch the runtime there. --- e2e-tests/src/bin/generate_snapshots.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/e2e-tests/src/bin/generate_snapshots.rs b/e2e-tests/src/bin/generate_snapshots.rs index 6f75077f16..18d045ba11 100644 --- a/e2e-tests/src/bin/generate_snapshots.rs +++ b/e2e-tests/src/bin/generate_snapshots.rs @@ -133,9 +133,17 @@ async fn main() -> Result<(), anyhow::Error> { .await?; // Step 2: keep the network running until finalized reaches - // `target_finalized`, then snapshot validator-0 + alice DBs. + // `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", @@ -151,8 +159,6 @@ async fn main() -> Result<(), anyhow::Error> { ) .await?; - dump_smoldot_db(&args.out, &network).await?; - create_bundle(&args.out)?; print_manifest(&args.out)?; log::info!("done"); @@ -328,7 +334,7 @@ async fn dump_smoldot_db( ("RELAY_CHAIN_SPEC", relay_spec_str.as_str()), ("PARA_CHAIN_SPEC", para_spec_str.as_str()), ("REQUIRED_BLOCKS", "5"), - ("FINALIZED_FLOOR", "0"), + ("EXPECTED_INITIAL_FINALIZED", "0"), ("SMOLDOT_DB_DUMP_DIR", dump_str.as_str()), ], ) From 17c58f1e4d49cae55005c68bd9021c69d839e916 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 11:01:00 +0000 Subject: [PATCH 12/25] test(smoke): floor check via chainHead_v1, not legacy chain_getFinalizedHead Smoldot blocks legacy JSON-RPC functions until the warp-sync gate has opened, so calling chain_getFinalizedHead right after addChain races the gate and the warm path times out. Subscribe chainHead_v1_follow on the relay, wait for the `initialized` event (which fires after warp sync), grab the newest finalized block hash, fetch its header via chainHead_v1_header, and decode the block number from the SCALE compact-encoded number. Also bump the para chainHead deadline to 180s so warm-path catch-up has headroom. --- e2e-tests/js/smoke.js | 81 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/e2e-tests/js/smoke.js b/e2e-tests/js/smoke.js index f55b2a366d..7b7c975f2a 100644 --- a/e2e-tests/js/smoke.js +++ b/e2e-tests/js/smoke.js @@ -39,6 +39,27 @@ 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; @@ -59,17 +80,65 @@ try { }); 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 (finalizedFloor > 0) { - const head = await sendRpcAndWait(relay, "chain_getFinalizedHead", [], 30_000); - const header = await sendRpcAndWait(relay, "chain_getHeader", [head], 30_000); - const num = Number.parseInt(header.number, 16); - const ok = Number.isFinite(num) && num >= finalizedFloor; + 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 >= finalizedFloor; report( "relay finalized clears floor", ok, `finalized=#${num} floor=#${finalizedFloor}`, ); - if (!ok) throw new Error(`relay finalized #${num} below floor #${finalizedFloor}`); + if (!ok) + throw new Error( + `relay finalized #${num} below floor #${finalizedFloor}`, + ); } const followReqId = sendRpc(para, "chainHead_v1_follow", [false]).toString(); @@ -109,7 +178,7 @@ try { } return undefined; }, - Date.now() + 120_000, + Date.now() + 180_000, ); const ok = newBlocks >= requiredBlocks; From 0f07258f8855aae4f0043cf6545487f4392ce49b Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 11:01:16 +0000 Subject: [PATCH 13/25] test(smoke): rename finalized_floor -> expected_initial_finalized The lower-bound assertion on smoldot's first reported finalized block exists to prove smoldot honoured the artifact's checkpoint (didn't fall back to genesis). The new name says what it is and what's expected of it. Rust finalized_floor -> expected_initial_finalized JS finalizedFloor -> expectedInitialFinalized env FINALIZED_FLOOR -> EXPECTED_INITIAL_FINALIZED --- e2e-tests/js/smoke.js | 12 ++++++------ e2e-tests/src/network.rs | 29 ++++++++++++++++------------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/e2e-tests/js/smoke.js b/e2e-tests/js/smoke.js index 7b7c975f2a..9421da51cd 100644 --- a/e2e-tests/js/smoke.js +++ b/e2e-tests/js/smoke.js @@ -29,7 +29,7 @@ import { const relaySpecPath = process.env.RELAY_CHAIN_SPEC; const paraSpecPath = process.env.PARA_CHAIN_SPEC; const requiredBlocks = Number.parseInt(process.env.REQUIRED_BLOCKS, 10); -const finalizedFloor = Number.parseInt(process.env.FINALIZED_FLOOR ?? "0", 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)) { @@ -85,7 +85,7 @@ try { // (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 (finalizedFloor > 0) { + if (expectedInitialFinalized > 0) { const relayFollowReqId = sendRpc(relay, "chainHead_v1_follow", [false]).toString(); const relaySubId = await readJsonRpcUntil( relay, @@ -129,15 +129,15 @@ try { 30_000, ); const num = decodeHeaderNumber(headerHex); - const ok = num >= finalizedFloor; + const ok = num >= expectedInitialFinalized; report( - "relay finalized clears floor", + "relay finalized at-or-past expected_initial_finalized", ok, - `finalized=#${num} floor=#${finalizedFloor}`, + `finalized=#${num} expected=#${expectedInitialFinalized}`, ); if (!ok) throw new Error( - `relay finalized #${num} below floor #${finalizedFloor}`, + `relay finalized #${num} below expected_initial_finalized #${expectedInitialFinalized}`, ); } diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 1ef627779f..5594808718 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -95,15 +95,17 @@ pub struct LiveNetwork { pub network: Network, pub relay_spec: PathBuf, pub para_spec: PathBuf, - /// Floor that smoldot's first reported finalized block must clear. - /// Fresh: 0. Cold: from `lightSyncState`. Warm: max(cold, persisted DB). - pub finalized_floor: u64, + /// 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, finalized floor). Builds smoldot + JS deps in parallel -/// with node startup so the test is ready to drive smoldot as soon as the -/// network is up. +/// 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: &ScenarioConfig, base_dir_str: &str, @@ -145,7 +147,7 @@ pub async fn spawn_scenario( } }; - let mut finalized_floor = match &cfg.spec { + let mut expected_initial_finalized = match &cfg.spec { SpecMode::Vanilla => 0, // lightSyncState is in both full and light-sync-state specs; use the // smaller one. @@ -156,14 +158,14 @@ pub async fn spawn_scenario( }; if let SmoldotState::FromDb { relay_db_json, .. } = &cfg.smoldot { let persisted = parse_finalized_height_from_db(relay_db_json)?; - finalized_floor = finalized_floor.max(persisted); + expected_initial_finalized = expected_initial_finalized.max(persisted); } Ok(LiveNetwork { network, relay_spec, para_spec, - finalized_floor, + expected_initial_finalized, }) } @@ -421,7 +423,8 @@ fn decode_header_number(hex_str: &str) -> Result { } /// Runs `js/smoke.js` against a live network. Env-injects spec paths, the -/// finalized floor, and (warm only) smoldot DB content paths. +/// expected-initial-finalized floor, and (warm only) smoldot DB content +/// paths. pub async fn run_smoke_js( live: &LiveNetwork, cfg: &ScenarioConfig, @@ -430,7 +433,7 @@ pub async fn run_smoke_js( 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 floor = live.finalized_floor.to_string(); + let expected_finalized = live.expected_initial_finalized.to_string(); let smoldot_db_paths = match &cfg.smoldot { SmoldotState::None => None, @@ -447,7 +450,7 @@ pub async fn run_smoke_js( ("RELAY_CHAIN_SPEC", relay_spec_str), ("PARA_CHAIN_SPEC", para_spec_str), ("REQUIRED_BLOCKS", required.as_str()), - ("FINALIZED_FLOOR", floor.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())); @@ -455,7 +458,7 @@ pub async fn run_smoke_js( } log::info!( - "running smoldot JS smoke test (relay_spec={relay_spec_str}, para_spec={para_spec_str}, required_blocks={required_blocks}, floor={floor})" + "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 From cd5262cd1e74f598f3c70d7fcb76db6ac3829a4c Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Fri, 1 May 2026 13:20:37 +0000 Subject: [PATCH 14/25] test(snapshots): pin v1 BUNDLE_SHA256 --- e2e-tests/src/snapshot.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs index b0f703c816..2c0be52db1 100644 --- a/e2e-tests/src/snapshot.rs +++ b/e2e-tests/src/snapshot.rs @@ -46,7 +46,7 @@ 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 = ""; +const BUNDLE_SHA256: &str = "57b7c07bffcf0a92605d5201ef7ac497ccd1af7615151a709b95b4caa5e0383b"; pub fn relay_db() -> Result { resolve("relaychain-db.tgz") From 08de99eba8853f04cb18f5a0d16cf5936c9607ee Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Mon, 4 May 2026 21:09:50 +0000 Subject: [PATCH 15/25] refactor(network): replace 3 mode enums with single Scenario enum --- e2e-tests/src/lib.rs | 2 +- e2e-tests/src/network.rs | 159 +++++++++++++++------------------ e2e-tests/tests/smoke_cold.rs | 21 ++--- e2e-tests/tests/smoke_fresh.rs | 2 +- e2e-tests/tests/smoke_warm.rs | 16 ++-- 5 files changed, 87 insertions(+), 113 deletions(-) diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 7580b56c05..12830d6138 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -22,7 +22,7 @@ pub mod snapshot; pub mod statement; pub use network::{ - run_smoke_js, spawn_scenario, LiveNetwork, ScenarioConfig, SmoldotState, SpecMode, StartMode, + run_smoke_js, spawn_scenario, LiveNetwork, Scenario, SmoldotDbPaths, SnapshotPaths, BEST_METRIC, FINALIZED_METRIC, PARA_ID, }; diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 5594808718..c99d19e8c4 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -44,49 +44,50 @@ pub const BEST_METRIC: &str = "block_height{status=\"best\"}"; /// downstream smoldot timeout. const RELAY_FIRST_FINALIZED_TIMEOUT_SECS: u64 = 120; -pub enum StartMode { - Fresh, - FromSnapshot { - relay_db_tgz: PathBuf, - para_db_tgz: PathBuf, - }, +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 enum SpecMode { - Vanilla, - WithLightSyncState { - /// Full chain spec with `genesis.raw`. Passed to substrate via - /// `with_chain_spec_path` so node DB extraction matches. - relay_full: PathBuf, - para_full: PathBuf, - /// Spec with `genesis.stateRootHash` only (no full state) plus the - /// `lightSyncState` checkpoint — what smoldot loads. Faster init, - /// smaller artifact. - relay_light_sync_state: PathBuf, - para_light_sync_state: PathBuf, - }, +pub struct SmoldotDbPaths { + pub relay_db_json: PathBuf, + pub para_db_json: PathBuf, } -pub enum SmoldotState { - None, - FromDb { - relay_db_json: PathBuf, - 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, }, } -pub struct ScenarioConfig { - pub start: StartMode, - pub spec: SpecMode, - pub smoldot: SmoldotState, -} +impl Scenario { + fn snapshot(&self) -> Option<&SnapshotPaths> { + match self { + Scenario::Fresh => None, + Scenario::Cold(s) | Scenario::Warm { snapshot: s, .. } => Some(s), + } + } -impl ScenarioConfig { - pub fn fresh() -> Self { - Self { - start: StartMode::Fresh, - spec: SpecMode::Vanilla, - smoldot: SmoldotState::None, + fn smoldot_db(&self) -> Option<&SmoldotDbPaths> { + match self { + Scenario::Warm { smoldot_db, .. } => Some(smoldot_db), + _ => None, } } } @@ -107,7 +108,7 @@ pub struct LiveNetwork { /// 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: &ScenarioConfig, + cfg: &Scenario, base_dir_str: &str, ) -> Result { let config = build_network_config(cfg, base_dir_str)?; @@ -124,40 +125,31 @@ pub async fn spawn_scenario( network.wait_until_is_up(120).await?; log::info!("network is up"); - if matches!(cfg.start, StartMode::Fresh) { + if matches!(cfg, Scenario::Fresh) { wait_for_relay_first_finalized(&network).await?; } - let (relay_spec, para_spec) = match &cfg.spec { - SpecMode::Vanilla => extract_emitted_specs(&network)?, - SpecMode::WithLightSyncState { - relay_light_sync_state, - para_light_sync_state, - .. - } => { - // Light-sync-state specs (genesis.stateRootHash + lightSyncState) - // are what smoldot loads. Published artifacts have empty - // `bootNodes`; inject current multiaddrs into runtime copies. - prepare_runtime_specs( - &network, - relay_light_sync_state, - para_light_sync_state, - base_dir_str, - )? - } + let (relay_spec, para_spec) = match cfg.snapshot() { + None => extract_emitted_specs(&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.spec { - SpecMode::Vanilla => 0, + let mut expected_initial_finalized = match cfg.snapshot() { + None => 0, // lightSyncState is in both full and light-sync-state specs; use the // smaller one. - SpecMode::WithLightSyncState { - relay_light_sync_state, - .. - } => parse_finalized_height_from_spec(relay_light_sync_state)?, + Some(s) => parse_finalized_height_from_spec(&s.smoldot_relay_spec)?, }; - if let SmoldotState::FromDb { relay_db_json, .. } = &cfg.smoldot { - let persisted = parse_finalized_height_from_db(relay_db_json)?; + 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); } @@ -170,7 +162,7 @@ pub async fn spawn_scenario( } fn build_network_config( - cfg: &ScenarioConfig, + cfg: &Scenario, base_dir_str: &str, ) -> Result { let images = zombienet_sdk::environment::get_images_from_env(); @@ -179,23 +171,16 @@ fn build_network_config( // 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.start { - StartMode::Fresh => StagedSnapshots::default(), - StartMode::FromSnapshot { - relay_db_tgz, - para_db_tgz, - } => stage_per_node_snapshots(base_dir_str, relay_db_tgz, para_db_tgz)?, + 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.spec { - SpecMode::Vanilla => (None, None), - SpecMode::WithLightSyncState { - relay_full, - para_full, - .. - } => ( - Some(relay_full.to_str().expect("UTF-8 path").to_owned()), - Some(para_full.to_str().expect("UTF-8 path").to_owned()), + 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()), ), }; @@ -427,7 +412,7 @@ fn decode_header_number(hex_str: &str) -> Result { /// paths. pub async fn run_smoke_js( live: &LiveNetwork, - cfg: &ScenarioConfig, + cfg: &Scenario, required_blocks: u32, ) -> Result<(), anyhow::Error> { let relay_spec_str = live.relay_spec.to_str().expect("UTF-8 path"); @@ -435,16 +420,12 @@ pub async fn run_smoke_js( let required = required_blocks.to_string(); let expected_finalized = live.expected_initial_finalized.to_string(); - let smoldot_db_paths = match &cfg.smoldot { - SmoldotState::None => None, - SmoldotState::FromDb { - relay_db_json, - para_db_json, - } => Some(( - relay_db_json.to_str().expect("UTF-8 path").to_owned(), - para_db_json.to_str().expect("UTF-8 path").to_owned(), - )), - }; + 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), diff --git a/e2e-tests/tests/smoke_cold.rs b/e2e-tests/tests/smoke_cold.rs index 2cafd9571f..b3181ed0cc 100644 --- a/e2e-tests/tests/smoke_cold.rs +++ b/e2e-tests/tests/smoke_cold.rs @@ -33,19 +33,14 @@ async fn smoke_cold() -> Result<(), anyhow::Error> { let base_dir = resolve_base_dir()?; let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); - let cfg = ScenarioConfig { - start: StartMode::FromSnapshot { - relay_db_tgz: snapshot::relay_db()?, - para_db_tgz: snapshot::para_db()?, - }, - spec: SpecMode::WithLightSyncState { - relay_full: snapshot::relay_spec()?, - para_full: snapshot::para_spec()?, - relay_light_sync_state: snapshot::relay_spec_light_sync_state()?, - para_light_sync_state: snapshot::para_spec_light_sync_state()?, - }, - smoldot: SmoldotState::None, - }; + 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)"); diff --git a/e2e-tests/tests/smoke_fresh.rs b/e2e-tests/tests/smoke_fresh.rs index ca97de76fa..e2c52e1137 100644 --- a/e2e-tests/tests/smoke_fresh.rs +++ b/e2e-tests/tests/smoke_fresh.rs @@ -31,7 +31,7 @@ async fn smoke_fresh() -> Result<(), anyhow::Error> { let base_dir = resolve_base_dir()?; let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); - let cfg = ScenarioConfig::fresh(); + let cfg = Scenario::Fresh; let live = spawn_scenario(&cfg, &base_dir_str).await?; log::info!("checking that alice has ≥{REQUIRED_BLOCKS} parachain blocks (best)"); diff --git a/e2e-tests/tests/smoke_warm.rs b/e2e-tests/tests/smoke_warm.rs index aa7b3b9313..faabcb272a 100644 --- a/e2e-tests/tests/smoke_warm.rs +++ b/e2e-tests/tests/smoke_warm.rs @@ -34,18 +34,16 @@ async fn smoke_warm() -> Result<(), anyhow::Error> { let base_dir = resolve_base_dir()?; let base_dir_str = base_dir.to_str().expect("UTF-8 path").to_owned(); - let cfg = ScenarioConfig { - start: StartMode::FromSnapshot { + 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()?, }, - spec: SpecMode::WithLightSyncState { - relay_full: snapshot::relay_spec()?, - para_full: snapshot::para_spec()?, - relay_light_sync_state: snapshot::relay_spec_light_sync_state()?, - para_light_sync_state: snapshot::para_spec_light_sync_state()?, - }, - smoldot: SmoldotState::FromDb { + smoldot_db: SmoldotDbPaths { relay_db_json: snapshot::smoldot_db_relay()?, para_db_json: snapshot::smoldot_db_para()?, }, From 4b2dfe39d93fc3c3f8a47c30264777771699fda9 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 06:23:17 +0000 Subject: [PATCH 16/25] refactor(network): share spawned_chain_spec_paths between modules Promote network::extract_emitted_specs to public spawned_chain_spec_paths and drop the near-identical helper in statement.rs. --- e2e-tests/src/lib.rs | 4 ++-- e2e-tests/src/network.rs | 12 ++++++++++-- e2e-tests/src/statement.rs | 28 ---------------------------- 3 files changed, 12 insertions(+), 32 deletions(-) diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 12830d6138..65610e92a0 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -22,8 +22,8 @@ pub mod snapshot; pub mod statement; pub use network::{ - run_smoke_js, spawn_scenario, LiveNetwork, Scenario, SmoldotDbPaths, SnapshotPaths, - BEST_METRIC, FINALIZED_METRIC, PARA_ID, + 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 diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index c99d19e8c4..7ddb4171a2 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -130,7 +130,7 @@ pub async fn spawn_scenario( } let (relay_spec, para_spec) = match cfg.snapshot() { - None => extract_emitted_specs(&network)?, + 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. @@ -355,7 +355,10 @@ fn write_spec_with_bootnodes( Ok(()) } -fn extract_emitted_specs( +/// 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( @@ -369,6 +372,11 @@ fn extract_emitted_specs( .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")); + log::info!( + "Resolved chain-spec paths: relay={}, para={}", + relay_spec.display(), + para_spec.display() + ); Ok((relay_spec, para_spec)) } diff --git a/e2e-tests/src/statement.rs b/e2e-tests/src/statement.rs index c14b9d60ba..f49a5628b3 100644 --- a/e2e-tests/src/statement.rs +++ b/e2e-tests/src/statement.rs @@ -163,34 +163,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]; From cf14344ab06bf15dc658ed9051206615bd68e5c1 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 07:01:55 +0000 Subject: [PATCH 17/25] refactor(snapshots): convert generator from bin to ignored test Move src/bin/generate_snapshots.rs to tests/smoke_generate_snapshots.rs as a #[tokio::test] #[ignore], driven by SMOKE_SNAPSHOT_* env vars instead of CLI flags. Bumps DEFAULT_TARGET_FINALIZED 100 -> 2500. --- e2e-tests/docs/smoke-scenarios.md | 25 ++-- e2e-tests/src/network.rs | 2 +- .../smoke_generate_snapshots.rs} | 126 ++++++------------ 3 files changed, 54 insertions(+), 99 deletions(-) rename e2e-tests/{src/bin/generate_snapshots.rs => tests/smoke_generate_snapshots.rs} (83%) diff --git a/e2e-tests/docs/smoke-scenarios.md b/e2e-tests/docs/smoke-scenarios.md index 7912ca9150..c5b63f48f2 100644 --- a/e2e-tests/docs/smoke-scenarios.md +++ b/e2e-tests/docs/smoke-scenarios.md @@ -45,27 +45,26 @@ Steps (from `e2e-tests/`): pub const ARTIFACTS_VERSION: &str = "v2"; // or whatever ``` -2. **Build the generator** and **run it** to produce a fresh bundle. Either start from genesis (~3 h for `--target-finalized=2000`) or resume from an existing source DB (~50 min): +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 - cargo build --release --bin generate_snapshots - # from genesis: - ./target/release/generate_snapshots \ - --out /tmp/smoldot-snap-v2 \ - --target-finalized 2000 + SMOKE_SNAPSHOT_OUT=/tmp/smoldot-snap-v2 \ + SMOKE_SNAPSHOT_TARGET_FINALIZED=2000 \ + cargo test --release --test smoke_generate_snapshots -- --ignored --nocapture # or resume: - ./target/release/generate_snapshots \ - --out /tmp/smoldot-snap-v2 \ - --target-finalized 2000 --spec-at-finalized 1525 \ - --relay-db-snapshot /path/to/old/relaychain-db.tgz \ - --para-db-snapshot /path/to/old/parachain-db.tgz + 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 generator's `--help` lists all flags. + Required: `ZOMBIE_PROVIDER=native`, polkadot/polkadot-parachain on `PATH`. The module-level docstring in `tests/generate_snapshots.rs` lists every env var. - It produces `bundle.tar.gz` under `--out` and prints the SHA256 in the manifest at the end. + It produces `bundle.tar.gz` under `SMOKE_SNAPSHOT_OUT` and prints the SHA256 in the manifest at the end. 3. **Verify locally** before publishing: diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 7ddb4171a2..2a3b986dad 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -22,7 +22,7 @@ //! - **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 `generate_snapshots`; see +//! 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}; diff --git a/e2e-tests/src/bin/generate_snapshots.rs b/e2e-tests/tests/smoke_generate_snapshots.rs similarity index 83% rename from e2e-tests/src/bin/generate_snapshots.rs rename to e2e-tests/tests/smoke_generate_snapshots.rs index 18d045ba11..684a6a589c 100644 --- a/e2e-tests/src/bin/generate_snapshots.rs +++ b/e2e-tests/tests/smoke_generate_snapshots.rs @@ -19,7 +19,15 @@ //! //! Builds the artifact set consumed by `smoke_cold` / `smoke_warm` (network //! DB tarballs, chain specs with `lightSyncState`, smoldot databaseContent -//! dumps). Run manually; never invoked from `cargo test`. +//! 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. @@ -37,21 +45,22 @@ use zombienet_sdk::{ NetworkConfigBuilder, NetworkNode, }; -const DEFAULT_TARGET_FINALIZED: u32 = 100; +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::main(flavor = "multi_thread")] -async fn main() -> Result<(), anyhow::Error> { +#[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::parse()?; + let args = Args::from_env()?; log::info!( - "generate_snapshots: out={} spec_at=#{} target_finalized=#{} relay_snap={:?} para_snap={:?}", + "smoke_generate_snapshots: out={} spec_at=#{} target_finalized=#{} relay_snap={:?} para_snap={:?}", args.out.display(), args.spec_at_finalized, args.target_finalized, @@ -494,61 +503,19 @@ struct Args { } impl Args { - fn parse() -> Result { - let mut out: Option = None; - let mut target_finalized: Option = None; - let mut spec_at_finalized: Option = None; - let mut relay_db_snapshot: Option = None; - let mut para_db_snapshot: Option = None; - - let mut iter = std::env::args().skip(1); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--out" => { - let v = iter.next().ok_or_else(|| anyhow!("--out needs a value"))?; - out = Some(PathBuf::from(v)); - } - "--target-finalized" => { - let v = iter - .next() - .ok_or_else(|| anyhow!("--target-finalized needs a value"))?; - target_finalized = Some(v.parse().map_err(|e| { - anyhow!("--target-finalized must be a positive integer: {e}") - })?); - } - "--spec-at-finalized" => { - let v = iter - .next() - .ok_or_else(|| anyhow!("--spec-at-finalized needs a value"))?; - spec_at_finalized = Some(v.parse().map_err(|e| { - anyhow!("--spec-at-finalized must be a positive integer: {e}") - })?); - } - "--relay-db-snapshot" => { - let v = iter - .next() - .ok_or_else(|| anyhow!("--relay-db-snapshot needs a path"))?; - relay_db_snapshot = Some(PathBuf::from(v)); - } - "--para-db-snapshot" => { - let v = iter - .next() - .ok_or_else(|| anyhow!("--para-db-snapshot needs a path"))?; - para_db_snapshot = Some(PathBuf::from(v)); - } - "-h" | "--help" => { - print_help(); - std::process::exit(0); - } - other => return Err(anyhow!("unknown argument: {other}")), - } - } - - let target_finalized = target_finalized.unwrap_or(DEFAULT_TARGET_FINALIZED); - let spec_at_finalized = spec_at_finalized.unwrap_or_else(|| target_finalized / 2); + 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!( - "--spec-at-finalized (#{spec_at_finalized}) must be ≤ --target-finalized (#{target_finalized})" + "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); @@ -559,8 +526,15 @@ impl Args { ); } + 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: out.ok_or_else(|| anyhow!("--out is required"))?, + out, target_finalized, spec_at_finalized, relay_db_snapshot, @@ -569,31 +543,13 @@ impl Args { } } -fn print_help() { - println!( - "usage: generate_snapshots --out [--target-finalized N] [--spec-at-finalized M]\n\ - \n\ - Spawns westend-local + people-westend-local from genesis. Captures the\n\ - relay sync spec (lightSyncState) at finalized #M, then continues until\n\ - finalized #N to snapshot the node DBs and run smoldot for a\n\ - `databaseContent` dump.\n\ - \n\ - The M..N gap should exceed smoldot's warp_sync_minimum_gap ({}) so\n\ - smoldot exercises real warp sync (handles GRANDPA rotations) rather\n\ - than follow-forward.\n\ - \n\ - options:\n\ - --out Artifact output directory (created if missing).\n\ - --target-finalized N Snapshot block. Default: {}.\n\ - --spec-at-finalized M Spec lightSyncState block (M ≤ N). Default: N/2.\n\ - --relay-db-snapshot P Resume relay validators from this DB tarball.\n\ - --para-db-snapshot P Resume collators from this DB tarball.\n\ - \n\ - When the *-db-snapshot flags are passed, the network resumes from the\n\ - tarball'd state instead of starting at genesis — useful for extending\n\ - a prior run without paying the cost of re-syncing from #0.", - WARP_SYNC_MINIMUM_GAP, DEFAULT_TARGET_FINALIZED - ); +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 { From ba2806d2cb41258ff4f96b7b2c5f73f6db0fbbda Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 12:31:47 +0000 Subject: [PATCH 18/25] docs(snapshots): align v2 regen example with new defaults Show ZOMBIE_PROVIDER=native and TARGET_FINALIZED=2500/SPEC_AT_FINALIZED=1250 in the genesis-run example, and fix the stale tests/generate_snapshots.rs path to tests/smoke_generate_snapshots.rs. --- e2e-tests/docs/smoke-scenarios.md | 7 +++++-- e2e-tests/tests/smoke_generate_snapshots.rs | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/e2e-tests/docs/smoke-scenarios.md b/e2e-tests/docs/smoke-scenarios.md index c5b63f48f2..0aa11919ee 100644 --- a/e2e-tests/docs/smoke-scenarios.md +++ b/e2e-tests/docs/smoke-scenarios.md @@ -49,11 +49,14 @@ Steps (from `e2e-tests/`): ```bash # from genesis: + ZOMBIE_PROVIDER=native \ SMOKE_SNAPSHOT_OUT=/tmp/smoldot-snap-v2 \ - SMOKE_SNAPSHOT_TARGET_FINALIZED=2000 \ + 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 \ @@ -62,7 +65,7 @@ Steps (from `e2e-tests/`): cargo test --release --test smoke_generate_snapshots -- --ignored --nocapture ``` - Required: `ZOMBIE_PROVIDER=native`, polkadot/polkadot-parachain on `PATH`. The module-level docstring in `tests/generate_snapshots.rs` lists every env var. + 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. diff --git a/e2e-tests/tests/smoke_generate_snapshots.rs b/e2e-tests/tests/smoke_generate_snapshots.rs index 684a6a589c..480e499bfc 100644 --- a/e2e-tests/tests/smoke_generate_snapshots.rs +++ b/e2e-tests/tests/smoke_generate_snapshots.rs @@ -75,7 +75,7 @@ async fn smoke_generate_snapshots() -> Result<(), anyhow::Error> { // 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 cache slot. + // filenames so each gets its own copy. let staged = stage_per_node_snapshots( &args.out, args.relay_db_snapshot.as_deref(), @@ -508,10 +508,10 @@ impl Args { .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); + 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 ≤ \ @@ -545,9 +545,11 @@ impl Args { 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}") - })?)), + Ok(v) => { + Ok(Some(v.parse().map_err(|e| { + anyhow!("{key} must be a positive integer: {e}") + })?)) + } Err(_) => Ok(None), } } From 484e28fa81cbd0515187da757f3bf12a13167681 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 12:43:43 +0000 Subject: [PATCH 19/25] test(snapshots): repin v1 BUNDLE_SHA256 to regenerated bundle --- e2e-tests/src/snapshot.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs index 2c0be52db1..b83df2d4e7 100644 --- a/e2e-tests/src/snapshot.rs +++ b/e2e-tests/src/snapshot.rs @@ -46,7 +46,7 @@ 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 = "57b7c07bffcf0a92605d5201ef7ac497ccd1af7615151a709b95b4caa5e0383b"; +const BUNDLE_SHA256: &str = "abea526d527c13aac54b4e1874602c04963046f7d3c3bc3e0adc217573bdc6da"; pub fn relay_db() -> Result { resolve("relaychain-db.tgz") From cd14e07eacfd19dca0a9e88d256180caa32c6b4f Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 12:46:43 +0000 Subject: [PATCH 20/25] ci(zombienet): run smoke_cold and smoke_warm Renumbers existing rows to 0001-0006 to keep the sequence tight. --- .github/workflows/zombienet.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/zombienet.yml b/.github/workflows/zombienet.yml index fa7fbabdd7..f93fd96921 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_fresh" + - 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" From 710fe98686f0b156477dd090e78f3f2fa0524ecc Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 14:44:26 +0000 Subject: [PATCH 21/25] fix(network): resolve para spec path via unique_id, not chain_id --- e2e-tests/src/network.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 2a3b986dad..2bb811e01e 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -370,8 +370,10 @@ pub fn spawned_chain_spec_paths( 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")); + // unique_id is what zombienet uses to name the emitted spec file. The + // spec's own `id` field can differ (e.g. the statement-store fixture + // sets `id = "people-westend-1004"`), so don't rely on chain_id(). + let para_spec = zombienet_base.join(format!("{}.json", parachain.unique_id())); log::info!( "Resolved chain-spec paths: relay={}, para={}", relay_spec.display(), From da60669c1498ffdfc02f3700e1e526348b3f415d Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Tue, 5 May 2026 15:04:29 +0000 Subject: [PATCH 22/25] test(statement-store): exit JS scripts directly to skip terminate hang --- e2e-tests/js/statement_store_peer_connection.js | 9 ++------- e2e-tests/js/statement_store_reception.js | 9 ++------- e2e-tests/js/statement_store_submission.js | 5 +++++ 3 files changed, 9 insertions(+), 14 deletions(-) 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 (_) {} From b2f2f5895721698555d2230e124fea312dd0ce55 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Wed, 6 May 2026 07:55:09 +0000 Subject: [PATCH 23/25] fix(network): resolve para spec path using const --- e2e-tests/src/network.rs | 13 +++++-------- e2e-tests/src/statement.rs | 3 +++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/e2e-tests/src/network.rs b/e2e-tests/src/network.rs index 2bb811e01e..452c1ec656 100644 --- a/e2e-tests/src/network.rs +++ b/e2e-tests/src/network.rs @@ -35,6 +35,7 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkConfig, NetworkConfigBuilde 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\"}"; @@ -214,7 +215,7 @@ fn build_network_config( .with_id(PARA_ID) .with_default_command("polkadot-parachain") .with_default_image(images.cumulus.as_str()) - .with_chain("people-westend-local") + .with_chain(PARA_CHAIN) .with_default_args(vec![ "--force-authoring".into(), "--authoring=slot-based".into(), @@ -367,13 +368,9 @@ pub fn spawned_chain_spec_paths( .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"))?; - // unique_id is what zombienet uses to name the emitted spec file. The - // spec's own `id` field can differ (e.g. the statement-store fixture - // sets `id = "people-westend-1004"`), so don't rely on chain_id(). - let para_spec = zombienet_base.join(format!("{}.json", parachain.unique_id())); + // 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(), diff --git a/e2e-tests/src/statement.rs b/e2e-tests/src/statement.rs index f49a5628b3..d14076c076 100644 --- a/e2e-tests/src/statement.rs +++ b/e2e-tests/src/statement.rs @@ -32,6 +32,8 @@ use zombienet_sdk::{ 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; @@ -119,6 +121,7 @@ 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()) From 6fde2bb32ff208ebe179113f75e3f3d6e3752bd0 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Thu, 7 May 2026 14:03:32 +0000 Subject: [PATCH 24/25] test(smoke): skip initial newBlock burst when counting parachain blocks --- e2e-tests/js/smoke.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e-tests/js/smoke.js b/e2e-tests/js/smoke.js index 9421da51cd..e4cf757473 100644 --- a/e2e-tests/js/smoke.js +++ b/e2e-tests/js/smoke.js @@ -161,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, @@ -169,9 +172,9 @@ 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"); From 5f0655716c0db7b454f22a2646c77b3dfa591c20 Mon Sep 17 00:00:00 2001 From: Lukasz Rubaszewski <117115317+lrubasze@users.noreply.github.com> Date: Thu, 7 May 2026 14:03:55 +0000 Subject: [PATCH 25/25] test(smoke): exit JS script directly to skip terminate hang --- e2e-tests/js/smoke.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/e2e-tests/js/smoke.js b/e2e-tests/js/smoke.js index e4cf757473..69599dce1c 100644 --- a/e2e-tests/js/smoke.js +++ b/e2e-tests/js/smoke.js @@ -213,12 +213,7 @@ try { } 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);