diff --git a/.github/workflows/zombienet-tests.yml b/.github/workflows/zombienet-tests.yml index f5056940..0680c8b6 100644 --- a/.github/workflows/zombienet-tests.yml +++ b/.github/workflows/zombienet-tests.yml @@ -180,3 +180,57 @@ jobs: name: zombienet-sync-test-logs-bulletin-${{ matrix.runtime.name }} path: /tmp/zombie-*/**/*.log retention-days: 14 + + 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/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..90196847 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 @@ -145,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` | 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/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 new file mode 100644 index 00000000..9cac62cc --- /dev/null +++ b/zombienet-sdk-tests/tests/hop_promotion_storage.rs @@ -0,0 +1,183 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +//! 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, + utils::{ + assert_proof_checked_at, authorize_account_via_sudo_finalized, blake2_256, + 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_session_change_on_node, + NETWORK_READY_TIMEOUT_SECS, NODE_LOG_CONFIG, 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 entry expiration; the proof must be promotable *immediately*, so +/// `HOP_PROMOTION_BUFFER_SECS > HOP_RETENTION_SECS`. +const HOP_RETENTION_SECS: u64 = 10; +const HOP_PROMOTION_BUFFER_SECS: u64 = 60; +/// 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 PROMOTION_TIMEOUT_SECS: u64 = 120; +const BITSWAP_TIMEOUT_SECS: u64 = 20; + +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), + format!("{},hop=trace,txpool=debug", NODE_LOG_CONFIG), + // Arguments after "--" are passed to the embedded relay chain client. + "--".into(), + "--network-backend=libp2p".into(), + ] +} + +/// 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 sub = client.blocks().subscribe_best().await?; + while std::time::Instant::now() < deadline { + 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 0x{}", + timeout_secs, + hex::encode(content_hash) + ) +} + +#[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, + "=== 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?; + + // 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; + + // `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.public_key().0, + 10, + (TEST_DATA_SIZE as u64) * 8, + nonce, + ) + .await?; + + // 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); + + 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!("bitswap BEFORE promotion: match={}", before_match); + + // `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); + + // 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!("bitswap AFTER promotion: match={}", after_match); + + // 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 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[{}]", + 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 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, "=== 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/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/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) } 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..215d1dce --- /dev/null +++ b/zombienet-sdk-tests/tests/utils/hop_rpc.rs @@ -0,0 +1,87 @@ +// 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_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); + buf.extend_from_slice(&submit_timestamp_ms.to_le_bytes()); + blake2_256(&buf) +} + +/// 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(bytes); + 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. Returns +/// the pool's 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 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![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}"))?; + 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}")) +} + +/// 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() + .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..6a7e1ce0 100644 --- a/zombienet-sdk-tests/tests/utils/mod.rs +++ b/zombienet-sdk-tests/tests/utils/mod.rs @@ -35,18 +35,22 @@ macro_rules! test_error { } pub mod bitswap; +pub mod blocks; pub mod config; pub mod crypto; pub mod events; +pub mod hop_rpc; pub mod ldb; pub mod network; pub mod sync; pub mod tx; pub use bitswap::*; +pub use blocks::*; pub use config::*; pub use crypto::*; pub use events::*; +pub use hop_rpc::*; pub use ldb::*; pub use network::*; pub use sync::*;