Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions zombienet-sdk-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 6 additions & 2 deletions zombienet-sdk-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
104 changes: 12 additions & 92 deletions zombienet-sdk-tests/tests/auto_renew_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<SubstrateConfig>,
) -> Result<subxt::blocks::Block<SubstrateConfig, OnlineClient<SubstrateConfig>>> {
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<SubstrateConfig>,
) -> Result<subxt::blocks::Block<SubstrateConfig, OnlineClient<SubstrateConfig>>> {
Ok(client.blocks().at_latest().await?)
}

const SESSION_CHANGE_TIMEOUT_SECS: u64 = 300;
const RETENTION_PERIOD: u32 = 10;
const BITSWAP_TIMEOUT_SECS: u64 = 30;
Expand Down Expand Up @@ -3476,66 +3456,6 @@ async fn parachain_auto_renew_quota_exhaustion_test() -> Result<()> {
Ok(())
}

async fn block_hash_at(
client: &OnlineClient<SubstrateConfig>,
target: u64,
) -> Result<subxt::utils::H256> {
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<SubstrateConfig>,
target: u64,
) -> Result<subxt::utils::H256> {
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<SubstrateConfig>,
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(
Expand Down
183 changes: 183 additions & 0 deletions zombienet-sdk-tests/tests/hop_promotion_storage.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<SubstrateConfig>,
content_hash: &[u8; 32],
timeout_secs: u64,
) -> Result<u64> {
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<SubstrateConfig> = 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(())
}
Loading
Loading