From 1beaab88f2097fba983a174bd1d8748a606b6a11 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 10:43:19 +0200 Subject: [PATCH 1/7] zombienet-sdk-tests: HOP promotion bitswap test Adds parachain_hop_promotion_bitswap_test under a new `zombie-hop-tests` feature flag. The test: - spawns a single HOP-enabled collator (small RP, small HOP retention, promotion buffer > retention so entries are promotable immediately) - authorizes Alice, submits `hop_submit` over JSON-RPC, waits for the collator's maintenance task to land the `HopPromotion::promote` extrinsic (detected via the Stored event) - after the retention period, asserts a `ProofChecked` event fired - compares the bitswap-served content to the original both before and after promotion -- this assertion is expected to fail and is the whole point of the test (surfacing the HOP -> col11/bitswap gap) Also adds a `utils::hop_rpc` helper that reconstructs the on-chain submit signing payload + SCALE-encoded MultiSigner/MultiSignature so the test can drive `hop_submit` without depending on `sc-hop` from polkadot-sdk, plus a `just test-zombienet-hop` recipe. --- justfile | 18 ++ zombienet-sdk-tests/Cargo.toml | 1 + zombienet-sdk-tests/README.md | 8 +- .../tests/hop_promotion_storage.rs | 298 ++++++++++++++++++ zombienet-sdk-tests/tests/tests.rs | 26 +- zombienet-sdk-tests/tests/utils/hop_rpc.rs | 104 ++++++ zombienet-sdk-tests/tests/utils/mod.rs | 2 + 7 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 zombienet-sdk-tests/tests/hop_promotion_storage.rs create mode 100644 zombienet-sdk-tests/tests/utils/hop_rpc.rs diff --git a/justfile b/justfile index bab91fa4..6c6dc9d7 100644 --- a/justfile +++ b/justfile @@ -121,6 +121,24 @@ test-zombienet-auto-renew runtime="westend" group="all": --features bulletin-chain-zombienet-sdk-tests/zombie-auto-renew-tests \ -- --test-threads=1 --nocapture "${filter_args[@]}" +# Zombienet HOP-promotion suite. runtime ∈ westend | paseo; filter is the cargo-test substring. +test-zombienet-hop runtime="westend" filter="parachain_hop_promotion": + #!/usr/bin/env bash + set -euo pipefail + POLKADOT_BIN_DIR="$(just binaries-polkadot)" + CSB_DIR="$(just binaries-chain-spec-builder)" + export PATH="$CSB_DIR:$PATH" + ./scripts/create_bulletin_{{runtime}}_spec.sh + export ZOMBIE_PROVIDER=native + export POLKADOT_RELAY_BINARY_PATH="$POLKADOT_BIN_DIR/polkadot" + export POLKADOT_PARACHAIN_BINARY_PATH="$POLKADOT_BIN_DIR/polkadot-omni-node" + export PARACHAIN_CHAIN_SPEC_PATH="$PWD/zombienet/bulletin-{{runtime}}-spec.json" + export PARACHAIN_CHAIN_ID="${PARACHAIN_CHAIN_ID:-bulletin-{{runtime}}}" + cargo test --release -p bulletin-chain-zombienet-sdk-tests \ + --features bulletin-chain-zombienet-sdk-tests/zombie-hop-tests \ + "{{filter}}" \ + -- --test-threads=1 --nocapture + # Zombienet sync suite. runtime ∈ westend | paseo; filter is the cargo-test substring. test-zombienet-sync runtime="westend" filter="parachain_sync_storage": #!/usr/bin/env bash diff --git a/zombienet-sdk-tests/Cargo.toml b/zombienet-sdk-tests/Cargo.toml index 0ade8c3c..01964470 100644 --- a/zombienet-sdk-tests/Cargo.toml +++ b/zombienet-sdk-tests/Cargo.toml @@ -44,6 +44,7 @@ pallet-bulletin-transaction-storage = { path = "../pallets/transaction-storage", [features] zombie-sync-tests = [] zombie-auto-renew-tests = [] +zombie-hop-tests = [] [build-dependencies] prost-build = "0.13" diff --git a/zombienet-sdk-tests/README.md b/zombienet-sdk-tests/README.md index b56afd51..7f2883e4 100644 --- a/zombienet-sdk-tests/README.md +++ b/zombienet-sdk-tests/README.md @@ -48,8 +48,8 @@ export ROCKSDB_LDB_PATH=/path/to/ldb ## Running tests -Tests are gated behind feature flags (`zombie-sync-tests`, `zombie-auto-renew-tests`) -so `cargo test --workspace` doesn't accidentally fire them. +Tests are gated behind feature flags (`zombie-sync-tests`, `zombie-auto-renew-tests`, +`zombie-hop-tests`) so `cargo test --workspace` doesn't accidentally fire them. Recommended path — `just` recipes from the repo root: @@ -65,6 +65,9 @@ just test-zombienet-auto-renew # Single auto-renew test: just test-zombienet-auto-renew westend parachain_auto_renew_quota_exhaustion_test + +# HOP promotion suite: +just test-zombienet-hop ``` The recipes fetch the right binaries, generate the chain spec, export the env @@ -92,6 +95,7 @@ are resource-intensive. | `parachain_full_sync_relay_warp_sync_test` | full + warp (relay) | no | Relay warp syncs, parachain full syncs, bitswap works | | `parachain_rpc_node_bitswap_test` | full | no | RPC node syncs and serves data via bitswap | | `parachain_ldb_storage_verification_test` | - | yes | Verifies col11 refcounting and data expiration | +| `parachain_hop_promotion_bitswap_test` | full | no | HOP `hop_submit` → promote → `ProofChecked` passes; bitswap-content-match assertion is **expected to fail** (drives out the HOP↔bitswap CID/indexing gap) | ## Environment variables diff --git a/zombienet-sdk-tests/tests/hop_promotion_storage.rs b/zombienet-sdk-tests/tests/hop_promotion_storage.rs new file mode 100644 index 00000000..6a6258aa --- /dev/null +++ b/zombienet-sdk-tests/tests/hop_promotion_storage.rs @@ -0,0 +1,298 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end test for the HOP (Hand-Off Protocol) promotion flow on a single-collator +//! parachain network. +//! +//! Flow exercised: +//! 1. Spawn a HOP-enabled collator with a small on-chain `RetentionPeriod`, a small HOP retention, +//! and a `promotion_buffer >= hop_retention` so submitted entries become promotable immediately. +//! 2. Authorize Alice for HOP promotion (`pallet_bulletin_transaction_storage::authorize_account`). +//! 3. Submit `hop_submit` over JSON-RPC with Alice's sr25519 signature. +//! 4. Wait for the collator's maintenance task to land the on-chain `HopPromotion::promote` +//! extrinsic (detected via `TransactionStorage::Stored`). +//! 5. Wait for `store_block + RetentionPeriod + 1` so the runtime's storage-proof inherent runs +//! against the promoted blob — strong assertion that `ProofChecked` fires. +//! 6. Bitswap-fetch the content both before and after promotion and compare against the original. +//! **This part is expected to fail** — surfacing whatever bug the test is meant to drive out +//! (e.g. CID/chunking mismatch or missing col11 indexing on the promotion path). The proof check +//! above passes; the bitswap check is the demonstration. + +use crate::{ + test_log, + utils::{ + authorize_account_via_sudo_finalized, blake2_256, + build_parachain_network_config_three_relay_validators, content_hash_and_cid, count_event, + generate_test_data, get_alice_nonce, hop_submit, initialize_network, now_ms, + set_retention_period_finalized, verify_bitswap_fetch, verify_parachain_binaries, + wait_for_block_height, wait_for_finalized_height, wait_for_session_change_on_node, + NETWORK_READY_TIMEOUT_SECS, TEST_DATA_SIZE, + }, +}; +use anyhow::{Context, Result}; +use std::time::Duration; +use subxt::{config::substrate::SubstrateConfig, OnlineClient}; +use subxt_signer::sr25519::dev; + +/// Short on-chain retention so the proof block lands within the test window. +const RETENTION_PERIOD: u32 = 10; + +/// HOP data pool retention. Picked smaller than the `promotion_buffer` below so +/// every submitted entry is in the "near-expiry" promotion window immediately. +const HOP_RETENTION_SECS: u64 = 10; +/// Must be greater than `HOP_RETENTION_SECS` so `get_promotable` returns the +/// entry on the first tick after submission. +const HOP_PROMOTION_BUFFER_SECS: u64 = 60; +/// Short maintenance cadence so the promotion lands within seconds of submission. +const HOP_CHECK_INTERVAL_SECS: u64 = 5; + +const SESSION_CHANGE_TIMEOUT_SECS: u64 = 300; +const BLOCK_PRODUCTION_TIMEOUT_SECS: u64 = 300; +const PROMOTION_TIMEOUT_SECS: u64 = 120; +const BITSWAP_TIMEOUT_SECS: u64 = 20; + +/// Logging for HOP — extends the standard target list with `hop=trace` so a failed +/// "promotion never landed" surfaces the maintenance-loop diagnostics, plus the +/// promotion's `Submitted/Failed` lines from `sc-hop`. +const HOP_NODE_LOG_CONFIG: &str = + "-lsync=trace,sub-libp2p=trace,litep2p=trace,request-response=trace,\ + transaction-storage=trace,bitswap=trace,hop=trace,txpool=debug"; + +fn get_para_node_args() -> Vec { + vec![ + "--ipfs-server".into(), + "--enable-hop".into(), + "--hop-disable-rate-limit".into(), + format!("--hop-retention-secs={}", HOP_RETENTION_SECS), + format!("--hop-promotion-buffer-secs={}", HOP_PROMOTION_BUFFER_SECS), + format!("--hop-check-interval={}", HOP_CHECK_INTERVAL_SECS), + HOP_NODE_LOG_CONFIG.into(), + // Arguments after "--" are passed to the embedded relay chain client. + "--".into(), + "--network-backend=libp2p".into(), + ] +} + +/// Poll the chain for a `TransactionStorage::Stored` event matching `content_hash` up to +/// `timeout_secs`. Returns the canonical block number the promotion landed in. +async fn wait_for_promoted( + client: &OnlineClient, + content_hash: &[u8; 32], + timeout_secs: u64, +) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + let mut blocks_sub = client.blocks().subscribe_best().await?; + while std::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_secs(5), blocks_sub.next()).await { + Ok(Some(Ok(block))) => { + let events = block.events().await?; + for ev in events.iter().filter_map(|e| e.ok()) { + if ev.pallet_name() == "TransactionStorage" && + ev.variant_name() == "Stored" && + ev.field_bytes().windows(32).any(|w| w == content_hash) + { + return Ok(block.number() as u64); + } + } + }, + Ok(Some(Err(e))) => anyhow::bail!("block subscription error: {e}"), + Ok(None) => anyhow::bail!("block subscription ended unexpectedly"), + Err(_) => continue, + } + } + anyhow::bail!( + "HOP promotion not observed within {}s — no Stored event for content_hash 0x{}", + timeout_secs, + hex::encode(content_hash) + ) +} + +/// Locate the canonical block at `target` by walking back from the latest finalized block. +/// Polls until finality reaches `target`. +async fn finalized_block_hash_at( + client: &OnlineClient, + target: u64, +) -> Result { + const POLL_INTERVAL_SECS: u64 = 2; + const POLL_TIMEOUT_SECS: u64 = 180; + let start = std::time::Instant::now(); + let mut current = client.blocks().at_latest().await?; + while (current.number() as u64) < target { + if start.elapsed().as_secs() > POLL_TIMEOUT_SECS { + anyhow::bail!( + "finalized height {} did not reach target {} within {}s", + current.number(), + target, + POLL_TIMEOUT_SECS + ); + } + tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await; + current = client.blocks().at_latest().await?; + } + while (current.number() as u64) > target { + let parent_hash = current.header().parent_hash; + current = client.blocks().at(parent_hash).await?; + } + Ok(current.hash()) +} + +/// Strong assertion: exactly one `ProofChecked` event at `block`. Reads on the finalized +/// chain so the verdict is stable. +async fn assert_proof_checked_at( + client: &OnlineClient, + block: u64, + context: &str, +) -> Result<()> { + let hash = finalized_block_hash_at(client, block).await?; + let events = client.blocks().at(hash).await?.events().await?; + let count = count_event(&events, "ProofChecked"); + if count != 1 { + anyhow::bail!( + "{}: expected exactly 1 ProofChecked at block {}, saw {}", + context, + block, + count + ); + } + Ok(()) +} + +/// HOP-promotion smoke test. Demonstrates the proof-check path works while the +/// bitswap-content-match check is **expected to fail** (the bug this test surfaces). +#[tokio::test(flavor = "multi_thread")] +async fn parachain_hop_promotion_bitswap_test() -> Result<()> { + const TEST: &str = "para_hop_promotion"; + crate::utils::init_logging(); + + test_log!( + TEST, + "=== Parachain HOP promotion (RP={}, hop_retention={}s, hop_buffer={}s) ===", + RETENTION_PERIOD, + HOP_RETENTION_SECS, + HOP_PROMOTION_BUFFER_SECS, + ); + + verify_parachain_binaries()?; + + let config = build_parachain_network_config_three_relay_validators(get_para_node_args())?; + let network = initialize_network(config).await?; + network.wait_until_is_up(NETWORK_READY_TIMEOUT_SECS).await?; + + let relay_alice = network.get_node("alice").context("get relay alice")?; + wait_for_session_change_on_node(relay_alice, SESSION_CHANGE_TIMEOUT_SECS).await?; + + let collator1 = network.get_node("collator-1").context("get collator-1")?; + let client: OnlineClient = collator1.wait_client().await?; + + // Set on-chain RetentionPeriod=10 (finalize so subsequent reads see the change). + let mut nonce = get_alice_nonce(collator1).await?; + set_retention_period_finalized(&client, RETENTION_PERIOD, nonce).await?; + nonce += 1; + + // Authorize Alice for at least one promotion's worth of bytes. + // `account_has_active_authorization` only requires the entry to exist + be unexpired — the + // per-extent counters aren't debited by HOP promotions, but `authorize_account` won't write an + // entry with zero extent, so give it a generous chunk to make the active state unambiguous. + let alice_pk = dev::alice().public_key().0; + authorize_account_via_sudo_finalized( + &client, + &alice_pk, + 10, + (TEST_DATA_SIZE as u64).saturating_mul(8), + nonce, + ) + .await?; + // `account_nonce` reads finalized state and the call above finalized — refresh from + // the chain so we don't double-spend the nonce in any later signed op. + let _ = nonce; // unused from here + + // Generate distinct test data (salt with timestamp suffix to keep the content hash + // fresh across re-runs of the same chain image). + let mut pattern = b"HOP_PROMOTION_TEST_".to_vec(); + pattern.extend_from_slice(format!("{}_", now_ms()).as_bytes()); + let data = generate_test_data(TEST_DATA_SIZE, &pattern); + let content_hash = blake2_256(&data); + let (hash_hex, cid) = content_hash_and_cid(&data); + tracing::info!("Test data: {} bytes, content_hash={}, CID={}", data.len(), hash_hex, cid); + + // --- Bitswap probe BEFORE promotion ---------------------------------------------------- + // At this point the blob lives only in the HOP data pool — col11 has no entry, so + // bitswap MUST NOT serve matching content. We assert "bitswap content matches original" + // here as part of the demonstration: this assertion is expected to fail. + test_log!(TEST, "Bitswap probe BEFORE promotion (expected: no match yet — drives the bug)"); + let ws_uri = collator1.ws_uri().to_string(); + let multiaddr = collator1.multiaddr().to_string(); + + let before_match = verify_bitswap_fetch(&multiaddr, &data, BITSWAP_TIMEOUT_SECS) + .await + .unwrap_or(false); + tracing::info!("[BEFORE promotion] bitswap content match = {}", before_match); + + // --- HOP submit --------------------------------------------------------------------- + // Alice signs the submit payload; recipient list contains Alice's own key so the + // runtime side doesn't reject the recipient encoding. + let alice = dev::alice(); + let recipients = [alice.public_key().0]; + let submit_ts = now_ms(); + tracing::info!("Submitting hop_submit via {} (ts_ms={})", ws_uri, submit_ts); + let entry_count = hop_submit(&ws_uri, &alice, &data, &recipients, submit_ts) + .await + .context("hop_submit RPC call failed")?; + tracing::info!("hop_submit OK; pool entry_count={}", entry_count); + + // --- Wait for promotion ---------------------------------------------------------- + // The maintenance loop runs every `HOP_CHECK_INTERVAL_SECS`; the entry is in the + // promotion window immediately because `buffer > retention`. + let store_block = wait_for_promoted(&client, &content_hash, PROMOTION_TIMEOUT_SECS) + .await + .context("HOP promotion did not land on-chain")?; + tracing::info!("✓ HOP promotion landed at block {} (Stored event observed)", store_block); + + // --- Bitswap probe AFTER promotion ----------------------------------------------- + // With the blob promoted on-chain, bitswap *should* match the original content. + // The user-visible failure of this test is here. + test_log!(TEST, "Bitswap probe AFTER promotion (expected: match — drives the bug if not)"); + let after_match = verify_bitswap_fetch(&multiaddr, &data, BITSWAP_TIMEOUT_SECS) + .await + .unwrap_or(false); + tracing::info!("[AFTER promotion] bitswap content match = {}", after_match); + + // --- Wait for retention period to elapse, then assert the storage-proof inherent ran --- + let proof_block = store_block + RETENTION_PERIOD as u64; + let wait_until = proof_block + 1; + tracing::info!( + "Waiting for finalized block {} so the storage proof at {} is on the canonical chain", + wait_until, + proof_block + ); + wait_for_block_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; + wait_for_finalized_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; + assert_proof_checked_at(&client, proof_block, "post-HOP-promotion").await?; + tracing::info!("✓ ProofChecked event present at block {}", proof_block); + + // --- The bitswap content-match demonstration -------------------------------------- + // Both probes should ideally match the original. They will not — that's the goal of + // this test. Assert here so the test exits with a clear failure pointing at the gap. + if !before_match { + anyhow::bail!( + "BEFORE promotion: bitswap did not return content matching the original. \ + (Pre-promotion the blob lives only in the HOP pool, not in col11, so this is \ + expected — but the assertion is part of demonstrating the bitswap/HOP gap.)" + ); + } + if !after_match { + anyhow::bail!( + "AFTER promotion: bitswap did not return content matching the original at \ + block {} (proof_block={}). The promote extrinsic landed (Stored event seen) \ + and the storage-proof inherent ran (ProofChecked event seen), but bitswap is \ + still not serving the blob via the published CID. This is the bug the test is \ + meant to surface.", + store_block, + proof_block, + ); + } + + test_log!(TEST, "=== Parachain HOP promotion bitswap test PASSED ==="); + network.destroy().await?; + Ok(()) +} diff --git a/zombienet-sdk-tests/tests/tests.rs b/zombienet-sdk-tests/tests/tests.rs index 9166b2de..0b5fdfcb 100644 --- a/zombienet-sdk-tests/tests/tests.rs +++ b/zombienet-sdk-tests/tests/tests.rs @@ -3,10 +3,28 @@ #![allow(clippy::uninlined_format_args)] -// Both suites compile together so shared `utils/` helpers don't trip dead-code warnings. -#[cfg(any(feature = "zombie-sync-tests", feature = "zombie-auto-renew-tests"))] +// All suites compile together so shared `utils/` helpers don't trip dead-code warnings. +#[cfg(any( + feature = "zombie-sync-tests", + feature = "zombie-auto-renew-tests", + feature = "zombie-hop-tests" +))] mod auto_renew_storage; -#[cfg(any(feature = "zombie-sync-tests", feature = "zombie-auto-renew-tests"))] +#[cfg(any( + feature = "zombie-sync-tests", + feature = "zombie-auto-renew-tests", + feature = "zombie-hop-tests" +))] +mod hop_promotion_storage; +#[cfg(any( + feature = "zombie-sync-tests", + feature = "zombie-auto-renew-tests", + feature = "zombie-hop-tests" +))] mod parachain_sync_storage; -#[cfg(any(feature = "zombie-sync-tests", feature = "zombie-auto-renew-tests"))] +#[cfg(any( + feature = "zombie-sync-tests", + feature = "zombie-auto-renew-tests", + feature = "zombie-hop-tests" +))] mod utils; diff --git a/zombienet-sdk-tests/tests/utils/hop_rpc.rs b/zombienet-sdk-tests/tests/utils/hop_rpc.rs new file mode 100644 index 00000000..0f1000ce --- /dev/null +++ b/zombienet-sdk-tests/tests/utils/hop_rpc.rs @@ -0,0 +1,104 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +//! Raw `hop_submit` JSON-RPC client. Mirrors the on-chain construction of +//! `MultiSigner`/`MultiSignature` + the submit signing payload so the test +//! driver can stand in for `sc-hop`'s SDK side without pulling it in. + +use super::crypto::blake2_256; +use anyhow::{anyhow, Result}; +use subxt::{ + backend::rpc::RpcClient, + ext::subxt_rpcs::client::{rpc_params, RpcParams}, +}; +use subxt_signer::sr25519::Keypair; + +/// Must remain byte-identical to `pallet-bulletin-hop-promotion::HOP_SUBMIT_CONTEXT`. +const HOP_SUBMIT_CONTEXT: &[u8] = b"hop-submit-v1:"; + +/// `blake2_256(HOP_SUBMIT_CONTEXT || blake2_256(data) || submit_timestamp.to_le_bytes())` — +/// must remain byte-identical to the pallet's reconstruction; the runtime re-verifies the +/// user's signature against this exact byte sequence. +pub fn submit_signing_payload(data_hash: &[u8; 32], submit_timestamp_ms: u64) -> [u8; 32] { + let mut buf = Vec::with_capacity(HOP_SUBMIT_CONTEXT.len() + 32 + 8); + buf.extend_from_slice(HOP_SUBMIT_CONTEXT); + buf.extend_from_slice(data_hash); + buf.extend_from_slice(&submit_timestamp_ms.to_le_bytes()); + blake2_256(&buf) +} + +/// SCALE-encoded `MultiSigner::Sr25519(pub_key)` — variant index 1 + 32 raw bytes. +pub fn encode_multi_signer_sr25519(pub_key: &[u8; 32]) -> Vec { + let mut out = Vec::with_capacity(1 + 32); + out.push(1u8); + out.extend_from_slice(pub_key); + out +} + +/// SCALE-encoded `MultiSignature::Sr25519(sig)` — variant index 1 + 64 raw bytes. +pub fn encode_multi_signature_sr25519(sig: &[u8; 64]) -> Vec { + let mut out = Vec::with_capacity(1 + 64); + out.push(1u8); + out.extend_from_slice(sig); + out +} + +/// Submit `data` to a collator's HOP data pool via the `hop_submit` JSON-RPC. +/// The submission is signed by `signer` and lists `recipients` as the intended +/// claimants. Returns the pool's reported entry count after insertion. +pub async fn hop_submit( + ws_uri: &str, + signer: &Keypair, + data: &[u8], + recipients: &[[u8; 32]], + submit_timestamp_ms: u64, +) -> Result { + let rpc = RpcClient::from_insecure_url(ws_uri) + .await + .map_err(|e| anyhow!("connect {ws_uri}: {e}"))?; + + let data_hash = blake2_256(data); + let payload = submit_signing_payload(&data_hash, submit_timestamp_ms); + let sig_bytes = signer.sign(&payload).0; + + let signer_encoded = encode_multi_signer_sr25519(&signer.public_key().0); + let signature_encoded = encode_multi_signature_sr25519(&sig_bytes); + + let recipients_encoded: Vec = recipients + .iter() + .map(|r| format!("0x{}", hex::encode(encode_multi_signer_sr25519(r)))) + .collect(); + + let mut params: RpcParams = rpc_params![format!("0x{}", hex::encode(data)), recipients_encoded]; + params + .push(format!("0x{}", hex::encode(&signature_encoded))) + .map_err(|e| anyhow!("encode signature param: {e}"))?; + params + .push(format!("0x{}", hex::encode(&signer_encoded))) + .map_err(|e| anyhow!("encode signer param: {e}"))?; + params + .push(submit_timestamp_ms) + .map_err(|e| anyhow!("encode timestamp param: {e}"))?; + + let value: serde_json::Value = rpc + .request("hop_submit", params) + .await + .map_err(|e| anyhow!("hop_submit RPC: {e}"))?; + let entry_count = value + .get("poolStatus") + .and_then(|p| p.get("entryCount")) + .and_then(|n| n.as_u64()) + .ok_or_else(|| anyhow!("hop_submit response missing poolStatus.entryCount: {value}"))?; + Ok(entry_count) +} + +/// Wall-clock now in milliseconds since the unix epoch. Used as the `submit_timestamp` +/// bound into the user's signature; the runtime rejects promotions whose timestamp +/// falls outside `SubmitTimestampTolerance` of on-chain time. +pub fn now_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/zombienet-sdk-tests/tests/utils/mod.rs b/zombienet-sdk-tests/tests/utils/mod.rs index 6385846d..da835a89 100644 --- a/zombienet-sdk-tests/tests/utils/mod.rs +++ b/zombienet-sdk-tests/tests/utils/mod.rs @@ -38,6 +38,7 @@ pub mod bitswap; pub mod config; pub mod crypto; pub mod events; +pub mod hop_rpc; pub mod ldb; pub mod network; pub mod sync; @@ -47,6 +48,7 @@ pub use bitswap::*; pub use config::*; pub use crypto::*; pub use events::*; +pub use hop_rpc::*; pub use ldb::*; pub use network::*; pub use sync::*; From dc32408fec66d2313e68f5c3eb9053096ff3ad3c Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 10:50:03 +0200 Subject: [PATCH 2/7] zombienet-sdk-tests: extract shared block helpers Move `current_best_block`, `current_finalized_block`, `block_hash_at`, `finalized_block_hash_at`, and `assert_proof_checked_at` out of `auto_renew_storage.rs` and `hop_promotion_storage.rs` into a new `utils::blocks` module. Both test files now use the shared implementation -- net diff is -128 lines. --- .../tests/auto_renew_storage.rs | 104 ++---------------- .../tests/hop_promotion_storage.rs | 54 +-------- zombienet-sdk-tests/tests/utils/blocks.rs | 92 ++++++++++++++++ zombienet-sdk-tests/tests/utils/mod.rs | 2 + 4 files changed, 108 insertions(+), 144 deletions(-) create mode 100644 zombienet-sdk-tests/tests/utils/blocks.rs diff --git a/zombienet-sdk-tests/tests/auto_renew_storage.rs b/zombienet-sdk-tests/tests/auto_renew_storage.rs index 6d5863ac..93286bc4 100644 --- a/zombienet-sdk-tests/tests/auto_renew_storage.rs +++ b/zombienet-sdk-tests/tests/auto_renew_storage.rs @@ -7,17 +7,19 @@ use crate::{ test_log, utils::{ - authorize_account_via_sudo, authorize_account_via_sudo_finalized, authorize_and_store_data, - blake2_256, build_parachain_network_config_three_relay_validators, content_hash_and_cid, - count_event, disable_auto_renew, enable_auto_renew, + assert_proof_checked_at, authorize_account_via_sudo, authorize_account_via_sudo_finalized, + authorize_and_store_data, blake2_256, block_hash_at, + build_parachain_network_config_three_relay_validators, content_hash_and_cid, count_event, + current_best_block, current_finalized_block, disable_auto_renew, enable_auto_renew, expect_all_items_bitswap_dont_have_concurrent, expect_bitswap_dont_have, - generate_test_data, get_alice_nonce, initialize_network, override_alice_authorization, - resolve_canonical_store_block, set_retention_period, set_retention_period_finalized, - submit_renew_pair, submit_store_signed, top_up_alice_authorization, - verify_all_items_bitswap_concurrent, verify_node_bitswap, verify_parachain_binaries, - wait_for_block_height, wait_for_finalized_height, wait_for_finalized_quiescence, - wait_for_session_change_on_node, AuthorizationOverride, BLOCK_PRODUCTION_TIMEOUT_SECS, - NETWORK_READY_TIMEOUT_SECS, NODE_LOG_CONFIG, PARACHAIN_TEST_DATA_PATTERN, TEST_DATA_SIZE, + finalized_block_hash_at, generate_test_data, get_alice_nonce, initialize_network, + override_alice_authorization, resolve_canonical_store_block, set_retention_period, + set_retention_period_finalized, submit_renew_pair, submit_store_signed, + top_up_alice_authorization, verify_all_items_bitswap_concurrent, verify_node_bitswap, + verify_parachain_binaries, wait_for_block_height, wait_for_finalized_height, + wait_for_finalized_quiescence, wait_for_session_change_on_node, AuthorizationOverride, + BLOCK_PRODUCTION_TIMEOUT_SECS, NETWORK_READY_TIMEOUT_SECS, NODE_LOG_CONFIG, + PARACHAIN_TEST_DATA_PATTERN, TEST_DATA_SIZE, }, }; use anyhow::{Context, Result}; @@ -33,28 +35,6 @@ use subxt_signer::{ SecretUri, }; -/// Fetch the latest best block. `at_latest()` returns the latest finalized block via -/// chainHead_v2 — on cumulus, finality lags ~10s behind production, so it can be stuck at -/// block 0 well after the chain is producing. -async fn current_best_block( - client: &OnlineClient, -) -> Result>> { - let mut sub = client.blocks().subscribe_best().await?; - let block = sub - .next() - .await - .ok_or_else(|| anyhow::anyhow!("subscribe_best stream empty"))??; - Ok(block) -} - -/// Fetch the latest finalized block. Use this when event/storage reads must be stable — -/// best-view can briefly follow a non-canonical branch as chainHead_v2 resolves. -async fn current_finalized_block( - client: &OnlineClient, -) -> Result>> { - Ok(client.blocks().at_latest().await?) -} - const SESSION_CHANGE_TIMEOUT_SECS: u64 = 300; const RETENTION_PERIOD: u32 = 10; const BITSWAP_TIMEOUT_SECS: u64 = 30; @@ -3476,66 +3456,6 @@ async fn parachain_auto_renew_quota_exhaustion_test() -> Result<()> { Ok(()) } -async fn block_hash_at( - client: &OnlineClient, - target: u64, -) -> Result { - let mut current = current_best_block(client).await?; - while (current.number() as u64) > target { - let parent_hash = current.header().parent_hash; - current = client.blocks().at(parent_hash).await?; - } - if (current.number() as u64) != target { - anyhow::bail!("could not locate block {} (best chain at {})", target, current.number()); - } - Ok(current.hash()) -} - -/// Locate the canonical block at `target` by walking back from the latest finalized block. -/// Polls until finality reaches `target` so callers can chain it directly after a best-block -/// wait without manually re-waiting on finality. -async fn finalized_block_hash_at( - client: &OnlineClient, - target: u64, -) -> Result { - const POLL_INTERVAL_SECS: u64 = 2; - const POLL_TIMEOUT_SECS: u64 = 120; - let start = std::time::Instant::now(); - let mut current = current_finalized_block(client).await?; - while (current.number() as u64) < target { - if start.elapsed().as_secs() > POLL_TIMEOUT_SECS { - anyhow::bail!( - "finalized height {} did not reach target {} within {}s", - current.number(), - target, - POLL_TIMEOUT_SECS - ); - } - tokio::time::sleep(std::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; - current = current_finalized_block(client).await?; - } - while (current.number() as u64) > target { - let parent_hash = current.header().parent_hash; - current = client.blocks().at(parent_hash).await?; - } - Ok(current.hash()) -} - -/// Assert exactly one `ProofChecked` event at the given block; verifies the storage-proof -/// step of `apply_block_inherents` actually ran for `Transactions[block - RetentionPeriod]`. -/// Reads at the canonical (finalized) block — caller must ensure finality has reached `block`. -async fn assert_proof_checked_at( - client: &OnlineClient, - block: u64, - context: &str, -) -> Result<()> { - let hash = finalized_block_hash_at(client, block).await?; - let events = client.blocks().at(hash).await?.events().await?; - let count = count_event(&events, "ProofChecked"); - assert_eq!(count, 1, "{}: expected 1 ProofChecked at block {}, saw {}", context, block, count); - Ok(()) -} - /// Log event counts at `r-1..=r+2` so a failed assertion can distinguish "renewal fired in /// a different block" from "renewal never fired". async fn dump_renewal_window( diff --git a/zombienet-sdk-tests/tests/hop_promotion_storage.rs b/zombienet-sdk-tests/tests/hop_promotion_storage.rs index 6a6258aa..8c5bcd47 100644 --- a/zombienet-sdk-tests/tests/hop_promotion_storage.rs +++ b/zombienet-sdk-tests/tests/hop_promotion_storage.rs @@ -21,8 +21,8 @@ use crate::{ test_log, utils::{ - authorize_account_via_sudo_finalized, blake2_256, - build_parachain_network_config_three_relay_validators, content_hash_and_cid, count_event, + assert_proof_checked_at, authorize_account_via_sudo_finalized, blake2_256, + build_parachain_network_config_three_relay_validators, content_hash_and_cid, generate_test_data, get_alice_nonce, hop_submit, initialize_network, now_ms, set_retention_period_finalized, verify_bitswap_fetch, verify_parachain_binaries, wait_for_block_height, wait_for_finalized_height, wait_for_session_change_on_node, @@ -107,56 +107,6 @@ async fn wait_for_promoted( ) } -/// Locate the canonical block at `target` by walking back from the latest finalized block. -/// Polls until finality reaches `target`. -async fn finalized_block_hash_at( - client: &OnlineClient, - target: u64, -) -> Result { - const POLL_INTERVAL_SECS: u64 = 2; - const POLL_TIMEOUT_SECS: u64 = 180; - let start = std::time::Instant::now(); - let mut current = client.blocks().at_latest().await?; - while (current.number() as u64) < target { - if start.elapsed().as_secs() > POLL_TIMEOUT_SECS { - anyhow::bail!( - "finalized height {} did not reach target {} within {}s", - current.number(), - target, - POLL_TIMEOUT_SECS - ); - } - tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await; - current = client.blocks().at_latest().await?; - } - while (current.number() as u64) > target { - let parent_hash = current.header().parent_hash; - current = client.blocks().at(parent_hash).await?; - } - Ok(current.hash()) -} - -/// Strong assertion: exactly one `ProofChecked` event at `block`. Reads on the finalized -/// chain so the verdict is stable. -async fn assert_proof_checked_at( - client: &OnlineClient, - block: u64, - context: &str, -) -> Result<()> { - let hash = finalized_block_hash_at(client, block).await?; - let events = client.blocks().at(hash).await?.events().await?; - let count = count_event(&events, "ProofChecked"); - if count != 1 { - anyhow::bail!( - "{}: expected exactly 1 ProofChecked at block {}, saw {}", - context, - block, - count - ); - } - Ok(()) -} - /// HOP-promotion smoke test. Demonstrates the proof-check path works while the /// bitswap-content-match check is **expected to fail** (the bug this test surfaces). #[tokio::test(flavor = "multi_thread")] diff --git a/zombienet-sdk-tests/tests/utils/blocks.rs b/zombienet-sdk-tests/tests/utils/blocks.rs new file mode 100644 index 00000000..107bebba --- /dev/null +++ b/zombienet-sdk-tests/tests/utils/blocks.rs @@ -0,0 +1,92 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +//! Block-fetch and proof-event helpers shared across test files. Centralizes the +//! best-vs-finalized fetch semantics and the `ProofChecked` assertion so each +//! test file doesn't ship its own copy. + +use super::events::count_event; +use anyhow::{anyhow, Result}; +use subxt::{blocks::Block, config::substrate::SubstrateConfig, utils::H256, OnlineClient}; + +const FINALIZED_POLL_INTERVAL_SECS: u64 = 2; +const FINALIZED_POLL_TIMEOUT_SECS: u64 = 120; + +/// Fetch the latest best block. `at_latest()` returns the latest finalized block via +/// chainHead_v2 — on cumulus, finality lags ~10s behind production, so it can be stuck at +/// block 0 well after the chain is producing. +pub async fn current_best_block( + client: &OnlineClient, +) -> Result>> { + let mut sub = client.blocks().subscribe_best().await?; + sub.next() + .await + .ok_or_else(|| anyhow!("subscribe_best stream empty"))? + .map_err(Into::into) +} + +/// Fetch the latest finalized block. Use this when event/storage reads must be stable — +/// best-view can briefly follow a non-canonical branch as chainHead_v2 resolves. +pub async fn current_finalized_block( + client: &OnlineClient, +) -> Result>> { + Ok(client.blocks().at_latest().await?) +} + +/// Locate `target` on the best chain by walking parents back from the head. Fails if the +/// chain hasn't yet reached `target` or if the walked ancestor doesn't match — callers +/// that need to outlast reorgs should use [`finalized_block_hash_at`] instead. +pub async fn block_hash_at(client: &OnlineClient, target: u64) -> Result { + let mut current = current_best_block(client).await?; + while (current.number() as u64) > target { + let parent_hash = current.header().parent_hash; + current = client.blocks().at(parent_hash).await?; + } + if (current.number() as u64) != target { + anyhow::bail!("could not locate block {} (best chain at {})", target, current.number()); + } + Ok(current.hash()) +} + +/// Locate the canonical block at `target` by walking back from the latest finalized block. +/// Polls until finality reaches `target` so callers can chain it directly after a best-block +/// wait without manually re-waiting on finality. +pub async fn finalized_block_hash_at( + client: &OnlineClient, + target: u64, +) -> Result { + let start = std::time::Instant::now(); + let mut current = current_finalized_block(client).await?; + while (current.number() as u64) < target { + if start.elapsed().as_secs() > FINALIZED_POLL_TIMEOUT_SECS { + anyhow::bail!( + "finalized height {} did not reach target {} within {}s", + current.number(), + target, + FINALIZED_POLL_TIMEOUT_SECS + ); + } + tokio::time::sleep(std::time::Duration::from_secs(FINALIZED_POLL_INTERVAL_SECS)).await; + current = current_finalized_block(client).await?; + } + while (current.number() as u64) > target { + let parent_hash = current.header().parent_hash; + current = client.blocks().at(parent_hash).await?; + } + Ok(current.hash()) +} + +/// Strong assertion: exactly one `ProofChecked` event at `block`. Reads at the canonical +/// (finalized) block — caller must ensure finality has reached `block`, or rely on +/// [`finalized_block_hash_at`]'s internal poll. +pub async fn assert_proof_checked_at( + client: &OnlineClient, + block: u64, + context: &str, +) -> Result<()> { + let hash = finalized_block_hash_at(client, block).await?; + let events = client.blocks().at(hash).await?.events().await?; + let count = count_event(&events, "ProofChecked"); + assert_eq!(count, 1, "{}: expected 1 ProofChecked at block {}, saw {}", context, block, count); + Ok(()) +} diff --git a/zombienet-sdk-tests/tests/utils/mod.rs b/zombienet-sdk-tests/tests/utils/mod.rs index da835a89..6a7e1ce0 100644 --- a/zombienet-sdk-tests/tests/utils/mod.rs +++ b/zombienet-sdk-tests/tests/utils/mod.rs @@ -35,6 +35,7 @@ macro_rules! test_error { } pub mod bitswap; +pub mod blocks; pub mod config; pub mod crypto; pub mod events; @@ -45,6 +46,7 @@ pub mod sync; pub mod tx; pub use bitswap::*; +pub use blocks::*; pub use config::*; pub use crypto::*; pub use events::*; From 658a447800d55f6388ae4f548e571628765b940d Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 10:56:43 +0200 Subject: [PATCH 3/7] zombienet-sdk-tests: tie ProofChecked at proof_block to the HOP-promoted blob Before asserting `ProofChecked` at `store_block + RetentionPeriod`, read `TransactionByContentHash[content_hash]` at the proof block and assert it equals `(store_block, 0)`. The runtime's storage-proof inherent at block N proves `Transactions[N - RetentionPeriod]`, so tying the index back to our HOP content_hash at the proof block's state guarantees the upcoming ProofChecked event covers our promoted blob and not some unrelated extrinsic that happened to land in the same slot. Refactor `canonical_store_block` to share its decode path with a new `transaction_location_at` helper that returns both `(block, index)` from the same `TransactionByContentHash` read. --- .../tests/hop_promotion_storage.rs | 53 +++++++++++-- zombienet-sdk-tests/tests/utils/tx.rs | 78 ++++++++++++------- 2 files changed, 98 insertions(+), 33 deletions(-) diff --git a/zombienet-sdk-tests/tests/hop_promotion_storage.rs b/zombienet-sdk-tests/tests/hop_promotion_storage.rs index 8c5bcd47..f0d41581 100644 --- a/zombienet-sdk-tests/tests/hop_promotion_storage.rs +++ b/zombienet-sdk-tests/tests/hop_promotion_storage.rs @@ -23,10 +23,11 @@ use crate::{ utils::{ assert_proof_checked_at, authorize_account_via_sudo_finalized, blake2_256, build_parachain_network_config_three_relay_validators, content_hash_and_cid, - generate_test_data, get_alice_nonce, hop_submit, initialize_network, now_ms, - set_retention_period_finalized, verify_bitswap_fetch, verify_parachain_binaries, - wait_for_block_height, wait_for_finalized_height, wait_for_session_change_on_node, - NETWORK_READY_TIMEOUT_SECS, TEST_DATA_SIZE, + finalized_block_hash_at, generate_test_data, get_alice_nonce, hop_submit, + initialize_network, now_ms, set_retention_period_finalized, transaction_location_at, + verify_bitswap_fetch, verify_parachain_binaries, wait_for_block_height, + wait_for_finalized_height, wait_for_session_change_on_node, NETWORK_READY_TIMEOUT_SECS, + TEST_DATA_SIZE, }, }; use anyhow::{Context, Result}; @@ -208,6 +209,11 @@ async fn parachain_hop_promotion_bitswap_test() -> Result<()> { tracing::info!("[AFTER promotion] bitswap content match = {}", after_match); // --- Wait for retention period to elapse, then assert the storage-proof inherent ran --- + // First confirm the index still points the HOP content_hash at `store_block` when the + // proof inherent fires at `store_block + RetentionPeriod`. The runtime proves + // `Transactions[proof_block - RetentionPeriod] = Transactions[store_block]`, so this read + // ties the upcoming `ProofChecked` event to *our* HOP-promoted blob rather than just any + // proof that happened to fire. let proof_block = store_block + RETENTION_PERIOD as u64; let wait_until = proof_block + 1; tracing::info!( @@ -217,8 +223,45 @@ async fn parachain_hop_promotion_bitswap_test() -> Result<()> { ); wait_for_block_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; wait_for_finalized_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; + + let proof_hash = finalized_block_hash_at(&client, proof_block).await?; + let location = transaction_location_at(&client, proof_hash, &content_hash) + .await + .with_context(|| { + format!("read TransactionByContentHash[{}] at proof_block {}", hash_hex, proof_block) + })? + .ok_or_else(|| { + anyhow::anyhow!( + "TransactionByContentHash[{}] missing at proof_block {} — HOP promotion not \ + indexed at the slot the proof inherent will read", + hash_hex, + proof_block, + ) + })?; + assert_eq!( + location, + (store_block, 0), + "HOP-promoted blob ({}) is indexed at {:?} but the proof inherent at block {} reads \ + Transactions[{}] — the upcoming ProofChecked event would not cover our blob", + hash_hex, + location, + proof_block, + store_block, + ); + tracing::info!( + "✓ TransactionByContentHash[{}] = ({}, {}) at proof_block {} — ProofChecked there \ + will be for the HOP-promoted blob", + hash_hex, + location.0, + location.1, + proof_block + ); + assert_proof_checked_at(&client, proof_block, "post-HOP-promotion").await?; - tracing::info!("✓ ProofChecked event present at block {}", proof_block); + tracing::info!( + "✓ ProofChecked event present at block {} (for the HOP-promoted blob)", + proof_block + ); // --- The bitswap content-match demonstration -------------------------------------- // Both probes should ideally match the original. They will not — that's the goal of diff --git a/zombienet-sdk-tests/tests/utils/tx.rs b/zombienet-sdk-tests/tests/utils/tx.rs index fe8616f5..21b645ae 100644 --- a/zombienet-sdk-tests/tests/utils/tx.rs +++ b/zombienet-sdk-tests/tests/utils/tx.rs @@ -518,6 +518,46 @@ pub async fn resolve_canonical_store_block( ) } +/// Read `TransactionByContentHash[content_hash]` at the given block hash. Returns the +/// `(block_number, tx_index)` of the most-recent store/renew/promote that landed the +/// content, or `None` if the index has no entry. +pub async fn transaction_location_at( + client: &OnlineClient, + at_block_hash: subxt::utils::H256, + content_hash: &[u8; 32], +) -> Result> { + use subxt::ext::scale_value::{Primitive, ValueDef}; + + let address = subxt::dynamic::storage( + "TransactionStorage", + "TransactionByContentHash", + vec![Value::from_bytes(content_hash.as_slice())], + ); + let Some(value) = client.storage().at(at_block_hash).fetch(&address).await? else { + return Ok(None); + }; + let decoded = value.to_value()?; + let ValueDef::Composite(ref c) = decoded.value else { + anyhow::bail!("unexpected TransactionByContentHash value shape: {:?}", decoded); + }; + let mut iter = c.values(); + let block_number = iter + .next() + .and_then(|v| match &v.value { + ValueDef::Primitive(Primitive::U128(n)) => Some(*n), + _ => None, + }) + .ok_or_else(|| anyhow!("TransactionByContentHash: missing block number"))?; + let tx_index = iter + .next() + .and_then(|v| match &v.value { + ValueDef::Primitive(Primitive::U128(n)) => Some(*n), + _ => None, + }) + .ok_or_else(|| anyhow!("TransactionByContentHash: missing tx index"))?; + Ok(Some((block_number as u64, tx_index as u32))) +} + /// Canonical store/renew block number, read from `TransactionByContentHash` at the /// inclusion-block hash. subxt's `tx_in_block.block_hash()` can name a block whose /// `block.number()` is one ahead of the canonical `Transactions[N]` key the pallet uses to @@ -528,35 +568,17 @@ pub async fn canonical_store_block( at_block_hash: subxt::utils::H256, content_hash: &[u8; 32], ) -> Result { - let address = subxt::dynamic::storage( - "TransactionStorage", - "TransactionByContentHash", - vec![Value::from_bytes(content_hash.as_slice())], - ); - let value = client.storage().at(at_block_hash).fetch(&address).await?.ok_or_else(|| { - anyhow!( - "TransactionByContentHash[0x{}] is empty at block 0x{} — the store extrinsic \ + transaction_location_at(client, at_block_hash, content_hash) + .await? + .map(|(block, _)| block) + .ok_or_else(|| { + anyhow!( + "TransactionByContentHash[0x{}] is empty at block 0x{} — the store extrinsic \ should have populated this entry in the same block", - hex::encode(content_hash), - hex::encode(&at_block_hash.0[..8]), - ) - })?; - use subxt::ext::scale_value::{Primitive, ValueDef}; - let decoded = value.to_value()?; - let block_number = match decoded.value { - ValueDef::Composite(ref c) => c - .values() - .next() - .and_then(|v| match &v.value { - ValueDef::Primitive(Primitive::U128(n)) => Some(*n), - _ => None, - }) - .ok_or_else(|| { - anyhow!("TransactionByContentHash value composite empty or non-numeric") - })?, - _ => anyhow::bail!("unexpected TransactionByContentHash value shape: {:?}", decoded), - }; - Ok(block_number as u64) + hex::encode(content_hash), + hex::encode(&at_block_hash.0[..8]), + ) + }) } /// Two `force_renew` calls signed by Alice and Bob respectively — synchronous immediate From ba106e5b61f2aeafbb353dc2990dec2de5c3bf9b Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 10:59:54 +0200 Subject: [PATCH 4/7] zombienet-sdk-tests: simplify HOP proof tie-back Drop the `transaction_location_at` helper -- the HOP test only needs the block number, so it can call the existing `canonical_store_block` directly. Single `assert_eq!(indexed_at, store_block, ...)` before `assert_proof_checked_at` ties the ProofChecked event back to the HOP-promoted blob. --- .../tests/hop_promotion_storage.rs | 63 ++++----------- zombienet-sdk-tests/tests/utils/tx.rs | 78 +++++++------------ 2 files changed, 42 insertions(+), 99 deletions(-) diff --git a/zombienet-sdk-tests/tests/hop_promotion_storage.rs b/zombienet-sdk-tests/tests/hop_promotion_storage.rs index f0d41581..8dec088a 100644 --- a/zombienet-sdk-tests/tests/hop_promotion_storage.rs +++ b/zombienet-sdk-tests/tests/hop_promotion_storage.rs @@ -22,9 +22,9 @@ use crate::{ test_log, utils::{ assert_proof_checked_at, authorize_account_via_sudo_finalized, blake2_256, - build_parachain_network_config_three_relay_validators, content_hash_and_cid, - finalized_block_hash_at, generate_test_data, get_alice_nonce, hop_submit, - initialize_network, now_ms, set_retention_period_finalized, transaction_location_at, + build_parachain_network_config_three_relay_validators, canonical_store_block, + content_hash_and_cid, finalized_block_hash_at, generate_test_data, get_alice_nonce, + hop_submit, initialize_network, now_ms, set_retention_period_finalized, verify_bitswap_fetch, verify_parachain_binaries, wait_for_block_height, wait_for_finalized_height, wait_for_session_change_on_node, NETWORK_READY_TIMEOUT_SECS, TEST_DATA_SIZE, @@ -208,60 +208,25 @@ async fn parachain_hop_promotion_bitswap_test() -> Result<()> { .unwrap_or(false); tracing::info!("[AFTER promotion] bitswap content match = {}", after_match); - // --- Wait for retention period to elapse, then assert the storage-proof inherent ran --- - // First confirm the index still points the HOP content_hash at `store_block` when the - // proof inherent fires at `store_block + RetentionPeriod`. The runtime proves - // `Transactions[proof_block - RetentionPeriod] = Transactions[store_block]`, so this read - // ties the upcoming `ProofChecked` event to *our* HOP-promoted blob rather than just any - // proof that happened to fire. + // --- Wait for retention period, assert the proof at `store_block + RP` covers our blob --- + // The runtime proves `Transactions[N - RetentionPeriod]` at block N, so confirming the + // HOP content hash is still indexed at `store_block` when the proof fires ties the + // `ProofChecked` event back to *our* promoted blob. let proof_block = store_block + RETENTION_PERIOD as u64; let wait_until = proof_block + 1; - tracing::info!( - "Waiting for finalized block {} so the storage proof at {} is on the canonical chain", - wait_until, - proof_block - ); + tracing::info!("Waiting for finalized block {} (proof at {})", wait_until, proof_block); wait_for_block_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; wait_for_finalized_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; let proof_hash = finalized_block_hash_at(&client, proof_block).await?; - let location = transaction_location_at(&client, proof_hash, &content_hash) - .await - .with_context(|| { - format!("read TransactionByContentHash[{}] at proof_block {}", hash_hex, proof_block) - })? - .ok_or_else(|| { - anyhow::anyhow!( - "TransactionByContentHash[{}] missing at proof_block {} — HOP promotion not \ - indexed at the slot the proof inherent will read", - hash_hex, - proof_block, - ) - })?; + let indexed_at = canonical_store_block(&client, proof_hash, &content_hash).await?; assert_eq!( - location, - (store_block, 0), - "HOP-promoted blob ({}) is indexed at {:?} but the proof inherent at block {} reads \ - Transactions[{}] — the upcoming ProofChecked event would not cover our blob", - hash_hex, - location, - proof_block, - store_block, - ); - tracing::info!( - "✓ TransactionByContentHash[{}] = ({}, {}) at proof_block {} — ProofChecked there \ - will be for the HOP-promoted blob", - hash_hex, - location.0, - location.1, - proof_block - ); - - assert_proof_checked_at(&client, proof_block, "post-HOP-promotion").await?; - tracing::info!( - "✓ ProofChecked event present at block {} (for the HOP-promoted blob)", - proof_block + indexed_at, store_block, + "HOP blob {} indexed at {} but proof at {} reads Transactions[{}]", + hash_hex, indexed_at, proof_block, store_block, ); + assert_proof_checked_at(&client, proof_block, "HOP-promoted blob").await?; + tracing::info!("✓ ProofChecked at block {} covers HOP blob {}", proof_block, hash_hex); // --- The bitswap content-match demonstration -------------------------------------- // Both probes should ideally match the original. They will not — that's the goal of diff --git a/zombienet-sdk-tests/tests/utils/tx.rs b/zombienet-sdk-tests/tests/utils/tx.rs index 21b645ae..fe8616f5 100644 --- a/zombienet-sdk-tests/tests/utils/tx.rs +++ b/zombienet-sdk-tests/tests/utils/tx.rs @@ -518,46 +518,6 @@ pub async fn resolve_canonical_store_block( ) } -/// Read `TransactionByContentHash[content_hash]` at the given block hash. Returns the -/// `(block_number, tx_index)` of the most-recent store/renew/promote that landed the -/// content, or `None` if the index has no entry. -pub async fn transaction_location_at( - client: &OnlineClient, - at_block_hash: subxt::utils::H256, - content_hash: &[u8; 32], -) -> Result> { - use subxt::ext::scale_value::{Primitive, ValueDef}; - - let address = subxt::dynamic::storage( - "TransactionStorage", - "TransactionByContentHash", - vec![Value::from_bytes(content_hash.as_slice())], - ); - let Some(value) = client.storage().at(at_block_hash).fetch(&address).await? else { - return Ok(None); - }; - let decoded = value.to_value()?; - let ValueDef::Composite(ref c) = decoded.value else { - anyhow::bail!("unexpected TransactionByContentHash value shape: {:?}", decoded); - }; - let mut iter = c.values(); - let block_number = iter - .next() - .and_then(|v| match &v.value { - ValueDef::Primitive(Primitive::U128(n)) => Some(*n), - _ => None, - }) - .ok_or_else(|| anyhow!("TransactionByContentHash: missing block number"))?; - let tx_index = iter - .next() - .and_then(|v| match &v.value { - ValueDef::Primitive(Primitive::U128(n)) => Some(*n), - _ => None, - }) - .ok_or_else(|| anyhow!("TransactionByContentHash: missing tx index"))?; - Ok(Some((block_number as u64, tx_index as u32))) -} - /// Canonical store/renew block number, read from `TransactionByContentHash` at the /// inclusion-block hash. subxt's `tx_in_block.block_hash()` can name a block whose /// `block.number()` is one ahead of the canonical `Transactions[N]` key the pallet uses to @@ -568,17 +528,35 @@ pub async fn canonical_store_block( at_block_hash: subxt::utils::H256, content_hash: &[u8; 32], ) -> Result { - transaction_location_at(client, at_block_hash, content_hash) - .await? - .map(|(block, _)| block) - .ok_or_else(|| { - anyhow!( - "TransactionByContentHash[0x{}] is empty at block 0x{} — the store extrinsic \ + let address = subxt::dynamic::storage( + "TransactionStorage", + "TransactionByContentHash", + vec![Value::from_bytes(content_hash.as_slice())], + ); + let value = client.storage().at(at_block_hash).fetch(&address).await?.ok_or_else(|| { + anyhow!( + "TransactionByContentHash[0x{}] is empty at block 0x{} — the store extrinsic \ should have populated this entry in the same block", - hex::encode(content_hash), - hex::encode(&at_block_hash.0[..8]), - ) - }) + hex::encode(content_hash), + hex::encode(&at_block_hash.0[..8]), + ) + })?; + use subxt::ext::scale_value::{Primitive, ValueDef}; + let decoded = value.to_value()?; + let block_number = match decoded.value { + ValueDef::Composite(ref c) => c + .values() + .next() + .and_then(|v| match &v.value { + ValueDef::Primitive(Primitive::U128(n)) => Some(*n), + _ => None, + }) + .ok_or_else(|| { + anyhow!("TransactionByContentHash value composite empty or non-numeric") + })?, + _ => anyhow::bail!("unexpected TransactionByContentHash value shape: {:?}", decoded), + }; + Ok(block_number as u64) } /// Two `force_renew` calls signed by Alice and Bob respectively — synchronous immediate From 295a7fed356d0e8c4be3dc00ede06eea01abd82a Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 11:06:14 +0200 Subject: [PATCH 5/7] simplify --- .../tests/hop_promotion_storage.rs | 193 ++++++------------ zombienet-sdk-tests/tests/utils/hop_rpc.rs | 71 +++---- 2 files changed, 87 insertions(+), 177 deletions(-) diff --git a/zombienet-sdk-tests/tests/hop_promotion_storage.rs b/zombienet-sdk-tests/tests/hop_promotion_storage.rs index 8dec088a..9cac62cc 100644 --- a/zombienet-sdk-tests/tests/hop_promotion_storage.rs +++ b/zombienet-sdk-tests/tests/hop_promotion_storage.rs @@ -1,22 +1,10 @@ // Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 -//! End-to-end test for the HOP (Hand-Off Protocol) promotion flow on a single-collator -//! parachain network. -//! -//! Flow exercised: -//! 1. Spawn a HOP-enabled collator with a small on-chain `RetentionPeriod`, a small HOP retention, -//! and a `promotion_buffer >= hop_retention` so submitted entries become promotable immediately. -//! 2. Authorize Alice for HOP promotion (`pallet_bulletin_transaction_storage::authorize_account`). -//! 3. Submit `hop_submit` over JSON-RPC with Alice's sr25519 signature. -//! 4. Wait for the collator's maintenance task to land the on-chain `HopPromotion::promote` -//! extrinsic (detected via `TransactionStorage::Stored`). -//! 5. Wait for `store_block + RetentionPeriod + 1` so the runtime's storage-proof inherent runs -//! against the promoted blob — strong assertion that `ProofChecked` fires. -//! 6. Bitswap-fetch the content both before and after promotion and compare against the original. -//! **This part is expected to fail** — surfacing whatever bug the test is meant to drive out -//! (e.g. CID/chunking mismatch or missing col11 indexing on the promotion path). The proof check -//! above passes; the bitswap check is the demonstration. +//! HOP (Hand-Off Protocol) promotion end-to-end: `hop_submit` -> wait for the on-chain +//! `Stored` event -> wait `RetentionPeriod` -> assert `ProofChecked` covers our blob. +//! The bitswap content-match check (before & after promotion) is the bug the test +//! surfaces and is expected to fail. use crate::{ test_log, @@ -25,9 +13,8 @@ use crate::{ build_parachain_network_config_three_relay_validators, canonical_store_block, content_hash_and_cid, finalized_block_hash_at, generate_test_data, get_alice_nonce, hop_submit, initialize_network, now_ms, set_retention_period_finalized, - verify_bitswap_fetch, verify_parachain_binaries, wait_for_block_height, - wait_for_finalized_height, wait_for_session_change_on_node, NETWORK_READY_TIMEOUT_SECS, - TEST_DATA_SIZE, + verify_bitswap_fetch, verify_parachain_binaries, wait_for_session_change_on_node, + NETWORK_READY_TIMEOUT_SECS, NODE_LOG_CONFIG, TEST_DATA_SIZE, }, }; use anyhow::{Context, Result}; @@ -37,28 +24,17 @@ use subxt_signer::sr25519::dev; /// Short on-chain retention so the proof block lands within the test window. const RETENTION_PERIOD: u32 = 10; - -/// HOP data pool retention. Picked smaller than the `promotion_buffer` below so -/// every submitted entry is in the "near-expiry" promotion window immediately. +/// HOP entry expiration; the proof must be promotable *immediately*, so +/// `HOP_PROMOTION_BUFFER_SECS > HOP_RETENTION_SECS`. const HOP_RETENTION_SECS: u64 = 10; -/// Must be greater than `HOP_RETENTION_SECS` so `get_promotable` returns the -/// entry on the first tick after submission. const HOP_PROMOTION_BUFFER_SECS: u64 = 60; -/// Short maintenance cadence so the promotion lands within seconds of submission. +/// Maintenance loop cadence — promotion lands within ~one tick of submission. const HOP_CHECK_INTERVAL_SECS: u64 = 5; const SESSION_CHANGE_TIMEOUT_SECS: u64 = 300; -const BLOCK_PRODUCTION_TIMEOUT_SECS: u64 = 300; const PROMOTION_TIMEOUT_SECS: u64 = 120; const BITSWAP_TIMEOUT_SECS: u64 = 20; -/// Logging for HOP — extends the standard target list with `hop=trace` so a failed -/// "promotion never landed" surfaces the maintenance-loop diagnostics, plus the -/// promotion's `Submitted/Failed` lines from `sc-hop`. -const HOP_NODE_LOG_CONFIG: &str = - "-lsync=trace,sub-libp2p=trace,litep2p=trace,request-response=trace,\ - transaction-storage=trace,bitswap=trace,hop=trace,txpool=debug"; - fn get_para_node_args() -> Vec { vec![ "--ipfs-server".into(), @@ -67,49 +43,44 @@ fn get_para_node_args() -> Vec { format!("--hop-retention-secs={}", HOP_RETENTION_SECS), format!("--hop-promotion-buffer-secs={}", HOP_PROMOTION_BUFFER_SECS), format!("--hop-check-interval={}", HOP_CHECK_INTERVAL_SECS), - HOP_NODE_LOG_CONFIG.into(), + format!("{},hop=trace,txpool=debug", NODE_LOG_CONFIG), // Arguments after "--" are passed to the embedded relay chain client. "--".into(), "--network-backend=libp2p".into(), ] } -/// Poll the chain for a `TransactionStorage::Stored` event matching `content_hash` up to -/// `timeout_secs`. Returns the canonical block number the promotion landed in. +/// Subscribe to best blocks and return the number of the first one whose events contain +/// a `TransactionStorage::Stored` with our `content_hash`. Times out after `timeout_secs`. async fn wait_for_promoted( client: &OnlineClient, content_hash: &[u8; 32], timeout_secs: u64, ) -> Result { let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); - let mut blocks_sub = client.blocks().subscribe_best().await?; + let mut sub = client.blocks().subscribe_best().await?; while std::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_secs(5), blocks_sub.next()).await { - Ok(Some(Ok(block))) => { - let events = block.events().await?; - for ev in events.iter().filter_map(|e| e.ok()) { - if ev.pallet_name() == "TransactionStorage" && - ev.variant_name() == "Stored" && - ev.field_bytes().windows(32).any(|w| w == content_hash) - { - return Ok(block.number() as u64); - } - } - }, - Ok(Some(Err(e))) => anyhow::bail!("block subscription error: {e}"), - Ok(None) => anyhow::bail!("block subscription ended unexpectedly"), - Err(_) => continue, + let Ok(Some(Ok(block))) = tokio::time::timeout(Duration::from_secs(5), sub.next()).await + else { + continue; + }; + let events = block.events().await?; + let hit = events.iter().filter_map(|e| e.ok()).any(|e| { + e.pallet_name() == "TransactionStorage" && + e.variant_name() == "Stored" && + e.field_bytes().windows(32).any(|w| w == content_hash) + }); + if hit { + return Ok(block.number() as u64); } } anyhow::bail!( - "HOP promotion not observed within {}s — no Stored event for content_hash 0x{}", + "HOP promotion not observed within {}s — no Stored event for 0x{}", timeout_secs, hex::encode(content_hash) ) } -/// HOP-promotion smoke test. Demonstrates the proof-check path works while the -/// bitswap-content-match check is **expected to fail** (the bug this test surfaces). #[tokio::test(flavor = "multi_thread")] async fn parachain_hop_promotion_bitswap_test() -> Result<()> { const TEST: &str = "para_hop_promotion"; @@ -117,7 +88,7 @@ async fn parachain_hop_promotion_bitswap_test() -> Result<()> { test_log!( TEST, - "=== Parachain HOP promotion (RP={}, hop_retention={}s, hop_buffer={}s) ===", + "=== HOP promotion (RP={}, hop_retention={}s, hop_buffer={}s) ===", RETENTION_PERIOD, HOP_RETENTION_SECS, HOP_PROMOTION_BUFFER_SECS, @@ -135,122 +106,78 @@ async fn parachain_hop_promotion_bitswap_test() -> Result<()> { let collator1 = network.get_node("collator-1").context("get collator-1")?; let client: OnlineClient = collator1.wait_client().await?; - // Set on-chain RetentionPeriod=10 (finalize so subsequent reads see the change). + // Small `RetentionPeriod` so the proof block lands within the test window. let mut nonce = get_alice_nonce(collator1).await?; set_retention_period_finalized(&client, RETENTION_PERIOD, nonce).await?; nonce += 1; - // Authorize Alice for at least one promotion's worth of bytes. - // `account_has_active_authorization` only requires the entry to exist + be unexpired — the - // per-extent counters aren't debited by HOP promotions, but `authorize_account` won't write an - // entry with zero extent, so give it a generous chunk to make the active state unambiguous. - let alice_pk = dev::alice().public_key().0; + // `can_account_promote` only requires Alice's authorization to exist + be unexpired — + // the per-extent counters aren't debited by HOP, but `authorize_account` won't write a + // zero-extent entry, so pass a generous amount. + let alice = dev::alice(); authorize_account_via_sudo_finalized( &client, - &alice_pk, + &alice.public_key().0, 10, - (TEST_DATA_SIZE as u64).saturating_mul(8), + (TEST_DATA_SIZE as u64) * 8, nonce, ) .await?; - // `account_nonce` reads finalized state and the call above finalized — refresh from - // the chain so we don't double-spend the nonce in any later signed op. - let _ = nonce; // unused from here - // Generate distinct test data (salt with timestamp suffix to keep the content hash - // fresh across re-runs of the same chain image). + // Salt with a wall-clock suffix so re-runs don't collide on content hash. let mut pattern = b"HOP_PROMOTION_TEST_".to_vec(); pattern.extend_from_slice(format!("{}_", now_ms()).as_bytes()); let data = generate_test_data(TEST_DATA_SIZE, &pattern); let content_hash = blake2_256(&data); let (hash_hex, cid) = content_hash_and_cid(&data); - tracing::info!("Test data: {} bytes, content_hash={}, CID={}", data.len(), hash_hex, cid); + tracing::info!("test data: {} bytes, content_hash={}, CID={}", data.len(), hash_hex, cid); - // --- Bitswap probe BEFORE promotion ---------------------------------------------------- - // At this point the blob lives only in the HOP data pool — col11 has no entry, so - // bitswap MUST NOT serve matching content. We assert "bitswap content matches original" - // here as part of the demonstration: this assertion is expected to fail. - test_log!(TEST, "Bitswap probe BEFORE promotion (expected: no match yet — drives the bug)"); - let ws_uri = collator1.ws_uri().to_string(); let multiaddr = collator1.multiaddr().to_string(); + let ws_uri = collator1.ws_uri().to_string(); + // Bitswap probe BEFORE promotion: blob lives only in the HOP pool, col11 has no entry. let before_match = verify_bitswap_fetch(&multiaddr, &data, BITSWAP_TIMEOUT_SECS) .await .unwrap_or(false); - tracing::info!("[BEFORE promotion] bitswap content match = {}", before_match); + tracing::info!("bitswap BEFORE promotion: match={}", before_match); - // --- HOP submit --------------------------------------------------------------------- - // Alice signs the submit payload; recipient list contains Alice's own key so the - // runtime side doesn't reject the recipient encoding. - let alice = dev::alice(); - let recipients = [alice.public_key().0]; - let submit_ts = now_ms(); - tracing::info!("Submitting hop_submit via {} (ts_ms={})", ws_uri, submit_ts); - let entry_count = hop_submit(&ws_uri, &alice, &data, &recipients, submit_ts) - .await - .context("hop_submit RPC call failed")?; + // `hop_submit` -> maintenance task promotes -> `TransactionStorage::Stored` on-chain. + let entry_count = hop_submit(&ws_uri, &alice, &data, &[alice.public_key().0], now_ms()).await?; tracing::info!("hop_submit OK; pool entry_count={}", entry_count); + let store_block = wait_for_promoted(&client, &content_hash, PROMOTION_TIMEOUT_SECS).await?; + tracing::info!("✓ HOP promotion landed at block {}", store_block); - // --- Wait for promotion ---------------------------------------------------------- - // The maintenance loop runs every `HOP_CHECK_INTERVAL_SECS`; the entry is in the - // promotion window immediately because `buffer > retention`. - let store_block = wait_for_promoted(&client, &content_hash, PROMOTION_TIMEOUT_SECS) - .await - .context("HOP promotion did not land on-chain")?; - tracing::info!("✓ HOP promotion landed at block {} (Stored event observed)", store_block); - - // --- Bitswap probe AFTER promotion ----------------------------------------------- - // With the blob promoted on-chain, bitswap *should* match the original content. - // The user-visible failure of this test is here. - test_log!(TEST, "Bitswap probe AFTER promotion (expected: match — drives the bug if not)"); + // Bitswap probe AFTER promotion: blob is now on-chain; bitswap *should* match. let after_match = verify_bitswap_fetch(&multiaddr, &data, BITSWAP_TIMEOUT_SECS) .await .unwrap_or(false); - tracing::info!("[AFTER promotion] bitswap content match = {}", after_match); + tracing::info!("bitswap AFTER promotion: match={}", after_match); - // --- Wait for retention period, assert the proof at `store_block + RP` covers our blob --- - // The runtime proves `Transactions[N - RetentionPeriod]` at block N, so confirming the - // HOP content hash is still indexed at `store_block` when the proof fires ties the - // `ProofChecked` event back to *our* promoted blob. + // Tie `ProofChecked` to our blob: the inherent at N proves `Transactions[N - RP]`, so + // confirm the content hash is still indexed at `store_block` when the proof fires. let proof_block = store_block + RETENTION_PERIOD as u64; - let wait_until = proof_block + 1; - tracing::info!("Waiting for finalized block {} (proof at {})", wait_until, proof_block); - wait_for_block_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; - wait_for_finalized_height(collator1, wait_until, BLOCK_PRODUCTION_TIMEOUT_SECS).await?; - let proof_hash = finalized_block_hash_at(&client, proof_block).await?; let indexed_at = canonical_store_block(&client, proof_hash, &content_hash).await?; assert_eq!( indexed_at, store_block, - "HOP blob {} indexed at {} but proof at {} reads Transactions[{}]", - hash_hex, indexed_at, proof_block, store_block, + "HOP blob indexed at {} but proof at {} reads Transactions[{}]", + indexed_at, proof_block, store_block, ); assert_proof_checked_at(&client, proof_block, "HOP-promoted blob").await?; tracing::info!("✓ ProofChecked at block {} covers HOP blob {}", proof_block, hash_hex); - // --- The bitswap content-match demonstration -------------------------------------- - // Both probes should ideally match the original. They will not — that's the goal of - // this test. Assert here so the test exits with a clear failure pointing at the gap. - if !before_match { - anyhow::bail!( - "BEFORE promotion: bitswap did not return content matching the original. \ - (Pre-promotion the blob lives only in the HOP pool, not in col11, so this is \ - expected — but the assertion is part of demonstrating the bitswap/HOP gap.)" - ); - } - if !after_match { - anyhow::bail!( - "AFTER promotion: bitswap did not return content matching the original at \ - block {} (proof_block={}). The promote extrinsic landed (Stored event seen) \ - and the storage-proof inherent ran (ProofChecked event seen), but bitswap is \ - still not serving the blob via the published CID. This is the bug the test is \ - meant to surface.", - store_block, - proof_block, - ); - } + // The HOP -> bitswap gap demonstration. Both assertions are by design — `before` is + // expected to fail (data not yet on-chain), `after` is the surprising failure the test + // drives out. + assert!(before_match, "bitswap did not match BEFORE promotion (no col11 entry yet)"); + assert!( + after_match, + "bitswap did not match AFTER promotion at block {} (proof at {}) — \ + HOP -> col11/bitswap gap", + store_block, proof_block, + ); - test_log!(TEST, "=== Parachain HOP promotion bitswap test PASSED ==="); + test_log!(TEST, "=== HOP promotion bitswap test PASSED ==="); network.destroy().await?; Ok(()) } diff --git a/zombienet-sdk-tests/tests/utils/hop_rpc.rs b/zombienet-sdk-tests/tests/utils/hop_rpc.rs index 0f1000ce..215d1dce 100644 --- a/zombienet-sdk-tests/tests/utils/hop_rpc.rs +++ b/zombienet-sdk-tests/tests/utils/hop_rpc.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 //! Raw `hop_submit` JSON-RPC client. Mirrors the on-chain construction of -//! `MultiSigner`/`MultiSignature` + the submit signing payload so the test -//! driver can stand in for `sc-hop`'s SDK side without pulling it in. +//! `MultiSigner`/`MultiSignature` + the submit signing payload so the test driver +//! can stand in for `sc-hop`'s SDK side without pulling it in. use super::crypto::blake2_256; use anyhow::{anyhow, Result}; @@ -16,10 +16,9 @@ use subxt_signer::sr25519::Keypair; /// Must remain byte-identical to `pallet-bulletin-hop-promotion::HOP_SUBMIT_CONTEXT`. const HOP_SUBMIT_CONTEXT: &[u8] = b"hop-submit-v1:"; -/// `blake2_256(HOP_SUBMIT_CONTEXT || blake2_256(data) || submit_timestamp.to_le_bytes())` — -/// must remain byte-identical to the pallet's reconstruction; the runtime re-verifies the -/// user's signature against this exact byte sequence. -pub fn submit_signing_payload(data_hash: &[u8; 32], submit_timestamp_ms: u64) -> [u8; 32] { +/// `blake2_256(HOP_SUBMIT_CONTEXT || blake2_256(data) || submit_timestamp_ms.to_le_bytes())` — +/// must remain byte-identical to the pallet's reconstruction. +fn submit_signing_payload(data_hash: &[u8; 32], submit_timestamp_ms: u64) -> [u8; 32] { let mut buf = Vec::with_capacity(HOP_SUBMIT_CONTEXT.len() + 32 + 8); buf.extend_from_slice(HOP_SUBMIT_CONTEXT); buf.extend_from_slice(data_hash); @@ -27,25 +26,21 @@ pub fn submit_signing_payload(data_hash: &[u8; 32], submit_timestamp_ms: u64) -> blake2_256(&buf) } -/// SCALE-encoded `MultiSigner::Sr25519(pub_key)` — variant index 1 + 32 raw bytes. -pub fn encode_multi_signer_sr25519(pub_key: &[u8; 32]) -> Vec { - let mut out = Vec::with_capacity(1 + 32); +/// SCALE: variant index 1 + raw bytes. Used for both `MultiSigner::Sr25519` +/// (1 + 32 bytes) and `MultiSignature::Sr25519` (1 + 64 bytes). +fn sr25519_scale(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(1 + bytes.len()); out.push(1u8); - out.extend_from_slice(pub_key); + out.extend_from_slice(bytes); out } -/// SCALE-encoded `MultiSignature::Sr25519(sig)` — variant index 1 + 64 raw bytes. -pub fn encode_multi_signature_sr25519(sig: &[u8; 64]) -> Vec { - let mut out = Vec::with_capacity(1 + 64); - out.push(1u8); - out.extend_from_slice(sig); - out +fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) } -/// Submit `data` to a collator's HOP data pool via the `hop_submit` JSON-RPC. -/// The submission is signed by `signer` and lists `recipients` as the intended -/// claimants. Returns the pool's reported entry count after insertion. +/// Submit `data` to a collator's HOP data pool via the `hop_submit` JSON-RPC. Returns +/// the pool's entry count after insertion. pub async fn hop_submit( ws_uri: &str, signer: &Keypair, @@ -59,42 +54,30 @@ pub async fn hop_submit( let data_hash = blake2_256(data); let payload = submit_signing_payload(&data_hash, submit_timestamp_ms); - let sig_bytes = signer.sign(&payload).0; - - let signer_encoded = encode_multi_signer_sr25519(&signer.public_key().0); - let signature_encoded = encode_multi_signature_sr25519(&sig_bytes); - - let recipients_encoded: Vec = recipients - .iter() - .map(|r| format!("0x{}", hex::encode(encode_multi_signer_sr25519(r)))) - .collect(); + let signature_scale = sr25519_scale(&signer.sign(&payload).0); + let signer_scale = sr25519_scale(&signer.public_key().0); + let recipients_hex: Vec = recipients.iter().map(|r| hex0x(&sr25519_scale(r))).collect(); - let mut params: RpcParams = rpc_params![format!("0x{}", hex::encode(data)), recipients_encoded]; - params - .push(format!("0x{}", hex::encode(&signature_encoded))) - .map_err(|e| anyhow!("encode signature param: {e}"))?; - params - .push(format!("0x{}", hex::encode(&signer_encoded))) - .map_err(|e| anyhow!("encode signer param: {e}"))?; - params - .push(submit_timestamp_ms) - .map_err(|e| anyhow!("encode timestamp param: {e}"))?; + let mut params: RpcParams = rpc_params![hex0x(data), recipients_hex]; + for p in [hex0x(&signature_scale), hex0x(&signer_scale)] { + params.push(p).map_err(|e| anyhow!("encode param: {e}"))?; + } + params.push(submit_timestamp_ms).map_err(|e| anyhow!("encode ts param: {e}"))?; let value: serde_json::Value = rpc .request("hop_submit", params) .await .map_err(|e| anyhow!("hop_submit RPC: {e}"))?; - let entry_count = value + value .get("poolStatus") .and_then(|p| p.get("entryCount")) .and_then(|n| n.as_u64()) - .ok_or_else(|| anyhow!("hop_submit response missing poolStatus.entryCount: {value}"))?; - Ok(entry_count) + .ok_or_else(|| anyhow!("hop_submit response missing poolStatus.entryCount: {value}")) } -/// Wall-clock now in milliseconds since the unix epoch. Used as the `submit_timestamp` -/// bound into the user's signature; the runtime rejects promotions whose timestamp -/// falls outside `SubmitTimestampTolerance` of on-chain time. +/// Wall-clock now in milliseconds since the unix epoch. Bound into the submit +/// signing payload; the runtime rejects promotions whose timestamp falls outside +/// `SubmitTimestampTolerance` of on-chain time. pub fn now_ms() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() From 2e0a9520711b0d528a363c6326dc1bbb136311c9 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 11:17:53 +0200 Subject: [PATCH 6/7] ci: add zombienet-hop-tests job (label-gated) Add a matching CI job for the new HOP suite, mirroring the `zombienet-sync-tests` shape: matrix over westend + paseo, label-gated so it doesn't run on every PR. The HOP test is currently designed to fail (it surfaces the HOP -> col11/bitswap gap), so unconditional execution would block every PR. Trigger with the `zombienet-hop-tests` label or `workflow_dispatch`. --- .github/workflows/zombienet-tests.yml | 59 +++++++++++++++++++++++++++ zombienet-sdk-tests/README.md | 1 + 2 files changed, 60 insertions(+) diff --git a/.github/workflows/zombienet-tests.yml b/.github/workflows/zombienet-tests.yml index f5056940..61dac558 100644 --- a/.github/workflows/zombienet-tests.yml +++ b/.github/workflows/zombienet-tests.yml @@ -180,3 +180,62 @@ jobs: name: zombienet-sync-test-logs-bulletin-${{ matrix.runtime.name }} path: /tmp/zombie-*/**/*.log retention-days: 14 + + zombienet-hop-tests: + # HOP test is currently *expected to fail* (it surfaces the HOP→col11/bitswap gap). + # Gate behind dispatch / `zombienet-hop-tests` label so it doesn't block every PR. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'zombienet-hop-tests')) + needs: [set-image, prepare-binaries] + name: hop / ${{ matrix.runtime.name }} + runs-on: parity-large + container: + image: ${{ needs.set-image.outputs.CI_IMAGE }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + runtime: + - name: westend + para_id: 1010 + - name: paseo + para_id: 1501 + steps: + - name: Checkout sources + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Polkadot binaries + uses: ./.github/actions/use-polkadot-binaries + with: + groups: polkadot-node chain-spec-builder + mode: consume + + - name: Rust cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: . + shared-key: "bulletin-cache-zombienet-hop-tests-${{ matrix.runtime.name }}" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Restore bulletin chain specs cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + zombienet/bulletin-westend-spec.json + zombienet/bulletin-paseo-spec.json + key: bulletin-specs-${{ runner.os }}-${{ github.sha }} + fail-on-cache-miss: true + + - name: Run zombienet HOP tests + env: + PARACHAIN_ID: ${{ matrix.runtime.para_id }} + run: just test-zombienet-hop ${{ matrix.runtime.name }} + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zombienet-hop-test-logs-bulletin-${{ matrix.runtime.name }} + path: /tmp/zombie-*/**/*.log + retention-days: 14 diff --git a/zombienet-sdk-tests/README.md b/zombienet-sdk-tests/README.md index 7f2883e4..ce3fd9a1 100644 --- a/zombienet-sdk-tests/README.md +++ b/zombienet-sdk-tests/README.md @@ -149,6 +149,7 @@ A single workflow (`.github/workflows/zombienet-tests.yml`) hosts both suites: |---|---| | `zombienet-auto-renew-tests` | Every PR push + `workflow_dispatch` | | `zombienet-sync-tests` | `zombienet-sync-tests` PR label + `workflow_dispatch` | +| `zombienet-hop-tests` | `zombienet-hop-tests` PR label + `workflow_dispatch` (test is expected to fail until the HOP→col11/bitswap gap is fixed) | A shared `prepare-binaries` job fetches/builds the polkadot binaries once and uploads them as an artifact; both suites download that artifact instead of building locally. Each suite From 3d266d943ee94073e37ccad74658e8c521aa22e5 Mon Sep 17 00:00:00 2001 From: Branislav Kontur Date: Fri, 29 May 2026 11:45:58 +0200 Subject: [PATCH 7/7] ci+test logging: run hop on every PR, dump bitswap content on mismatch CI: - Drop the `if:` gate on `zombienet-hop-tests` so it runs on every PR push, the same as `zombienet-auto-renew-tests`. - README CI matrix row updated. Logging: - `verify_data_matches` (called from every bitswap probe) now logs the full hex of expected + fetched on mismatch, plus both byte-len and hex-string-len so postmortems can see where the data diverges. --- .github/workflows/zombienet-tests.yml | 5 ----- zombienet-sdk-tests/README.md | 2 +- zombienet-sdk-tests/tests/utils/crypto.rs | 12 ++++++++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/zombienet-tests.yml b/.github/workflows/zombienet-tests.yml index 61dac558..0680c8b6 100644 --- a/.github/workflows/zombienet-tests.yml +++ b/.github/workflows/zombienet-tests.yml @@ -182,11 +182,6 @@ jobs: retention-days: 14 zombienet-hop-tests: - # HOP test is currently *expected to fail* (it surfaces the HOP→col11/bitswap gap). - # Gate behind dispatch / `zombienet-hop-tests` label so it doesn't block every PR. - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'zombienet-hop-tests')) needs: [set-image, prepare-binaries] name: hop / ${{ matrix.runtime.name }} runs-on: parity-large diff --git a/zombienet-sdk-tests/README.md b/zombienet-sdk-tests/README.md index ce3fd9a1..90196847 100644 --- a/zombienet-sdk-tests/README.md +++ b/zombienet-sdk-tests/README.md @@ -149,7 +149,7 @@ A single workflow (`.github/workflows/zombienet-tests.yml`) hosts both suites: |---|---| | `zombienet-auto-renew-tests` | Every PR push + `workflow_dispatch` | | `zombienet-sync-tests` | `zombienet-sync-tests` PR label + `workflow_dispatch` | -| `zombienet-hop-tests` | `zombienet-hop-tests` PR label + `workflow_dispatch` (test is expected to fail until the HOP→col11/bitswap gap is fixed) | +| `zombienet-hop-tests` | Every PR push + `workflow_dispatch` (test is expected to fail until the HOP→col11/bitswap gap is fixed) | A shared `prepare-binaries` job fetches/builds the polkadot binaries once and uploads them as an artifact; both suites download that artifact instead of building locally. Each suite diff --git a/zombienet-sdk-tests/tests/utils/crypto.rs b/zombienet-sdk-tests/tests/utils/crypto.rs index 7e3a005b..9620d337 100644 --- a/zombienet-sdk-tests/tests/utils/crypto.rs +++ b/zombienet-sdk-tests/tests/utils/crypto.rs @@ -81,10 +81,18 @@ pub fn verify_data_matches(fetched: &[u8], expected: &[u8]) -> Result { tracing::info!("Bitswap fetch successful - data matches ({} bytes)", fetched.len()); Ok(true) } else { + let expected_hex = hex::encode(expected); + let fetched_hex = hex::encode(fetched); tracing::error!( - "Bitswap fetch data mismatch: expected {} bytes, got {} bytes", + "Bitswap fetch data mismatch: \ + expected.len={} (hex.len={}), got.len={} (hex.len={}), \ + expected=0x{}, got=0x{}", expected.len(), - fetched.len() + expected_hex.len(), + fetched.len(), + fetched_hex.len(), + expected_hex, + fetched_hex, ); Ok(false) }