From 60b40b3dce7a178693d64d46c24c573114d72267 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Wed, 6 May 2026 16:28:05 +0200 Subject: [PATCH 01/15] stream/getmany implemented --- lib/src/json_rpc/methods.rs | 40 + lib/src/json_rpc/service/client_main_task.rs | 11 + lib/src/network/codec/bitswap.rs | 58 +- light-base/src/bitswap_service.rs | 820 ++++++++++++++++-- light-base/src/json_rpc_service/background.rs | 217 ++++- 5 files changed, 1094 insertions(+), 52 deletions(-) diff --git a/lib/src/json_rpc/methods.rs b/lib/src/json_rpc/methods.rs index 322f2c4d1e..8a4df4f3f5 100644 --- a/lib/src/json_rpc/methods.rs +++ b/lib/src/json_rpc/methods.rs @@ -526,6 +526,16 @@ define_methods! { /// Request a data chunk by its CID from one of the connected peers that have it. bitswap_v1_get(cid: String) -> HexString, + /// Request multiple data chunks by CID in a single call. + /// Returns one `[cid, BlockResult]` tuple per input CID, in input order. + bitswap_v1_getMany(cids: Vec) -> Vec, + /// Subscribe to a stream of data chunks. Each input CID produces exactly one + /// `bitswap_v1_streamEvent` notification, emitted as soon as that CID resolves + /// (in arrival order, not input order). + bitswap_v1_stream(cids: Vec) -> Cow<'a, str>, + /// Cancel a `bitswap_v1_stream` subscription. No-op if the subscription does + /// not exist or has already completed. + bitswap_v1_unstream(subscription: Cow<'a, str>) -> (), // These functions are a custom addition in smoldot. As of the writing of this comment, there // is no plan to standardize them. See and @@ -549,6 +559,7 @@ define_methods! { // The functions below are experimental and are defined in the document https://github.com/paritytech/json-rpc-interface-spec/ chainHead_v1_followEvent(subscription: Cow<'a, str>, result: FollowEvent<'a>) -> (), transactionWatch_v1_watchEvent(subscription: Cow<'a, str>, result: TransactionWatchEvent<'a>) -> (), + bitswap_v1_streamEvent(subscription: Cow<'a, str>, result: BitswapBlockResultEntry) -> (), // This function is a custom addition in smoldot. As of the writing of this comment, there is // no plan to standardize it. See https://github.com/paritytech/smoldot/issues/2245. @@ -1306,6 +1317,35 @@ impl serde::Serialize for HexString { } } +/// Per-CID outcome returned by `bitswap_v1_getMany` and `bitswap_v1_streamEvent`. +/// +/// Serializes to a JSON 2-element array `[cid, BlockResult]`. The `cid` is echoed verbatim from +/// the input so callers can correlate without keeping the input array around. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BitswapBlockResultEntry(pub String, pub BitswapBlockResult); + +/// Per-CID outcome carried inside [`BitswapBlockResultEntry`]. +/// +/// On success, a `0x`-prefixed hex string carrying the chunk data (same encoding as the return +/// value of `bitswap_v1_get`). On failure, a JSON-RPC error code and human-readable diagnostic +/// message — the `code` carries the same retry categories as the top-level error of +/// `bitswap_v1_get`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum BitswapBlockResult { + Ok(HexString), + Err(BitswapBlockError), +} + +/// Per-CID error embedded inside [`BitswapBlockResult::Err`]. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BitswapBlockError { + /// Error code identifying the retry category. See `bitswap_v1_get` error categories. + pub code: i32, + /// Human-readable diagnostic message. Not stable for programmatic dispatch. + pub message: String, +} + impl serde::Serialize for RpcMethods { fn serialize(&self, serializer: S) -> Result where diff --git a/lib/src/json_rpc/service/client_main_task.rs b/lib/src/json_rpc/service/client_main_task.rs index e79ebda64a..fa6431f6a7 100644 --- a/lib/src/json_rpc/service/client_main_task.rs +++ b/lib/src/json_rpc/service/client_main_task.rs @@ -395,6 +395,7 @@ impl ClientMainTask { | methods::MethodCall::author_submitExtrinsic { .. } | methods::MethodCall::babe_epochAuthorship { .. } | methods::MethodCall::bitswap_v1_get { .. } + | methods::MethodCall::bitswap_v1_getMany { .. } | methods::MethodCall::chain_getBlock { .. } | methods::MethodCall::chain_getBlockHash { .. } | methods::MethodCall::chain_getFinalizedHead { .. } @@ -473,6 +474,7 @@ impl ClientMainTask { | methods::MethodCall::transaction_v1_broadcast { .. } | methods::MethodCall::transactionWatch_v1_submitAndWatch { .. } | methods::MethodCall::sudo_network_unstable_watch { .. } + | methods::MethodCall::bitswap_v1_stream { .. } | methods::MethodCall::chainHead_v1_follow { .. } => { // Subscription starting requests. @@ -548,6 +550,7 @@ impl ClientMainTask { } | methods::MethodCall::transactionWatch_v1_unwatch { subscription, .. } | methods::MethodCall::sudo_network_unstable_unwatch { subscription, .. } + | methods::MethodCall::bitswap_v1_unstream { subscription, .. } | methods::MethodCall::chainHead_v1_unfollow { follow_subscription: subscription, .. @@ -578,6 +581,9 @@ impl ClientMainTask { methods::MethodCall::sudo_network_unstable_unwatch { .. } => methods::Response::sudo_network_unstable_unwatch(()), + methods::MethodCall::bitswap_v1_unstream { .. } => { + methods::Response::bitswap_v1_unstream(()) + } methods::MethodCall::chainHead_v1_unfollow { .. } => { methods::Response::chainHead_v1_unfollow(()) } @@ -603,6 +609,11 @@ impl ClientMainTask { methods::Response::state_unsubscribeStorage(false) .to_json_response(request_id) } + methods::MethodCall::bitswap_v1_unstream { .. } => { + // Per spec: no error if subscription is unknown or already-completed. + methods::Response::bitswap_v1_unstream(()) + .to_json_response(request_id) + } _ => parse::build_error_response( request_id, ErrorResponse::InvalidParams(None), diff --git a/lib/src/network/codec/bitswap.rs b/lib/src/network/codec/bitswap.rs index 24bad87f19..8bbbeaf4bd 100644 --- a/lib/src/network/codec/bitswap.rs +++ b/lib/src/network/codec/bitswap.rs @@ -153,7 +153,7 @@ pub fn build_bitswap_message( // Build wantlist entries let mut entries_encoded = Vec::new(); for cid in cids { - let entry = build_wantlist_entry(cid.as_ref(), 1, want_type, send_dont_have); + let entry = build_wantlist_entry(cid.as_ref(), 1, want_type, false, send_dont_have); // Encode as repeated message field (tag 1, wire type 2) for slice in protobuf::message_tag_encode(1, core::iter::once(entry.as_slice())) { entries_encoded.extend_from_slice(slice.as_ref()); @@ -176,11 +176,42 @@ pub fn build_bitswap_message( out } +/// Builds a Bitswap message that withdraws (cancels) previously-issued wantlist entries. +/// +/// Each entry in the resulting message has the `cancel` flag set, instructing peers to drop +/// any pending want-list state they were holding for the listed CIDs. `want_type` is set to +/// `Block` (the wire default) since cancel applies to all variants of a want-list entry for a +/// given CID. +/// +/// # Arguments +/// * `cids` - Iterator of CIDs to cancel +pub fn build_bitswap_cancel_message( + cids: impl Iterator>, +) -> Vec { + let cids: Vec<_> = cids.collect(); + + let mut entries_encoded = Vec::new(); + for cid in cids { + let entry = build_wantlist_entry(cid.as_ref(), 1, WantType::Block, true, false); + for slice in protobuf::message_tag_encode(1, core::iter::once(entry.as_slice())) { + entries_encoded.extend_from_slice(slice.as_ref()); + } + } + + let mut out = Vec::with_capacity(entries_encoded.len() + 16); + for slice in protobuf::message_tag_encode(1, core::iter::once(entries_encoded.as_slice())) { + out.extend_from_slice(slice.as_ref()); + } + + out +} + /// Builds a single wantlist entry as a byte vector. fn build_wantlist_entry( cid: &[u8], priority: u32, want_type: WantType, + cancel: bool, send_dont_have: bool, ) -> Vec { let mut entry = Vec::new(); @@ -195,6 +226,13 @@ fn build_wantlist_entry( entry.extend_from_slice(slice.as_ref()); } + // Field 3: cancel (bool) - only encode if true + if cancel { + for slice in protobuf::bool_tag_encode(3, true) { + entry.extend_from_slice(slice.as_ref()); + } + } + // Field 4: wantType (enum) - only encode if not Block (default) if want_type != WantType::Block { for slice in protobuf::enum_tag_encode(4, want_type as u64) { @@ -411,6 +449,24 @@ mod tests { assert!(wantlist.full); } + #[test] + fn encode_decode_cancel_message() { + let cids = vec![[0x10u8; 32], [0x20u8; 32], [0x30u8; 32]]; + let encoded = build_bitswap_cancel_message(cids.iter()); + + let decoded = decode_bitswap_message(&encoded).unwrap(); + let wantlist = decoded.wantlist.unwrap(); + + assert_eq!(wantlist.entries.len(), 3); + for (i, entry) in wantlist.entries.iter().enumerate() { + assert_eq!(entry.cid, cids[i].as_slice()); + assert!(entry.cancel, "cancel flag should be set"); + assert_eq!(entry.want_type, WantType::Block); + assert!(!entry.send_dont_have); + } + assert!(!wantlist.full); + } + #[test] fn encode_decode_block_response() { let blocks = vec![ diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index 5189d57bbd..e5ef703980 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -71,12 +71,20 @@ use rand_chacha::rand_core::SeedableRng as _; use smoldot::{ json_rpc::parse, libp2p::cid::{self, Cid, CidPrefix}, - network::codec::{Block, BlockPresence, BlockPresenceType, WantType, build_bitswap_message}, + network::codec::{ + Block, BlockPresence, BlockPresenceType, WantType, build_bitswap_cancel_message, + build_bitswap_message, + }, }; // TODO: how many parallel requests to expect? const PARALLEL_REQUESTS: usize = 50; // 100 MiB of 2 MiB chunks. +/// Maximum number of CIDs accepted in a single `bitswap_v1_getMany` or `bitswap_v1_stream` call. +/// Mirrors the limit used by the polkadot-sdk full-node implementation. The spec requires +/// implementations to accept at least 16 CIDs. +pub const MAX_CIDS_PER_REQUEST: usize = 64; + /// Configuration for a [`BitswapService`]. pub struct Config { /// Name of the chain, for logging purposes. @@ -118,6 +126,7 @@ impl BitswapService { pending_block_requests: FuturesUnordered::new(), platform: platform.clone(), next_request_id_inner: 0, + next_batch_id_inner: 0, randomness: rand_chacha::ChaCha20Rng::from_seed({ let mut seed = [0; 32]; platform.fill_random_bytes(&mut seed); @@ -136,6 +145,10 @@ impl BitswapService { seed }), ), + batches: hashbrown::HashMap::with_capacity_and_hasher( + PARALLEL_REQUESTS, + fnv::FnvBuildHasher::default(), + ), })); platform.spawn_task(log_target.clone().into(), { @@ -163,6 +176,124 @@ impl BitswapService { result_rx.await.unwrap() } + + /// Request multiple Bitswap blocks in a single batched want-list. Resolves once every CID has + /// been decided (Ok block, NotFound, or Timeout). The returned `Vec` echoes input CIDs in input + /// order with a [`BlockResult`] per slot. + /// + /// Top-level errors (`-32602 InvalidParams`): empty input is allowed (returns empty vec); + /// duplicate CIDs or batch size > [`MAX_CIDS_PER_REQUEST`] are rejected before any wire I/O. + pub async fn bitswap_get_many( + &self, + cids: Vec, + ) -> Result, BitswapGetError> { + let entries = parse_and_dedup(cids)?; + + if entries.is_empty() { + return Ok(Vec::new()); + } + + let (result_tx, result_rx) = oneshot::channel(); + let (batch_id_tx, batch_id_rx) = oneshot::channel(); + + self.messages_tx + .send(ToBackground::BitswapBatch { + entries, + mode: BatchMode::GetMany { result_tx }, + batch_id_tx, + }) + .await + .unwrap(); + + let batch_id = batch_id_rx.await.unwrap(); + + // RAII guard: if the caller's future is dropped before result_rx resolves, the guard + // drops and sends `CancelBatch` to the service so peers receive a Cancel wantlist. + let _cancel_guard = BatchCancelGuard { + batch_id, + messages_tx: self.messages_tx.clone(), + }; + + Ok(result_rx.await.unwrap()) + } + + /// Subscribe to a stream of Bitswap blocks. Returns immediately with a [`BitswapStreamHandle`] + /// whose `events_rx` yields one `(cid_string, BlockResult)` event per input CID, in arrival + /// order (the order in which each CID resolves), not input order. + /// + /// Dropping the returned handle (explicit unsubscribe or client disconnect) cancels remaining + /// work and emits a Bitswap Cancel wantlist to peers we previously contacted. + pub async fn bitswap_stream( + &self, + cids: Vec, + ) -> Result { + let entries = parse_and_dedup(cids)?; + + // Channel size matches typical batch sizes; events_rx is drained promptly by the JSON-RPC + // layer so back-pressure here is unlikely. + let (events_tx, events_rx) = async_channel::bounded(MAX_CIDS_PER_REQUEST); + let (batch_id_tx, batch_id_rx) = oneshot::channel(); + + self.messages_tx + .send(ToBackground::BitswapBatch { + entries, + mode: BatchMode::Stream { events_tx }, + batch_id_tx, + }) + .await + .unwrap(); + + let batch_id = batch_id_rx.await.unwrap(); + + Ok(BitswapStreamHandle { + events_rx, + _cancel_guard: BatchCancelGuard { + batch_id, + messages_tx: self.messages_tx.clone(), + }, + }) + } +} + +/// Per-CID outcome of [`BitswapService::bitswap_get_many`] / [`BitswapService::bitswap_stream`]. +#[derive(Debug, Clone)] +pub enum BlockResult { + /// Block bytes received from a peer. + Ok(Vec), + /// Per-CID failure. The variant carries the same retry semantics as the top-level error of + /// [`BitswapService::bitswap_get`]. + Err(BitswapGetError), +} + +/// Active subscription handle for [`BitswapService::bitswap_stream`]. +/// +/// `events_rx` yields one event per input CID in arrival order. After all events have been +/// emitted, the channel closes naturally. Dropping the handle before then signals the service to +/// abort remaining work and emit a Bitswap Cancel wantlist for in-flight CIDs. +pub struct BitswapStreamHandle { + /// Receiver of per-CID events. `(cid_string, BlockResult)` per spec. + pub events_rx: async_channel::Receiver<(String, BlockResult)>, + _cancel_guard: BatchCancelGuard, +} + +/// Internal RAII guard that fires `ToBackground::CancelBatch` on drop. Held by both +/// [`BitswapStreamHandle`] (covers explicit unsubscribe and client disconnect) and the inner +/// future of [`BitswapService::bitswap_get_many`] (covers caller cancellation mid-await). +struct BatchCancelGuard { + batch_id: BatchId, + messages_tx: async_channel::Sender, +} + +impl Drop for BatchCancelGuard { + fn drop(&mut self) { + // Best-effort. If the service's channel is closed (service shut down already) or full + // (extremely unlikely — bounded(32)), we have no recourse since Drop can't await. + // A no-op CancelBatch on an already-finished batch is harmless: the service will look up + // the batch_id, find nothing, and ignore the message. + let _ = self.messages_tx.try_send(ToBackground::CancelBatch { + batch_id: self.batch_id, + }); + } } /// Error by [`BitswapService::bitswap_get`]. @@ -186,6 +317,18 @@ pub enum BitswapGetError { /// Request timeout. #[display("Request timeout.")] Timeout, + /// Too many CIDs in a single batch request. + #[display("Too many CIDs in batch request: max {max}, got {got}.")] + TooManyCids { + /// Configured limit. + max: usize, + /// Number of CIDs in the rejected request. + got: usize, + }, + /// Same CID appears more than once in the input. Two-stage detection: literal-string match, + /// or two distinct strings decoding to the same content digest. + #[display("Input contains duplicate CIDs.")] + DuplicateCids, } /// JSON-RPC error categories for `bitswap_v1_get` method. @@ -226,6 +369,8 @@ impl BitswapGetError { ("QueueFull", Some(BitswapJsonRpcError::FailRetryBackoff)) } BitswapGetError::NoPeers => ("NoPeers", Some(BitswapJsonRpcError::FailRetryBackoff)), + BitswapGetError::TooManyCids { .. } => ("TooManyCids", None), + BitswapGetError::DuplicateCids => ("DuplicateCids", None), }; let data = format!("{{\"variant\":\"{variant}\"}}"); @@ -237,6 +382,26 @@ impl BitswapGetError { parse::build_error_response(request_id_json, error_response, Some(&data)) } + + /// Returns the JSON-RPC `(code, message)` pair to embed inside a per-CID `BlockResult::Err` + /// in `bitswap_v1_getMany` / `bitswap_v1_streamEvent`. The code uses the same four categories + /// as the top-level error of `bitswap_v1_get`, so callers can reuse retry logic. + pub fn to_block_result_err(&self) -> (i32, String) { + const INVALID_PARAMS: i32 = -32602; + let code = match self { + BitswapGetError::InvalidCid(_) + | BitswapGetError::TooManyCids { .. } + | BitswapGetError::DuplicateCids => INVALID_PARAMS, + BitswapGetError::NotFound => BitswapJsonRpcError::Fail as i32, + BitswapGetError::BlockRequestFailed | BitswapGetError::Timeout => { + BitswapJsonRpcError::FailRetry as i32 + } + BitswapGetError::QueueFull | BitswapGetError::NoPeers => { + BitswapJsonRpcError::FailRetryBackoff as i32 + } + }; + (code, self.to_string()) + } } impl From for BitswapGetError { @@ -248,11 +413,78 @@ impl From for BitswapGetError { } } +/// Validates and de-duplicates the input CIDs of `bitswap_v1_getMany` / `bitswap_v1_stream`. +/// +/// On success returns one entry per input CID, in input order, preserving the original string and +/// the parse result. Caller-side per-CID `Err(InvalidCid)` reporting is left to the JSON-RPC layer +/// since the spec emits invalid CIDs as per-CID errors rather than aborting the whole call. +/// +/// Failure cases (returned as `Err(_)` so the caller emits a top-level JSON-RPC error): +/// * `TooManyCids` if the input exceeds [`MAX_CIDS_PER_REQUEST`]. +/// * `DuplicateCids` if two inputs are literally-equal strings, **or** if two valid-but-different +/// strings decode to the same [`Cid`] (digest collision). +pub fn parse_and_dedup( + cids: Vec, +) -> Result)>, BitswapGetError> { + if cids.len() > MAX_CIDS_PER_REQUEST { + return Err(BitswapGetError::TooManyCids { + max: MAX_CIDS_PER_REQUEST, + got: cids.len(), + }); + } + + let mut seen_strings: hashbrown::HashSet = + hashbrown::HashSet::with_capacity(cids.len()); + let mut seen_cids: hashbrown::HashSet = hashbrown::HashSet::with_capacity(cids.len()); + let mut out = Vec::with_capacity(cids.len()); + + for cid_str in cids { + if !seen_strings.insert(cid_str.clone()) { + return Err(BitswapGetError::DuplicateCids); + } + + let parsed = Cid::from_str(&cid_str); + if let Ok(c) = &parsed { + if !seen_cids.insert(c.clone()) { + return Err(BitswapGetError::DuplicateCids); + } + } + + out.push((cid_str, parsed)); + } + + Ok(out) +} + enum ToBackground { BitswapBlock { cid: Cid, result_tx: oneshot::Sender, BitswapGetError>>, }, + /// Submit a batched request. The service allocates a [`BatchId`], reports it back via + /// `batch_id_tx`, then issues a single Have broadcast covering all valid input CIDs. + BitswapBatch { + /// Validated and de-duplicated entries from [`parse_and_dedup`]. Per-slot `Err` carries + /// an `InvalidCid` ParseError that gets surfaced as a per-CID error event. + entries: Vec<(String, Result)>, + mode: BatchMode, + batch_id_tx: oneshot::Sender, + }, + /// Cancel an in-flight batch. Idempotent: if the batch already finished, this is a no-op. + CancelBatch { + batch_id: BatchId, + }, +} + +/// Mode of a batched request. `GetMany` collects all outcomes and replies once; `Stream` pushes +/// each outcome to `events_tx` as it becomes available. +enum BatchMode { + GetMany { + result_tx: oneshot::Sender>, + }, + Stream { + events_tx: async_channel::Sender<(String, BlockResult)>, + }, } #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -263,6 +495,9 @@ impl RequestId { const MAX: RequestId = RequestId(u64::MAX); } +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct BatchId(u64); + #[derive(Debug)] enum RequestStage { /// We are waiting for peers to respond to our "have" request. `HashSet` are the peers @@ -272,19 +507,59 @@ enum RequestStage { Block, } +/// Per-request output destination. A request resolves into either a single oneshot reply +/// (for `bitswap_get`) or a slot of a `Batch` (for `bitswap_get_many` / `bitswap_stream`). +#[derive(Debug)] +enum SlotOutput { + Single(oneshot::Sender, BitswapGetError>>), + Batch { batch_id: BatchId, slot_idx: usize }, +} + #[derive(Debug)] struct Request { - result_tx: oneshot::Sender, BitswapGetError>>, + result_tx: SlotOutput, timeout: TPlat::Instant, stage: RequestStage, cid: Cid, } -type HaveBroadcastResult = ( - Result, SendBitswapMessageError>, - Cid, - oneshot::Sender, BitswapGetError>>, -); +/// State for an in-flight batch. Allocated when `BitswapBatch` is received and removed once all +/// slots have been resolved (or the batch is explicitly cancelled). +struct Batch { + mode: BatchMode, + /// Per-slot CID strings, indexed by slot index. Echoed back in outcomes/events. + cid_strs: Vec, + /// `Some(RequestId)` while a slot is in-flight; `None` once the slot has been resolved. + /// Invalid-CID slots start as `None` (resolved synchronously when the batch is created). + slots: Vec>, + /// Per-slot collected outcomes. For `BatchMode::GetMany` this fills in until `pending_count` + /// reaches zero, then is drained into the response. For `BatchMode::Stream` outcomes go + /// directly to `events_tx` and these slots stay `None` (kept allocated to mirror `cid_strs` + /// for diagnostic readability — checked by `pending_count`). + outcomes: Vec>, + /// Peers we sent this batch's Have broadcast to. Used to address Cancel wantlist on + /// `CancelBatch`. Empty until `HaveBroadcastResult` arrives successfully. + peers_for_cancel: Vec, + /// Number of slots still pending resolution. When this reaches zero, finalize the batch. + pending_count: usize, +} + +/// Outcome of an issued Have broadcast. Carries enough context to either finalize a single +/// request or register N sub-requests for a batch. +enum HaveContext { + Single { + cid: Cid, + result_tx: oneshot::Sender, BitswapGetError>>, + }, + Batch { + batch_id: BatchId, + /// Valid (slot_idx, cid) pairs. Invalid-CID slots are pre-resolved before the broadcast + /// is queued and don't appear here. + cids: Vec<(usize, Cid)>, + }, +} + +type HaveBroadcastResult = (Result, SendBitswapMessageError>, HaveContext); struct BackgroundTask { /// Log target. @@ -308,6 +583,8 @@ struct BackgroundTask { platform: TPlat, /// Next request ID to use. next_request_id_inner: u64, + /// Next batch ID to use. + next_batch_id_inner: u64, /// RNG. randomness: rand_chacha::ChaCha20Rng, @@ -323,6 +600,8 @@ struct BackgroundTask { /// the platform implementation of `now` is monothonic (true for /// [`crate::platform::DefaultPlatform`]). requests_by_cid: hashbrown::HashMap, util::SipHasherBuild>, + /// In-flight batches. Each entry corresponds to a `bitswap_get_many` / `bitswap_stream` call. + batches: hashbrown::HashMap, } impl BackgroundTask { @@ -332,6 +611,172 @@ impl BackgroundTask { request_id } + + fn allocate_batch_id(&mut self) -> BatchId { + let batch_id = BatchId(self.next_batch_id_inner); + self.next_batch_id_inner += 1; + batch_id + } + + /// Final disposition of a single request slot. Routes the resolution either to the original + /// `bitswap_get` caller (`Single`) or to the owning batch (`Batch`). + fn deliver_slot(&mut self, slot_output: SlotOutput, result: Result, BitswapGetError>) { + match slot_output { + SlotOutput::Single(tx) => { + let _ = tx.send(result); + } + SlotOutput::Batch { batch_id, slot_idx } => { + self.deliver_batch_slot(batch_id, slot_idx, result); + } + } + } + + /// Deliver one resolved slot to its owning batch and finalize the batch if all slots are now + /// resolved. + fn deliver_batch_slot( + &mut self, + batch_id: BatchId, + slot_idx: usize, + result: Result, BitswapGetError>, + ) { + let block_result = match result { + Ok(data) => BlockResult::Ok(data), + Err(e) => BlockResult::Err(e), + }; + + // Touch state in two passes so we can release the borrow before potentially calling + // `cancel_batch` (which also borrows `self.batches`). + let (cid_str, should_cancel, should_finalize) = { + let Some(batch) = self.batches.get_mut(&batch_id) else { + // The batch was already finalized or cancelled; the resolution is stale. + return; + }; + + // Mark this slot as no longer in-flight regardless of mode. + batch.slots[slot_idx] = None; + batch.pending_count = batch.pending_count.saturating_sub(1); + + let cid_str = batch.cid_strs[slot_idx].clone(); + let mut should_cancel = false; + + match &mut batch.mode { + BatchMode::Stream { events_tx } => { + match events_tx.try_send((cid_str.clone(), block_result)) { + Ok(()) => {} + Err(async_channel::TrySendError::Closed(_)) => { + // Receiver dropped — JSON-RPC client disconnected or unsubscribed. + // Cancel the rest of the batch and emit a Bitswap Cancel wantlist. + should_cancel = true; + } + Err(async_channel::TrySendError::Full(_)) => { + // Bounded channel saturated. With a `bounded(MAX_CIDS_PER_REQUEST)` + // channel and a JSON-RPC pump that drains it eagerly this should be + // unreachable in practice. Log and drop the event rather than + // blocking the service. + log!( + &self.platform, + Warn, + &self.log_target, + "stream events channel full, dropping per-CID event" + ); + } + } + } + BatchMode::GetMany { .. } => { + batch.outcomes[slot_idx] = Some(block_result); + } + } + + let should_finalize = !should_cancel && batch.pending_count == 0; + (cid_str, should_cancel, should_finalize) + }; + let _ = cid_str; + + if should_cancel { + self.cancel_batch(batch_id); + } else if should_finalize { + self.finalize_batch(batch_id); + } + } + + /// Finalize a batch whose slots have all been resolved. Drains accumulated outcomes for + /// `GetMany` mode and closes the events channel for `Stream` mode. + fn finalize_batch(&mut self, batch_id: BatchId) { + let Some(batch) = self.batches.remove(&batch_id) else { + return; + }; + debug_assert_eq!(batch.pending_count, 0); + + match batch.mode { + BatchMode::GetMany { result_tx } => { + let mut out = Vec::with_capacity(batch.cid_strs.len()); + for (cid_str, outcome) in batch.cid_strs.into_iter().zip(batch.outcomes.into_iter()) + { + out.push(( + cid_str, + outcome.expect("pending_count == 0 implies all slots resolved; qed"), + )); + } + let _ = result_tx.send(out); + } + BatchMode::Stream { events_tx } => { + // Dropping the sender closes the channel. The JSON-RPC pump task will see the + // channel close and end its loop after the last event has been delivered. + drop(events_tx); + } + } + } + + /// Cancel a batch: tear down all its still-pending slots, then send a Bitswap Cancel wantlist + /// to every peer we contacted on its behalf. Idempotent. + fn cancel_batch(&mut self, batch_id: BatchId) { + let Some(batch) = self.batches.remove(&batch_id) else { + return; + }; + + // Walk pending slots, evict their `RequestId`s from the global tracking maps, and gather + // the CIDs to be cancelled on the wire. + let mut pending_cids: Vec = Vec::new(); + for slot in batch.slots.into_iter() { + let Some(request_id) = slot else { continue }; + let Some(request) = self.requests.remove(&request_id) else { + continue; + }; + let _ = self + .requests_by_timeout + .remove(&(request.timeout, request_id)); + + if let hashbrown::hash_map::Entry::Occupied(mut entry) = + self.requests_by_cid.entry(request.cid.clone()) + { + entry.get_mut().retain(|id| *id != request_id); + if entry.get().is_empty() { + entry.remove(); + } + } + + pending_cids.push(request.cid); + } + + if pending_cids.is_empty() || batch.peers_for_cancel.is_empty() { + return; + } + + // One Cancel wantlist message containing all pending CIDs, sent to every peer this + // batch's Have broadcast reached. Cancel for an unknown CID is harmless on the receiver + // side — the peer no-ops. + let message = build_bitswap_cancel_message(pending_cids.iter()); + + for peer in batch.peers_for_cancel { + let network_service = self.network_service.clone(); + let msg = message.clone(); + // Fire-and-forget. Cancel is best-effort and does not need to be awaited; if it + // fails the peer will eventually expire its want-list state on its own. + self.platform.spawn_task(self.log_target.clone().into(), async move { + let _ = network_service.send_bitswap_message(peer, msg).await; + }); + } + } } fn bitswap_have_message(cid: &Cid) -> Vec { @@ -441,56 +886,220 @@ async fn background_task(mut task: BackgroundTask) { // Network service can be back-pressuring, so we run this in the background. task.pending_have_broadcast = Some(Box::pin(async move { let result = network_service.broadcast_bitswap_message(message).await; - (result, cid, result_tx) + (result, HaveContext::Single { cid, result_tx }) })); } - WakeUpReason::HaveBroadcastResult((result, cid, result_tx)) => { - // We either succeeded or failed in broadcasting the "have" request. - - let broadcast_to = match result { - Ok(peers) => peers, - Err(err) => { - // The request is not tracked yet, so we just report the failure. - let _ = result_tx.send(Err(err.into())); - continue; + WakeUpReason::Message(ToBackground::BitswapBatch { + entries, + mode, + batch_id_tx, + }) => { + debug_assert!(task.pending_have_broadcast.is_none()); + + // Allocate the batch up front and report the BatchId back so the caller's RAII + // cancel guard can address us if the call is dropped before resolution. + let batch_id = task.allocate_batch_id(); + let _ = batch_id_tx.send(batch_id); + + let total = entries.len(); + let mut cid_strs: Vec = Vec::with_capacity(total); + let mut slots: Vec> = Vec::with_capacity(total); + let mut outcomes: Vec> = Vec::with_capacity(total); + // Slots whose CID parsed successfully — these will have their RequestId set + // after the Have broadcast lands. (slot_idx, cid). + let mut valid_cids: Vec<(usize, Cid)> = Vec::with_capacity(total); + // Slots whose CID failed to parse — pre-resolve as `InvalidCid` per spec. + // (slot_idx, cid_str, err). + let mut invalid_slots: Vec<(usize, String, BitswapGetError)> = Vec::new(); + + for (slot_idx, (cid_str, parsed)) in entries.into_iter().enumerate() { + cid_strs.push(cid_str.clone()); + slots.push(None); + outcomes.push(None); + match parsed { + Ok(c) => valid_cids.push((slot_idx, c)), + Err(e) => { + invalid_slots.push((slot_idx, cid_str, BitswapGetError::InvalidCid(e))); + } } - }; + } - // Start tracking the request. - let request_id = task.allocate_request_id(); - let timeout = task.platform.now() + Duration::from_secs(10); // TODO: 5? 20? - - let have_peers = { - let mut have_peers = hashbrown::HashSet::with_capacity_and_hasher( - broadcast_to.len(), - util::SipHasherBuild::new({ - let mut seed = [0; 16]; - task.randomness.fill_bytes(&mut seed); - seed - }), - ); - have_peers.extend(broadcast_to.into_iter()); - have_peers + let mut batch = Batch { + mode, + cid_strs, + slots, + outcomes, + peers_for_cancel: Vec::new(), + pending_count: total, }; - task.requests.insert( - request_id, - Request { - result_tx, - timeout: timeout.clone(), - stage: RequestStage::Have(have_peers), - cid: cid.clone(), - }, + // Pre-resolve invalid-CID slots. For Stream we push events immediately; for + // GetMany we accumulate in `outcomes`. + for (slot_idx, cid_str, err) in invalid_slots { + batch.pending_count -= 1; + let block_result = BlockResult::Err(err); + match &mut batch.mode { + BatchMode::Stream { events_tx } => { + let _ = events_tx.try_send((cid_str, block_result)); + } + BatchMode::GetMany { .. } => { + batch.outcomes[slot_idx] = Some(block_result); + } + } + } + + // Insert the batch before the broadcast: if the caller drops mid-await and a + // CancelBatch arrives, it must find an entry to cancel. + task.batches.insert(batch_id, batch); + + if valid_cids.is_empty() { + // No wire I/O needed. If everything resolved (all-invalid case), finalize + // immediately. If empty input slipped through somehow, also finalize. + if task + .batches + .get(&batch_id) + .map_or(true, |b| b.pending_count == 0) + { + task.finalize_batch(batch_id); + } + continue; + } + + let message = build_bitswap_message( + valid_cids.iter().map(|(_, c)| c), + WantType::Have, + true, + false, ); - task.requests_by_timeout.insert((timeout, request_id)); - task.requests_by_cid - .entry(cid) - .or_default() - .push_back(request_id); + let network_service = task.network_service.clone(); + task.pending_have_broadcast = Some(Box::pin(async move { + let result = network_service.broadcast_bitswap_message(message).await; + ( + result, + HaveContext::Batch { + batch_id, + cids: valid_cids, + }, + ) + })); + } + WakeUpReason::Message(ToBackground::CancelBatch { batch_id }) => { + task.cancel_batch(batch_id); } + WakeUpReason::HaveBroadcastResult((result, ctx)) => match ctx { + HaveContext::Single { cid, result_tx } => { + let broadcast_to = match result { + Ok(peers) => peers, + Err(err) => { + let _ = result_tx.send(Err(err.into())); + continue; + } + }; + + let request_id = task.allocate_request_id(); + let timeout = task.platform.now() + Duration::from_secs(10); + + let have_peers = { + let mut have_peers = hashbrown::HashSet::with_capacity_and_hasher( + broadcast_to.len(), + util::SipHasherBuild::new({ + let mut seed = [0; 16]; + task.randomness.fill_bytes(&mut seed); + seed + }), + ); + have_peers.extend(broadcast_to.into_iter()); + have_peers + }; + + task.requests.insert( + request_id, + Request { + result_tx: SlotOutput::Single(result_tx), + timeout: timeout.clone(), + stage: RequestStage::Have(have_peers), + cid: cid.clone(), + }, + ); + task.requests_by_timeout.insert((timeout, request_id)); + task.requests_by_cid + .entry(cid) + .or_default() + .push_back(request_id); + } + HaveContext::Batch { batch_id, cids } => { + let broadcast_to = match result { + Ok(peers) => peers, + Err(err) => { + // Whole-broadcast failure: every still-pending slot fails with the + // same error. We resolve them via `deliver_batch_slot` so that + // Stream events fire and GetMany finalizes at zero. + let bsw_err: BitswapGetError = err.into(); + for (slot_idx, _) in cids { + task.deliver_batch_slot( + batch_id, + slot_idx, + Err(bsw_err.clone()), + ); + } + continue; + } + }; + + if let Some(batch) = task.batches.get_mut(&batch_id) { + batch.peers_for_cancel = broadcast_to.clone(); + } else { + // The batch was cancelled while we were broadcasting. Don't bother + // registering per-CID requests; the cancel handler will have cleaned up + // any state already, and we have nothing to track. + continue; + } + + // Register one Request per valid slot, sharing the same Have peer set. + let timeout = task.platform.now() + Duration::from_secs(10); + for (slot_idx, cid) in cids { + let request_id = task.allocate_request_id(); + let have_peers = { + let mut have_peers = hashbrown::HashSet::with_capacity_and_hasher( + broadcast_to.len(), + util::SipHasherBuild::new({ + let mut seed = [0; 16]; + task.randomness.fill_bytes(&mut seed); + seed + }), + ); + have_peers.extend(broadcast_to.iter().cloned()); + have_peers + }; + + task.requests.insert( + request_id, + Request { + result_tx: SlotOutput::Batch { batch_id, slot_idx }, + timeout: timeout.clone(), + stage: RequestStage::Have(have_peers), + cid: cid.clone(), + }, + ); + task.requests_by_timeout.insert((timeout.clone(), request_id)); + task.requests_by_cid + .entry(cid) + .or_default() + .push_back(request_id); + + if let Some(batch) = task.batches.get_mut(&batch_id) { + batch.slots[slot_idx] = Some(request_id); + } + } + } + }, WakeUpReason::NetworkEvent(BitswapEvent::BitswapMessage { peer_id, message }) => { let message = message.decode(); + // Slots that have just resolved and need to be delivered after the per-block- + // presence borrow on `task.requests_by_cid` is released. + let mut deliveries: Vec<(SlotOutput, Result, BitswapGetError>)> = Vec::new(); + for BlockPresence { cid, presence_type } in message.block_presences { let cid = match Cid::from_bytes(cid.to_owned()) { Ok(cid) => cid, @@ -547,7 +1156,10 @@ async fn background_task(mut task: BackgroundTask) { .remove(&(request.timeout, request_id)); debug_assert!(_was_in); - let _ = request.result_tx.send(Err(BitswapGetError::NotFound)); + // Defer delivery: dispatching now would re-borrow `task` while + // we still hold `entry` on `task.requests_by_cid`. + deliveries + .push((request.result_tx, Err(BitswapGetError::NotFound))); } } (RequestStage::Block, _) => {} @@ -604,10 +1216,15 @@ async fn background_task(mut task: BackgroundTask) { .remove(&(request.timeout, request_id)); debug_assert!(_was_in); - let _ = request.result_tx.send(Ok(data.to_owned())); + task.deliver_slot(request.result_tx, Ok(data.to_owned())); } } } + + // Dispatch deferred deliveries from the block_presences loop. + for (slot_output, result) in deliveries { + task.deliver_slot(slot_output, result); + } } WakeUpReason::BlockRequestResult((result, cid)) => { // We either succeeded or failed in sending the "block" request. @@ -630,7 +1247,7 @@ async fn background_task(mut task: BackgroundTask) { .remove(&(request.timeout, request_id)); debug_assert!(_was_in); - let _ = request.result_tx.send(Err(err.clone())); + task.deliver_slot(request.result_tx, Err(err.clone())); } } } @@ -670,7 +1287,7 @@ async fn background_task(mut task: BackgroundTask) { hashbrown::hash_map::Entry::Vacant(_) => unreachable!(), } - let _ = request.result_tx.send(Err(BitswapGetError::Timeout)); + task.deliver_slot(request.result_tx, Err(BitswapGetError::Timeout)); } } WakeUpReason::ForegroundClosed => { @@ -763,4 +1380,107 @@ mod tests { let err: BitswapGetError = SendBitswapMessageError::QueueFull.into(); assert!(matches!(err, BitswapGetError::QueueFull)); } + + #[test] + fn error_too_many_cids_maps_to_invalid_params() { + let err = BitswapGetError::TooManyCids { max: 64, got: 100 }; + let json = err.to_json_rpc_error("\"1\""); + assert_eq!(extract_error_code(&json), -32602); + assert_eq!(extract_variant(&json), "TooManyCids"); + } + + #[test] + fn error_duplicate_cids_maps_to_invalid_params() { + let json = BitswapGetError::DuplicateCids.to_json_rpc_error("\"1\""); + assert_eq!(extract_error_code(&json), -32602); + assert_eq!(extract_variant(&json), "DuplicateCids"); + } + + #[test] + fn block_result_err_codes_match_top_level_codes() { + // Per spec, per-CID error codes use the same retry categories as `bitswap_v1_get`. + assert_eq!(BitswapGetError::NotFound.to_block_result_err().0, -32810); + assert_eq!(BitswapGetError::Timeout.to_block_result_err().0, -32811); + assert_eq!( + BitswapGetError::BlockRequestFailed.to_block_result_err().0, + -32811 + ); + assert_eq!(BitswapGetError::QueueFull.to_block_result_err().0, -32812); + assert_eq!(BitswapGetError::NoPeers.to_block_result_err().0, -32812); + // Invalid CIDs surface as -32602 InvalidParams per the spec. + assert_eq!( + BitswapGetError::InvalidCid(Cid::from_str("not-a-cid").unwrap_err()) + .to_block_result_err() + .0, + -32602 + ); + } + + /// A known-valid CIDv1 in base32 multibase encoding (sha2-256 over an empty input). + const VALID_CID_A: &str = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; + const VALID_CID_B: &str = "bafkreigh2akiscaildc3rdvuwhszwgrtgvybsh7lhxavhgqitanwh4kc6q"; + + #[test] + fn parse_and_dedup_empty_input_is_ok() { + let out = parse_and_dedup(vec![]).unwrap(); + assert!(out.is_empty()); + } + + #[test] + fn parse_and_dedup_happy_path() { + let out = parse_and_dedup(vec![VALID_CID_A.into(), VALID_CID_B.into()]).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].0, VALID_CID_A); + assert!(out[0].1.is_ok()); + assert_eq!(out[1].0, VALID_CID_B); + assert!(out[1].1.is_ok()); + } + + #[test] + fn parse_and_dedup_preserves_invalid_inputs_per_slot() { + // Invalid-but-unique strings must not abort the call — they surface as per-CID `Err` later. + let out = parse_and_dedup(vec![ + VALID_CID_A.into(), + "garbage".into(), + VALID_CID_B.into(), + ]) + .unwrap(); + assert_eq!(out.len(), 3); + assert!(out[0].1.is_ok()); + assert!(out[1].1.is_err()); + assert!(out[2].1.is_ok()); + } + + #[test] + fn parse_and_dedup_rejects_literal_string_duplicate() { + // Stage 1: identical input strings, both garbage. Caught before parsing. + let err = parse_and_dedup(vec!["garbage".into(), "garbage".into()]).unwrap_err(); + assert!(matches!(err, BitswapGetError::DuplicateCids)); + } + + #[test] + fn parse_and_dedup_rejects_valid_string_duplicate() { + // Stage 1: identical valid CID strings. + let err = parse_and_dedup(vec![VALID_CID_A.into(), VALID_CID_A.into()]).unwrap_err(); + assert!(matches!(err, BitswapGetError::DuplicateCids)); + } + + #[test] + fn parse_and_dedup_rejects_too_many_cids() { + let cids = (0..MAX_CIDS_PER_REQUEST + 1) + .map(|_| VALID_CID_A.into()) + .collect(); + let err = parse_and_dedup(cids).unwrap_err(); + assert!(matches!( + err, + BitswapGetError::TooManyCids { max: MAX_CIDS_PER_REQUEST, got } if got == MAX_CIDS_PER_REQUEST + 1 + )); + } + + #[test] + fn parse_and_dedup_accepts_max_size() { + let cids: Vec = (0..MAX_CIDS_PER_REQUEST).map(|i| format!("invalid-{i}")).collect(); + let out = parse_and_dedup(cids).unwrap(); + assert_eq!(out.len(), MAX_CIDS_PER_REQUEST); + } } diff --git a/light-base/src/json_rpc_service/background.rs b/light-base/src/json_rpc_service/background.rs index befc5ebb74..63588d48fc 100644 --- a/light-base/src/json_rpc_service/background.rs +++ b/light-base/src/json_rpc_service/background.rs @@ -179,6 +179,12 @@ struct Background { /// unsubscribes. transactions_subscriptions: hashbrown::HashMap, + /// Active `bitswap_v1_stream` subscriptions, keyed by subscription ID. Holds the + /// [`bitswap_service::BitswapStreamHandle`] alive — dropping the entry (via + /// `bitswap_v1_unstream` or task shutdown) drops the embedded cancel guard, which sends a + /// Bitswap Cancel wantlist to peers we contacted on this subscription's behalf. + bitswap_subscriptions: hashbrown::HashMap, + /// List of all active `state_subscribeStorage` subscriptions, indexed by the subscription ID. /// Values are the list of keys requested by this subscription. legacy_api_storage_subscriptions: BTreeSet<(Arc, Vec)>, @@ -512,6 +518,28 @@ enum Event { request_id_json: String, result: Result, bitswap_service::BitswapGetError>, }, + BitswapGetManyResult { + request_id_json: String, + result: Result< + Vec<(String, bitswap_service::BlockResult)>, + bitswap_service::BitswapGetError, + >, + }, + /// One iteration of the `bitswap_v1_stream` events pump. `event` is `None` if the events + /// channel closed (no more notifications). The receiver is shipped along so the main loop + /// can re-arm the next pump iteration. + BitswapStreamEvent { + subscription_id: String, + event: Option<(String, bitswap_service::BlockResult)>, + events_rx: async_channel::Receiver<(String, bitswap_service::BlockResult)>, + }, +} + +struct BitswapSubscription { + /// Holding the handle keeps the underlying batch alive on the bitswap service. When this + /// struct is dropped (explicit unsubscribe or whole-task shutdown), the inner + /// [`bitswap_service::BatchCancelGuard`] drops too, sending `CancelBatch`. + _handle: bitswap_service::BitswapStreamHandle, } struct TransactionWatch { @@ -590,6 +618,10 @@ pub(super) async fn run( 2, Default::default(), ), + bitswap_subscriptions: hashbrown::HashMap::with_capacity_and_hasher( + 0, + Default::default(), + ), chain_head_follow_subscriptions: hashbrown::HashMap::with_hasher(Default::default()), legacy_api_storage_subscriptions: BTreeSet::new(), legacy_api_storage_subscriptions_by_key: BTreeSet::new(), @@ -1007,7 +1039,10 @@ pub(super) async fn run( | methods::MethodCall::sudo_network_unstable_watch { .. } | methods::MethodCall::sudo_network_unstable_unwatch { .. } | methods::MethodCall::chainHead_unstable_finalizedDatabase { .. } - | methods::MethodCall::bitswap_v1_get { .. } => {} + | methods::MethodCall::bitswap_v1_get { .. } + | methods::MethodCall::bitswap_v1_getMany { .. } + | methods::MethodCall::bitswap_v1_stream { .. } + | methods::MethodCall::bitswap_v1_unstream { .. } => {} } // Actual requests handler. @@ -1154,6 +1189,100 @@ pub(super) async fn run( }); } + methods::MethodCall::bitswap_v1_getMany { cids } => { + log!( + &me.platform, + Debug, + &me.log_target, + format!("Bitswap getMany request: {} cids", cids.len()) + ); + + me.background_tasks.push({ + let bitswap_service = me.bitswap_service.clone(); + let request_id_json = request_id_json.to_owned(); + + Box::pin(async move { + let result = bitswap_service.bitswap_get_many(cids).await; + + Event::BitswapGetManyResult { + request_id_json, + result, + } + }) + }); + } + + methods::MethodCall::bitswap_v1_stream { cids } => { + log!( + &me.platform, + Debug, + &me.log_target, + format!("Bitswap stream subscription: {} cids", cids.len()) + ); + + match me.bitswap_service.bitswap_stream(cids).await { + Ok(handle) => { + let subscription_id = { + let mut sub_id = [0u8; 32]; + me.randomness.fill_bytes(&mut sub_id); + bs58::encode(sub_id).into_string() + }; + + let events_rx = handle.events_rx.clone(); + let _prev = me.bitswap_subscriptions.insert( + subscription_id.clone(), + BitswapSubscription { _handle: handle }, + ); + debug_assert!(_prev.is_none()); + + let _ = me + .responses_tx + .send( + methods::Response::bitswap_v1_stream(Cow::Borrowed( + &subscription_id, + )) + .to_json_response(request_id_json), + ) + .await; + + // Push the events pump. The pump yields one event per loop and + // re-arms itself; on channel close it ends the chain by + // delivering `event = None`. + me.background_tasks.push(Box::pin(async move { + let event = events_rx.recv().await.ok(); + Event::BitswapStreamEvent { + subscription_id, + event, + events_rx, + } + })); + } + Err(error) => { + let _ = me + .responses_tx + .send(error.to_json_rpc_error(request_id_json)) + .await; + } + } + } + + methods::MethodCall::bitswap_v1_unstream { subscription } => { + // Removing the entry drops the embedded `BitswapStreamHandle`, which + // drops the cancel guard, which sends `ToBackground::CancelBatch` to the + // bitswap service. The service then evicts pending slots and emits a + // Bitswap Cancel wantlist to peers we'd contacted. + me.bitswap_subscriptions.remove(&*subscription); + // Per spec: success even if the subscription is unknown or already + // completed. + let _ = me + .responses_tx + .send( + methods::Response::bitswap_v1_unstream(()) + .to_json_response(request_id_json), + ) + .await; + } + methods::MethodCall::chain_getBlock { hash } => { // Because this request requires asynchronous operations, we push it // to a list of "multi-stage requests" that are processed later. @@ -5960,6 +6089,92 @@ pub(super) async fn run( let _ = me.responses_tx.send(response).await; } + WakeUpReason::Event(Event::BitswapGetManyResult { + request_id_json, + result, + }) => { + let response = match result { + Ok(entries) => { + let result_array: Vec = entries + .into_iter() + .map(|(cid, br)| { + let block_result = match br { + bitswap_service::BlockResult::Ok(bytes) => { + methods::BitswapBlockResult::Ok(methods::HexString(bytes)) + } + bitswap_service::BlockResult::Err(err) => { + let (code, message) = err.to_block_result_err(); + methods::BitswapBlockResult::Err( + methods::BitswapBlockError { code, message }, + ) + } + }; + methods::BitswapBlockResultEntry(cid, block_result) + }) + .collect(); + methods::Response::bitswap_v1_getMany(result_array) + .to_json_response(&request_id_json) + } + Err(error) => error.to_json_rpc_error(&request_id_json), + }; + let _ = me.responses_tx.send(response).await; + } + + WakeUpReason::Event(Event::BitswapStreamEvent { + subscription_id, + event, + events_rx, + }) => { + // If the JSON-RPC client unsubscribed (or this is a stale event), the + // subscription will not be in the map and we must not emit notifications for it. + if !me.bitswap_subscriptions.contains_key(&subscription_id) { + continue; + } + + match event { + Some((cid, br)) => { + let block_result = match br { + bitswap_service::BlockResult::Ok(bytes) => { + methods::BitswapBlockResult::Ok(methods::HexString(bytes)) + } + bitswap_service::BlockResult::Err(err) => { + let (code, message) = err.to_block_result_err(); + methods::BitswapBlockResult::Err(methods::BitswapBlockError { + code, + message, + }) + } + }; + let entry = methods::BitswapBlockResultEntry(cid, block_result); + let notification = methods::ServerToClient::bitswap_v1_streamEvent { + subscription: Cow::Borrowed(&subscription_id), + result: entry, + } + .to_json_request_object_parameters(None); + + let _ = me.responses_tx.send(notification).await; + + // Re-arm the pump for the next event. + me.background_tasks.push(Box::pin(async move { + let next = events_rx.recv().await.ok(); + Event::BitswapStreamEvent { + subscription_id, + event: next, + events_rx, + } + })); + } + None => { + // Channel closed — the bitswap service has emitted exactly one event per + // input CID and dropped its sender. Per spec, the JSON-RPC subscription + // remains addressable until the client calls `bitswap_v1_unstream`; we + // can drop our internal entry early since `unstream` is a no-op for an + // unknown subscription. + me.bitswap_subscriptions.remove(&subscription_id); + } + } + } + WakeUpReason::NotifyFinalizedHeads => { // All `chain_subscribeFinalizedHeads` subscriptions must be notified of the // latest finalized block. From 68993e83e3c8e1782fb5bb58d0493e6f1687ce2f Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Wed, 6 May 2026 16:40:20 +0200 Subject: [PATCH 02/15] logs added --- light-base/src/bitswap_service.rs | 156 ++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index e5ef703980..7574e930d1 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -707,6 +707,15 @@ impl BackgroundTask { }; debug_assert_eq!(batch.pending_count, 0); + log!( + &self.platform, + Trace, + &self.log_target, + "batch finalized", + batch_id = batch_id.0, + slots = batch.cid_strs.len() + ); + match batch.mode { BatchMode::GetMany { result_tx } => { let mut out = Vec::with_capacity(batch.cid_strs.len()); @@ -759,9 +768,28 @@ impl BackgroundTask { } if pending_cids.is_empty() || batch.peers_for_cancel.is_empty() { + log!( + &self.platform, + Trace, + &self.log_target, + "batch cancelled (nothing to cancel on wire)", + batch_id = batch_id.0, + pending_cids = pending_cids.len(), + peers = batch.peers_for_cancel.len() + ); return; } + log!( + &self.platform, + Trace, + &self.log_target, + "sending cancel wantlist", + batch_id = batch_id.0, + cids = ?pending_cids, + peers = batch.peers_for_cancel.len() + ); + // One Cancel wantlist message containing all pending CIDs, sent to every peer this // batch's Have broadcast reached. Cancel for an unknown CID is harmless on the receiver // side — the peer no-ops. @@ -877,6 +905,14 @@ async fn background_task(mut task: BackgroundTask) { WakeUpReason::Message(ToBackground::BitswapBlock { cid, result_tx }) => { debug_assert!(task.pending_have_broadcast.is_none()); + log!( + &task.platform, + Trace, + &task.log_target, + "queueing have wantlist (single)", + cid + ); + let message = bitswap_have_message(&cid); let network_service = task.network_service.clone(); @@ -948,6 +984,17 @@ async fn background_task(mut task: BackgroundTask) { } } + log!( + &task.platform, + Trace, + &task.log_target, + "batch queued", + batch_id = batch_id.0, + total, + valid = valid_cids.len(), + invalid = total - valid_cids.len() + ); + // Insert the batch before the broadcast: if the caller drops mid-await and a // CancelBatch arrives, it must find an entry to cancel. task.batches.insert(batch_id, batch); @@ -965,6 +1012,15 @@ async fn background_task(mut task: BackgroundTask) { continue; } + log!( + &task.platform, + Trace, + &task.log_target, + "queueing have wantlist (batch)", + batch_id = batch_id.0, + cids = ?valid_cids.iter().map(|(_, c)| c).collect::>() + ); + let message = build_bitswap_message( valid_cids.iter().map(|(_, c)| c), WantType::Have, @@ -984,6 +1040,13 @@ async fn background_task(mut task: BackgroundTask) { })); } WakeUpReason::Message(ToBackground::CancelBatch { batch_id }) => { + log!( + &task.platform, + Trace, + &task.log_target, + "cancel requested", + batch_id = batch_id.0 + ); task.cancel_batch(batch_id); } WakeUpReason::HaveBroadcastResult((result, ctx)) => match ctx { @@ -991,11 +1054,28 @@ async fn background_task(mut task: BackgroundTask) { let broadcast_to = match result { Ok(peers) => peers, Err(err) => { + log!( + &task.platform, + Trace, + &task.log_target, + "have broadcast failed (single)", + cid, + ?err + ); let _ = result_tx.send(Err(err.into())); continue; } }; + log!( + &task.platform, + Trace, + &task.log_target, + "have broadcast sent (single)", + cid, + peers = broadcast_to.len() + ); + let request_id = task.allocate_request_id(); let timeout = task.platform.now() + Duration::from_secs(10); @@ -1031,6 +1111,14 @@ async fn background_task(mut task: BackgroundTask) { let broadcast_to = match result { Ok(peers) => peers, Err(err) => { + log!( + &task.platform, + Trace, + &task.log_target, + "have broadcast failed (batch)", + batch_id = batch_id.0, + ?err + ); // Whole-broadcast failure: every still-pending slot fails with the // same error. We resolve them via `deliver_batch_slot` so that // Stream events fire and GetMany finalizes at zero. @@ -1046,6 +1134,16 @@ async fn background_task(mut task: BackgroundTask) { } }; + log!( + &task.platform, + Trace, + &task.log_target, + "have broadcast sent (batch)", + batch_id = batch_id.0, + cid_count = cids.len(), + peers = broadcast_to.len() + ); + if let Some(batch) = task.batches.get_mut(&batch_id) { batch.peers_for_cancel = broadcast_to.clone(); } else { @@ -1130,6 +1228,19 @@ async fn background_task(mut task: BackgroundTask) { continue; }; + log!( + &task.platform, + Trace, + &task.log_target, + "presence response", + peer_id, + cid, + presence = match presence_type { + BlockPresenceType::Have => "have", + BlockPresenceType::DontHave => "dont_have", + } + ); + let mut needs_block_request = false; let request_ids = entry.get_mut(); @@ -1156,6 +1267,14 @@ async fn background_task(mut task: BackgroundTask) { .remove(&(request.timeout, request_id)); debug_assert!(_was_in); + log!( + &task.platform, + Trace, + &task.log_target, + "all peers dont have, NotFound", + cid = request.cid + ); + // Defer delivery: dispatching now would re-borrow `task` while // we still hold `entry` on `task.requests_by_cid`. deliveries @@ -1175,6 +1294,15 @@ async fn background_task(mut task: BackgroundTask) { } if needs_block_request { + log!( + &task.platform, + Trace, + &task.log_target, + "sending block request", + peer_id, + cid + ); + let message = bitswap_block_message(&cid); let network_service = task.network_service.clone(); let peer_id = peer_id.clone(); @@ -1206,6 +1334,16 @@ async fn background_task(mut task: BackgroundTask) { let cid = prefix.with_digest_of(data); + log!( + &task.platform, + Trace, + &task.log_target, + "block payload received", + peer_id, + cid, + bytes = data.len() + ); + // Respond to requests asking for this CID regardless of the request stage and // remove these requests from internal structures. if let Some(request_ids) = task.requests_by_cid.remove(&cid) { @@ -1230,6 +1368,15 @@ async fn background_task(mut task: BackgroundTask) { // We either succeeded or failed in sending the "block" request. // Nothing to do on success, but we must respond to requests & cleanup on failure. if let Err(err) = result { + log!( + &task.platform, + Trace, + &task.log_target, + "block request failed", + cid, + ?err + ); + // Requests might have timed out while we were waiting for a response from // network service. if let Some(request_ids) = task.requests_by_cid.remove(&cid) { @@ -1265,6 +1412,7 @@ async fn background_task(mut task: BackgroundTask) { task.requests_by_timeout.remove(&(timeout, request_id)); let request = task.requests.remove(&request_id).unwrap(); + let cid = request.cid.clone(); match task.requests_by_cid.entry(request.cid) { hashbrown::hash_map::Entry::Occupied(mut entry) => { @@ -1287,6 +1435,14 @@ async fn background_task(mut task: BackgroundTask) { hashbrown::hash_map::Entry::Vacant(_) => unreachable!(), } + log!( + &task.platform, + Trace, + &task.log_target, + "request timeout", + cid + ); + task.deliver_slot(request.result_tx, Err(BitswapGetError::Timeout)); } } From d5c598bf5ff0b000118c61f6d61756e05c6bc13b Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Wed, 6 May 2026 16:58:39 +0200 Subject: [PATCH 03/15] Bitswap peer-set membership - logging --- light-base/src/bitswap_service.rs | 37 +++++++++++++++++++++++++++++++ light-base/src/network_service.rs | 13 +++++++++++ 2 files changed, 50 insertions(+) diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index 7574e930d1..1068b28e15 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -149,6 +149,14 @@ impl BitswapService { PARALLEL_REQUESTS, fnv::FnvBuildHasher::default(), ), + bitswap_peers: hashbrown::HashSet::with_capacity_and_hasher( + 4, + util::SipHasherBuild::new({ + let mut seed = [0; 16]; + platform.fill_random_bytes(&mut seed); + seed + }), + ), })); platform.spawn_task(log_target.clone().into(), { @@ -602,6 +610,11 @@ struct BackgroundTask { requests_by_cid: hashbrown::HashMap, util::SipHasherBuild>, /// In-flight batches. Each entry corresponds to a `bitswap_get_many` / `bitswap_stream` call. batches: hashbrown::HashMap, + /// Set of peers with an open Bitswap substream — i.e. the targets of + /// `broadcast_bitswap_message`. Maintained from `BitswapEvent::BitswapConnected`/ + /// `BitswapDisconnected`. Used purely for diagnostic logging; the wire-level set lives in + /// the network service. + bitswap_peers: hashbrown::HashSet, } impl BackgroundTask { @@ -1191,6 +1204,30 @@ async fn background_task(mut task: BackgroundTask) { } } }, + WakeUpReason::NetworkEvent(BitswapEvent::BitswapConnected { peer_id }) => { + let inserted = task.bitswap_peers.insert(peer_id.clone()); + log!( + &task.platform, + Trace, + &task.log_target, + "bitswap peer joined desired set", + peer_id, + new = inserted, + total = task.bitswap_peers.len() + ); + } + WakeUpReason::NetworkEvent(BitswapEvent::BitswapDisconnected { peer_id }) => { + let removed = task.bitswap_peers.remove(&peer_id); + log!( + &task.platform, + Trace, + &task.log_target, + "bitswap peer left desired set", + peer_id, + was_known = removed, + total = task.bitswap_peers.len() + ); + } WakeUpReason::NetworkEvent(BitswapEvent::BitswapMessage { peer_id, message }) => { let message = message.decode(); diff --git a/light-base/src/network_service.rs b/light-base/src/network_service.rs index e51cd14305..9b30770484 100644 --- a/light-base/src/network_service.rs +++ b/light-base/src/network_service.rs @@ -819,6 +819,11 @@ pub enum BitswapEvent { peer_id: PeerId, message: service::EncodedBitswapMessage, }, + /// A peer has joined the set of peers we have an open Bitswap substream with. Subsequent + /// [`NetworkServiceChain::broadcast_bitswap_message`] calls will reach this peer. + BitswapConnected { peer_id: PeerId }, + /// A peer has left the Bitswap-desired set; subsequent broadcasts will skip it. + BitswapDisconnected { peer_id: PeerId }, } /// Error returned by [`NetworkServiceChain::blocks_request`]. @@ -2603,6 +2608,9 @@ async fn background_task(mut task: BackgroundTask) { "bitswap-open-success", peer_id ); + debug_assert!(task.bitswap_event_pending_send.is_none()); + task.bitswap_event_pending_send = + Some(BitswapEvent::BitswapConnected { peer_id }); } WakeUpReason::NetworkEvent(service::Event::BitswapOpenFailed { peer_id, error }) => { log!( @@ -2649,6 +2657,11 @@ async fn background_task(mut task: BackgroundTask) { } WakeUpReason::NetworkEvent(service::Event::BitswapDisconnected { peer_id }) => { log!(&task.platform, Debug, "network", "bitswap-closed", peer_id); + debug_assert!(task.bitswap_event_pending_send.is_none()); + task.bitswap_event_pending_send = + Some(BitswapEvent::BitswapDisconnected { + peer_id: peer_id.clone(), + }); let ban_duration = Duration::from_secs(10); if matches!( task.bitswap_peering_strategy From 48e480a79d10021c28c12ac85147a2b872d49ee4 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Mon, 11 May 2026 09:30:45 +0200 Subject: [PATCH 04/15] poc: batch tests added --- e2e-tests/js/bulletin_batch.js | 458 ++++++++++++++++++++++++++++++ e2e-tests/tests/bulletin_batch.rs | 251 ++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 e2e-tests/js/bulletin_batch.js create mode 100644 e2e-tests/tests/bulletin_batch.rs diff --git a/e2e-tests/js/bulletin_batch.js b/e2e-tests/js/bulletin_batch.js new file mode 100644 index 0000000000..83d67911d3 --- /dev/null +++ b/e2e-tests/js/bulletin_batch.js @@ -0,0 +1,458 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import { webcrypto } from "node:crypto"; +import { + addChainFromSpec, + createSmoldotClient, + readJsonRpcUntil, + report, + sendRpcAndWait, +} from "./helpers.js"; + +const ERR_INVALID_PARAMS = -32602; +const ERR_FAIL = -32810; +const ERR_FAIL_RETRY = -32811; +const ERR_FAIL_BACKOFF = -32812; + +const relaySpecPath = process.env.RELAY_CHAIN_SPEC; +const bulletinSpecPath = process.env.BULLETIN_CHAIN_SPEC; +const missingCid = process.env.MISSING_CID; +const payloadsJson = process.env.PAYLOADS_JSON; +const maxCidsStr = process.env.MAX_CIDS; +if (!relaySpecPath || !bulletinSpecPath || !missingCid || !payloadsJson || !maxCidsStr) { + console.error( + "Required env vars: RELAY_CHAIN_SPEC, BULLETIN_CHAIN_SPEC, MISSING_CID, PAYLOADS_JSON, MAX_CIDS", + ); + process.exit(1); +} +const payloads = JSON.parse(payloadsJson); +const maxCids = Number.parseInt(maxCidsStr, 10); + +const client = createSmoldotClient(); +let exitCode = 0; +try { + const relay = await addChainFromSpec(client, relaySpecPath); + const bulletin = await addChainFromSpec(client, bulletinSpecPath, { + potentialRelayChains: [relay], + }); + + // ===== getMany section ===== + await runGetManyHappy(bulletin); + await runGetManyDedup(bulletin); + await runGetManyTooMany(bulletin); + await runGetManyPerCidErrors(bulletin); + await runGetManyMixed(bulletin); + + // ===== stream section ===== + await runStreamHappy(bulletin); + await runStreamDedup(bulletin); + await runStreamTooMany(bulletin); + await runStreamPerCidErrors(bulletin); + await runStreamMixed(bulletin); +} catch (err) { + console.error(`bulletin_batch error: ${err?.stack || err}`); + exitCode = 1; +} finally { + try { + await client.terminate(); + } catch (_) {} +} + +if (exitCode || process.exitCode) { + process.exit(exitCode || 1); +} + +// ---------- getMany tests ---------- + +async function runGetManyHappy(chain) { + const cids = payloads.map((p) => p.cid); + try { + const result = await getManyWithRetry(chain, cids); + const checkErr = await verifyGetManyResult(result, payloads); + report("gm-happy", checkErr === null, checkErr ?? `${cids.length} entries`); + } catch (err) { + report("gm-happy", false, err.message); + } +} + +async function runGetManyDedup(chain) { + const cids = [payloads[0].cid, payloads[0].cid]; + try { + await sendRpcAndWait(chain, "bitswap_v1_getMany", [cids]); + report("gm-dedup", false, "expected DuplicateCids rejection, got success"); + } catch (err) { + const code = errorCode(err); + const variant = errorVariant(err); + const ok = code === ERR_INVALID_PARAMS && variant === "DuplicateCids"; + report( + "gm-dedup", + ok, + ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/DuplicateCids, got ${code}/${variant}`, + ); + } +} + +async function runGetManyTooMany(chain) { + // parse_and_dedup() checks length BEFORE deduping, so identical CIDs work as + // long as the array length exceeds MAX_CIDS_PER_REQUEST. + const cids = Array(maxCids + 1).fill(payloads[0].cid); + try { + await sendRpcAndWait(chain, "bitswap_v1_getMany", [cids]); + report("gm-too-many", false, "expected TooManyCids rejection, got success"); + } catch (err) { + const code = errorCode(err); + const variant = errorVariant(err); + const ok = code === ERR_INVALID_PARAMS && variant === "TooManyCids"; + report( + "gm-too-many", + ok, + ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/TooManyCids, got ${code}/${variant}`, + ); + } +} + +async function runGetManyPerCidErrors(chain) { + const valid = payloads[0]; + const cids = [valid.cid, "not-a-cid", missingCid]; + try { + const result = await getManyWithRetry(chain, cids); + if (!Array.isArray(result) || result.length !== 3) { + report("gm-per-cid-errors", false, `expected 3-entry array, got ${JSON.stringify(result)}`); + return; + } + const [tup0, tup1, tup2] = result; + if (tup0[0] !== valid.cid || !isHexString(tup0[1])) { + report("gm-per-cid-errors", false, `slot 0 expected Ok hex, got ${JSON.stringify(tup0)}`); + return; + } + const okBytes = await verifyHexAgainstPayload(tup0[1], valid); + if (okBytes !== null) { + report("gm-per-cid-errors", false, `slot 0 bytes mismatch: ${okBytes}`); + return; + } + if (tup1[0] !== "not-a-cid" || !isErrObject(tup1[1]) || tup1[1].code !== ERR_INVALID_PARAMS) { + report("gm-per-cid-errors", false, `slot 1 expected ${ERR_INVALID_PARAMS}, got ${JSON.stringify(tup1)}`); + return; + } + if (tup2[0] !== missingCid || !isErrObject(tup2[1]) || tup2[1].code !== ERR_FAIL) { + report("gm-per-cid-errors", false, `slot 2 expected ${ERR_FAIL}, got ${JSON.stringify(tup2)}`); + return; + } + report("gm-per-cid-errors", true, `Ok, ${tup1[1].code}, ${tup2[1].code}`); + } catch (err) { + report("gm-per-cid-errors", false, err.message); + } +} + +async function runGetManyMixed(chain) { + const fullOnly = payloads.filter((p) => !p.on_partial); + if (fullOnly.length === 0) { + report("gm-mixed", true, "skipped (no full-only payloads)"); + return; + } + const cids = fullOnly.map((p) => p.cid); + try { + const result = await getManyWithRetry(chain, cids); + const checkErr = await verifyGetManyResult(result, fullOnly); + report("gm-mixed", checkErr === null, checkErr ?? `${cids.length} entries`); + } catch (err) { + report("gm-mixed", false, err.message); + } +} + +// ---------- stream tests ---------- + +async function runStreamHappy(chain) { + const cids = payloads.map((p) => p.cid); + try { + const events = await streamCollect(chain, cids); + const checkErr = await verifyStreamMap(events, payloads); + report("st-happy", checkErr === null, checkErr ?? `${cids.length} events`); + } catch (err) { + report("st-happy", false, err.message); + } +} + +async function runStreamDedup(chain) { + const cids = [payloads[0].cid, payloads[0].cid]; + try { + await sendRpcAndWait(chain, "bitswap_v1_stream", [cids]); + report("st-dedup", false, "expected DuplicateCids rejection at subscription, got success"); + } catch (err) { + const code = errorCode(err); + const variant = errorVariant(err); + const ok = code === ERR_INVALID_PARAMS && variant === "DuplicateCids"; + report( + "st-dedup", + ok, + ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/DuplicateCids, got ${code}/${variant}`, + ); + } +} + +async function runStreamTooMany(chain) { + const cids = Array(maxCids + 1).fill(payloads[0].cid); + try { + await sendRpcAndWait(chain, "bitswap_v1_stream", [cids]); + report("st-too-many", false, "expected TooManyCids rejection at subscription, got success"); + } catch (err) { + const code = errorCode(err); + const variant = errorVariant(err); + const ok = code === ERR_INVALID_PARAMS && variant === "TooManyCids"; + report( + "st-too-many", + ok, + ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/TooManyCids, got ${code}/${variant}`, + ); + } +} + +async function runStreamPerCidErrors(chain) { + const valid = payloads[0]; + const cids = [valid.cid, "not-a-cid", missingCid]; + try { + const events = await streamCollect(chain, cids); + const okEntry = events.get(valid.cid); + const invalidEntry = events.get("not-a-cid"); + const missingEntry = events.get(missingCid); + if (!okEntry || !isHexString(okEntry)) { + report("st-per-cid-errors", false, `valid slot expected Ok hex, got ${JSON.stringify(okEntry)}`); + return; + } + const okBytes = await verifyHexAgainstPayload(okEntry, valid); + if (okBytes !== null) { + report("st-per-cid-errors", false, `valid slot bytes mismatch: ${okBytes}`); + return; + } + if (!isErrObject(invalidEntry) || invalidEntry.code !== ERR_INVALID_PARAMS) { + report("st-per-cid-errors", false, `invalid-cid expected ${ERR_INVALID_PARAMS}, got ${JSON.stringify(invalidEntry)}`); + return; + } + if (!isErrObject(missingEntry) || missingEntry.code !== ERR_FAIL) { + report("st-per-cid-errors", false, `missing-cid expected ${ERR_FAIL}, got ${JSON.stringify(missingEntry)}`); + return; + } + report("st-per-cid-errors", true, `Ok, ${invalidEntry.code}, ${missingEntry.code}`); + } catch (err) { + report("st-per-cid-errors", false, err.message); + } +} + +async function runStreamMixed(chain) { + const fullOnly = payloads.filter((p) => !p.on_partial); + if (fullOnly.length === 0) { + report("st-mixed", true, "skipped (no full-only payloads)"); + return; + } + const cids = fullOnly.map((p) => p.cid); + try { + const events = await streamCollect(chain, cids); + const checkErr = await verifyStreamMap(events, fullOnly); + report("st-mixed", checkErr === null, checkErr ?? `${cids.length} events`); + } catch (err) { + report("st-mixed", false, err.message); + } +} + +// ---------- subscription helper ---------- + +/// Subscribes via bitswap_v1_stream, collects exactly `cids.length` events, +/// then politely unsubscribes. Returns a Map. The order in +/// which events arrive is not asserted (per spec, arrival order, not input +/// order). +async function streamCollect(chain, cids, totalBudgetMs = 180_000) { + const subscription = await subscribeWithRetry(chain, cids, totalBudgetMs); + const collected = new Map(); + const deadline = Date.now() + totalBudgetMs; + while (collected.size < cids.length) { + const got = await readJsonRpcUntil( + chain, + (msg) => { + if ( + msg.method === "bitswap_v1_streamEvent" && + msg.params && + msg.params.subscription === subscription + ) { + return msg.params.result; + } + return undefined; + }, + deadline, + ); + if (got === undefined) { + throw new Error( + `stream timed out: collected ${collected.size}/${cids.length} events`, + ); + } + const [cid, blockResult] = got; + collected.set(cid, blockResult); + } + // Polite cancel; we don't assert on the response. + try { + await sendRpcAndWait(chain, "bitswap_v1_unstream", [subscription], 10_000); + } catch (_) {} + return collected; +} + +async function subscribeWithRetry(chain, cids, totalBudgetMs) { + const deadline = Date.now() + totalBudgetMs; + let attempt = 0; + while (true) { + attempt += 1; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`bitswap_v1_stream timed out after ${totalBudgetMs}ms`); + } + try { + return await sendRpcAndWait(chain, "bitswap_v1_stream", [cids], Math.min(60_000, remaining)); + } catch (err) { + const code = errorCode(err); + if (code === ERR_FAIL_BACKOFF || code === ERR_FAIL_RETRY) { + const backoff = Math.min(5_000, 500 * 2 ** Math.min(attempt - 1, 3)); + await new Promise((r) => setTimeout(r, backoff)); + continue; + } + throw err; + } + } +} + +// ---------- getMany helper ---------- + +/// Same retry strategy as bulletin_fetch.js's `bitswapGetWithRetry`: retry on +/// transient FailRetry / FailRetryBackoff while smoldot's peer set warms up. +async function getManyWithRetry(chain, cids, totalBudgetMs = 180_000) { + const deadline = Date.now() + totalBudgetMs; + let attempt = 0; + while (true) { + attempt += 1; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`bitswap_v1_getMany timed out after ${totalBudgetMs}ms`); + } + try { + return await sendRpcAndWait(chain, "bitswap_v1_getMany", [cids], Math.min(60_000, remaining)); + } catch (err) { + const code = errorCode(err); + if (code === ERR_FAIL_BACKOFF || code === ERR_FAIL_RETRY) { + const backoff = Math.min(5_000, 500 * 2 ** Math.min(attempt - 1, 3)); + await new Promise((r) => setTimeout(r, backoff)); + continue; + } + throw err; + } + } +} + +// ---------- verification helpers ---------- + +/// Asserts a `bitswap_v1_getMany` response is an array of `[cid, hex]` tuples +/// in input order, and each tuple's hex content matches the corresponding +/// payload. Returns null on success, or a string explaining the first +/// mismatch. +async function verifyGetManyResult(result, expectedPayloads) { + if (!Array.isArray(result) || result.length !== expectedPayloads.length) { + return `expected ${expectedPayloads.length}-entry array, got ${JSON.stringify(result)}`; + } + for (let i = 0; i < expectedPayloads.length; i++) { + const tup = result[i]; + const p = expectedPayloads[i]; + if (!Array.isArray(tup) || tup.length !== 2 || tup[0] !== p.cid) { + return `slot ${i}: expected cid ${p.cid}, got ${JSON.stringify(tup)}`; + } + if (!isHexString(tup[1])) { + return `slot ${i}: expected Ok hex, got ${JSON.stringify(tup[1])}`; + } + const mismatch = await verifyHexAgainstPayload(tup[1], p); + if (mismatch !== null) { + return `slot ${i} (${p.label}): ${mismatch}`; + } + } + return null; +} + +/// Asserts the collected `bitswap_v1_streamEvent` map contains every expected +/// payload by CID, with bytes matching size and sha256. Order-agnostic. +async function verifyStreamMap(events, expectedPayloads) { + if (events.size !== expectedPayloads.length) { + return `expected ${expectedPayloads.length} events, got ${events.size}`; + } + for (const p of expectedPayloads) { + const blockResult = events.get(p.cid); + if (blockResult === undefined) { + return `missing event for cid ${p.cid} (${p.label})`; + } + if (!isHexString(blockResult)) { + return `${p.label}: expected Ok hex, got ${JSON.stringify(blockResult)}`; + } + const mismatch = await verifyHexAgainstPayload(blockResult, p); + if (mismatch !== null) { + return `${p.label}: ${mismatch}`; + } + } + return null; +} + +async function verifyHexAgainstPayload(hex, payload) { + const bytes = hexToBytes(hex); + if (bytes.length !== payload.size) { + return `size ${bytes.length} != ${payload.size}`; + } + const sha = await sha256Hex(bytes); + if (sha !== payload.sha256) { + return `sha256 mismatch (got ${sha.slice(0, 12)}…, expected ${payload.sha256.slice(0, 12)}…)`; + } + return null; +} + +function isHexString(v) { + return typeof v === "string" && v.startsWith("0x"); +} + +function isErrObject(v) { + return typeof v === "object" && v !== null && typeof v.code === "number"; +} + +function errorCode(err) { + const m = /"code":(-?\d+)/.exec(err.message ?? ""); + return m ? Number.parseInt(m[1], 10) : null; +} + +function errorVariant(err) { + const m = /"variant":"([^"]+)"/.exec(err.message ?? ""); + return m ? m[1] : null; +} + +function hexToBytes(hex) { + const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; + if (stripped.length % 2 !== 0) { + throw new Error(`odd-length hex: ${stripped.length}`); + } + const out = new Uint8Array(stripped.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(stripped.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +async function sha256Hex(bytes) { + const digest = await webcrypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/e2e-tests/tests/bulletin_batch.rs b/e2e-tests/tests/bulletin_batch.rs new file mode 100644 index 0000000000..5b7e729b90 --- /dev/null +++ b/e2e-tests/tests/bulletin_batch.rs @@ -0,0 +1,251 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; +use serde::Serialize; +use smoldot_e2e_tests::{ + bulletin, ensure_js_deps_installed, ensure_smoldot_built, resolve_base_dir, run_js_test, +}; +use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; + +/// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. Same +/// artifacts as `bulletin_fetch.rs`. +const DB_SNAPSHOT_RELAY: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN_FULL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; + +/// Mirrors `light-base/src/bitswap_service.rs::MAX_CIDS_PER_REQUEST`. +const MAX_CIDS: u32 = 64; + +#[derive(Serialize)] +struct PayloadJson { + label: &'static str, + cid: String, + sha256: String, + size: u64, + on_partial: bool, +} + +/// Drives `bitswap_v1_getMany` and `bitswap_v1_stream` against a real bulletin +/// chain. Both methods are exercised in the same JS run on the same chain +/// handle: happy path, dedup rejection, too-many rejection, per-CID errors, +/// and mixed-availability (some CIDs only on the full-snapshot collator). +#[tokio::test(flavor = "multi_thread")] +async fn bulletin_batch() -> Result<()> { + env_logger::try_init().ok(); + + let chain_spec = bulletin_chain_spec(); + let base_dir = resolve_base_dir()?; + + let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); + let bulletin_full = get_snapshot_url( + DB_SNAPSHOT_BULLETIN_FULL, + "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", + ); + let bulletin_partial = get_snapshot_url( + DB_SNAPSHOT_BULLETIN_PARTIAL, + "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", + ); + + let network = spawn_with_snapshots( + &base_dir, + &chain_spec, + &relay, + &bulletin_full, + &bulletin_partial, + ) + .await?; + + let (relay_spec, bulletin_spec) = chain_spec_paths(&network)?; + + ensure_smoldot_built(); + ensure_js_deps_installed(); + + let payloads_json = serde_json::to_string( + &bulletin::payloads() + .iter() + .map(|p| PayloadJson { + label: p.label, + cid: p.predicted_cid(), + sha256: p.sha256_hex(), + size: p.size(), + on_partial: p.on_partial, + }) + .collect::>(), + )?; + let missing_cid = bulletin::sha256_cid(b"smoldot-bitswap-not-on-chain").to_string(); + let max_cids = MAX_CIDS.to_string(); + let relay_spec = relay_spec + .to_str() + .ok_or_else(|| anyhow!("non-utf8 relay spec path"))?; + let bulletin_spec = bulletin_spec + .to_str() + .ok_or_else(|| anyhow!("non-utf8 bulletin spec path"))?; + + let env_pairs = [ + ("RELAY_CHAIN_SPEC", relay_spec), + ("BULLETIN_CHAIN_SPEC", bulletin_spec), + ("PAYLOADS_JSON", payloads_json.as_str()), + ("MISSING_CID", missing_cid.as_str()), + ("MAX_CIDS", max_cids.as_str()), + ]; + + if std::env::var("DEV_MODE").is_ok() { + print_dev_mode_invocation(&env_pairs); + let secs: u64 = std::env::var("KEEP_ALIVE_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(36000); + eprintln!("DEV_MODE: keeping zombienet alive for {secs}s (set KEEP_ALIVE_SECS to override)..."); + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + return Ok(()); + } + + run_js_test("js/bulletin_batch.js", &env_pairs) + .await + .map_err(|e| anyhow!("JS test failed: {e}")) +} + +/// Emit a copy-pasteable shell command equivalent to what `run_js_test` would +/// execute. Used in `DEV_MODE` so a developer can iterate on the JS client +/// against a long-lived zombienet without restarting the cargo harness. +fn print_dev_mode_invocation(env_pairs: &[(&str, &str)]) { + println!(); + println!("=== DEV_MODE: skipping JS test, run it manually with: ==="); + println!(); + println!("cd e2e-tests && \\"); + for (k, v) in env_pairs { + println!(" {}={} \\", k, shell_quote(v)); + } + println!(" node js/bulletin_batch.js"); + println!(); +} + +/// Single-quote a string for safe shell pasting. Embedded single quotes are +/// escaped via the standard `'\''` trick. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Returns the GCS URL by default, or the contents of `env_var` if set +/// (so a developer can point at a local `.tgz` for iteration). +fn get_snapshot_url(default: &str, env_var: &str) -> String { + std::env::var(env_var).unwrap_or_else(|_| default.to_string()) +} + +fn bulletin_chain_spec() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("chain-specs/bulletin-westend-local-spec.json") +} + +async fn spawn_with_snapshots( + base_dir: &Path, + chain_spec: &Path, + relay_snap: &str, + bulletin_full_snap: &str, + bulletin_partial_snap: &str, +) -> Result> { + let chain_spec_str = chain_spec + .to_str() + .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? + .to_string(); + let base_dir_str = base_dir + .to_str() + .ok_or_else(|| anyhow!("non-utf8 base dir"))? + .to_string(); + let relay = relay_snap.to_string(); + let bulletin_full = bulletin_full_snap.to_string(); + let bulletin_partial = bulletin_partial_snap.to_string(); + + let cfg = NetworkConfigBuilder::new() + .with_relaychain(|rc| { + rc.with_chain(bulletin::RELAY_CHAIN) + .with_default_command(bulletin::RELAY_BINARY) + .with_validator(|n| { + n.with_name("alice") + .bootnode(true) + .with_db_snapshot(relay.as_str()) + }) + .with_validator(|n| { + n.with_name("bob") + .bootnode(true) + .with_db_snapshot(relay.as_str()) + }) + }) + .with_parachain(|p| { + p.with_id(bulletin::PARA_ID) + .with_chain_spec_path(chain_spec_str.as_str()) + .cumulus_based(true) + // Skip the embedded relay client and proxy relay-chain + // queries through alice/bob's RPC. Zombienet expands the + // `{{ZOMBIE::ws_uri}}` templates at spawn time. This + // sidesteps the relay-side libp2p discovery quirks we hit + // with the embedded relay (see polkadot-sdk's + // `full_node_warp_sync/common.rs` for the same pattern on + // collators "four" / "five"). + .with_default_args(vec![ + "--ipfs-server".into(), + "-lsub-libp2p::bitswap=trace".into(), + "-lsync=debug".into(), + ("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into(), + ]) + .with_collator(|c| { + c.with_name("collator-1") + .validator(true) + .bootnode(true) + .with_command(bulletin::PARA_BINARY) + .with_db_snapshot(bulletin_full.as_str()) + }) + .with_collator(|c| { + c.with_name("collator-2") + .validator(true) + .bootnode(true) + .with_command(bulletin::PARA_BINARY) + .with_db_snapshot(bulletin_partial.as_str()) + }) + }) + .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) + .build() + .map_err(|e| anyhow!("network config errors: {e:?}"))?; + + let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); + let network = spawn_fn(cfg).await?; + network.detach().await; + network.wait_until_is_up(180).await?; + Ok(network) +} + +/// Returns the raw chain-spec files zombienet emits for the relay and the +/// bulletin parachain. Smoldot consumes these directly. +fn chain_spec_paths(network: &Network) -> Result<(PathBuf, PathBuf)> { + let base_dir = PathBuf::from( + network + .base_dir() + .ok_or_else(|| anyhow!("network has no base_dir"))?, + ); + let relay_chain = network.relaychain().chain(); + let relay_path = base_dir.join(format!("{relay_chain}.json")); + let para = network + .parachain(bulletin::PARA_ID) + .ok_or_else(|| anyhow!("parachain {} not found", bulletin::PARA_ID))?; + let para_path = base_dir.join(format!("{}.json", para.unique_id())); + Ok((relay_path, para_path)) +} From 85bee78db420f37b1a6f43782db1bd420ae27594 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Tue, 12 May 2026 17:27:32 +0200 Subject: [PATCH 05/15] fixing tests --- .github/workflows/zombienet.yml | 3 + e2e-tests/js/bulletin_batch.js | 7 +- e2e-tests/js/package-lock.json | 2 +- e2e-tests/src/bulletin.rs | 39 +---- e2e-tests/src/snapshot.rs | 2 +- e2e-tests/tests/bulletin_batch.rs | 40 ++--- e2e-tests/tests/bulletin_fetch.rs | 109 ++++++++------ e2e-tests/tests/bulletin_generate_snapshot.rs | 138 ++++++++---------- 8 files changed, 153 insertions(+), 187 deletions(-) diff --git a/.github/workflows/zombienet.yml b/.github/workflows/zombienet.yml index 3c8ceb6057..b6972a9c68 100644 --- a/.github/workflows/zombienet.yml +++ b/.github/workflows/zombienet.yml @@ -80,6 +80,9 @@ jobs: - job-name: "zombienet-smoldot-0007-bulletin_fetch" test: "bulletin_fetch" runner-type: "default" + - job-name: "zombienet-smoldot-0008-bulletin_batch" + test: "bulletin_batch" + runner-type: "default" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/e2e-tests/js/bulletin_batch.js b/e2e-tests/js/bulletin_batch.js index 83d67911d3..b18ef34a32 100644 --- a/e2e-tests/js/bulletin_batch.js +++ b/e2e-tests/js/bulletin_batch.js @@ -73,9 +73,10 @@ try { } catch (_) {} } -if (exitCode || process.exitCode) { - process.exit(exitCode || 1); -} +// Force exit so Node doesn't sit waiting for the smoldot client's underlying +// WebSocket / TCP handles to drain on their own — that takes a few minutes +// in practice (graceful close handshakes for the 6 peer connections). +process.exit(exitCode || process.exitCode || 0); // ---------- getMany tests ---------- diff --git a/e2e-tests/js/package-lock.json b/e2e-tests/js/package-lock.json index 2fa7c6aa1a..70b2b1125a 100644 --- a/e2e-tests/js/package-lock.json +++ b/e2e-tests/js/package-lock.json @@ -10,7 +10,7 @@ }, "../../wasm-node/javascript": { "name": "smoldot", - "version": "3.1.1", + "version": "3.1.2", "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", "dependencies": { "ws": "^8.8.1" diff --git a/e2e-tests/src/bulletin.rs b/e2e-tests/src/bulletin.rs index ef4c43d6f8..54c4ecb5b3 100644 --- a/e2e-tests/src/bulletin.rs +++ b/e2e-tests/src/bulletin.rs @@ -33,20 +33,6 @@ pub const PARA_BINARY: &str = "polkadot-parachain"; /// Default snapshot height target. Must exceed 1000 blocks. pub const DEFAULT_SNAPSHOT_HEIGHT: u64 = 1024; -/// Index after which the partial bulletin snapshot is taken. -/// -/// The generator produces two bulletin DB snapshots from one network run: -/// -/// - `bulletin-full.tgz` — every payload in [`payloads`] is injected. -/// - `bulletin-partial.tgz` — only the first `PARTIAL_FORK_INDEX` payloads -/// are injected, then the partial snapshot is captured. -/// -/// The CI test for mixed availability loads `bulletin-full` on one -/// collator and `bulletin-partial` on another, then fetches a CID that -/// exists only in `bulletin-full` to verify smoldot still finds it via -/// gossip when a peer reports `DontHave`. -pub const PARTIAL_FORK_INDEX: usize = 2; - /// CIDv1 multicodec for the `raw` codec. const CODEC_RAW: u64 = 0x55; @@ -56,8 +42,6 @@ const CODEC_RAW: u64 = 0x55; pub struct Payload { pub label: &'static str, pub content: &'static [u8], - /// Whether the partial bulletin snapshot also contains this CID. - pub on_partial: bool, } impl Payload { @@ -78,33 +62,24 @@ impl Payload { } } -/// Deterministic payloads the generator injects and the CI tests assert -/// on. Labels prefixed `all-nodes-*` are present on every bulletin node; -/// `one-node-*` payloads are present only on the collator that loads -/// `bulletin-full.tgz`. Order matters: items at -/// `[..PARTIAL_FORK_INDEX]` go in before the partial snapshot is -/// captured. +/// Deterministic payloads the generator injects and the CI tests assert on. pub fn payloads() -> Vec { vec![ Payload { - label: "all-nodes-with-26b-payload", + label: "payload-26b", content: b"smoldot-bitswap-both-small", - on_partial: true, }, Payload { - label: "all-nodes-with-4kib-payload", + label: "payload-4kib", content: rand_4k(), - on_partial: true, }, Payload { - label: "one-node-with-31b-payload", + label: "payload-31b", content: b"smoldot-bitswap-full-only-small", - on_partial: false, }, Payload { - label: "one-node-with-1mib-payload", + label: "payload-1mib", content: rand_1m(), - on_partial: false, }, ] } @@ -182,14 +157,12 @@ pub struct ManifestPayload { pub cid: String, pub sha256: String, pub size: u64, - pub on_partial: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArchiveChecksums { pub relay_sha256: String, - pub bulletin_full_sha256: String, - pub bulletin_partial_sha256: String, + pub bulletin_sha256: String, } /// Manifest emitted alongside the snapshots by the generator. Bumping diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs index b83df2d4e7..87c1513fdf 100644 --- a/e2e-tests/src/snapshot.rs +++ b/e2e-tests/src/snapshot.rs @@ -36,7 +36,7 @@ use std::path::PathBuf; use anyhow::anyhow; -pub const ARTIFACTS_VERSION: &str = "v1"; +pub const ARTIFACTS_VERSION: &str = "v2"; const GCS_BASE: &str = "https://storage.googleapis.com/zombienet-db-snaps/zombienet/smoldot_smoke_db"; diff --git a/e2e-tests/tests/bulletin_batch.rs b/e2e-tests/tests/bulletin_batch.rs index 5b7e729b90..7a87bdcef9 100644 --- a/e2e-tests/tests/bulletin_batch.rs +++ b/e2e-tests/tests/bulletin_batch.rs @@ -28,10 +28,8 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; /// artifacts as `bulletin_fetch.rs`. const DB_SNAPSHOT_RELAY: &str = "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_FULL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-2026-05-04.tgz"; /// Mirrors `light-base/src/bitswap_service.rs::MAX_CIDS_PER_REQUEST`. const MAX_CIDS: u32 = 64; @@ -42,13 +40,11 @@ struct PayloadJson { cid: String, sha256: String, size: u64, - on_partial: bool, } /// Drives `bitswap_v1_getMany` and `bitswap_v1_stream` against a real bulletin /// chain. Both methods are exercised in the same JS run on the same chain -/// handle: happy path, dedup rejection, too-many rejection, per-CID errors, -/// and mixed-availability (some CIDs only on the full-snapshot collator). +/// handle: happy path, dedup rejection, too-many rejection, per-CID errors. #[tokio::test(flavor = "multi_thread")] async fn bulletin_batch() -> Result<()> { env_logger::try_init().ok(); @@ -57,23 +53,10 @@ async fn bulletin_batch() -> Result<()> { let base_dir = resolve_base_dir()?; let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); - let bulletin_full = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_FULL, - "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", - ); - let bulletin_partial = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_PARTIAL, - "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", - ); + let bulletin = get_snapshot_url(DB_SNAPSHOT_BULLETIN, "DB_SNAPSHOT_BULLETIN_OVERRIDE"); - let network = spawn_with_snapshots( - &base_dir, - &chain_spec, - &relay, - &bulletin_full, - &bulletin_partial, - ) - .await?; + let network = + spawn_with_snapshots(&base_dir, &chain_spec, &relay, &bulletin).await?; let (relay_spec, bulletin_spec) = chain_spec_paths(&network)?; @@ -88,7 +71,6 @@ async fn bulletin_batch() -> Result<()> { cid: p.predicted_cid(), sha256: p.sha256_hex(), size: p.size(), - on_partial: p.on_partial, }) .collect::>(), )?; @@ -160,8 +142,7 @@ async fn spawn_with_snapshots( base_dir: &Path, chain_spec: &Path, relay_snap: &str, - bulletin_full_snap: &str, - bulletin_partial_snap: &str, + bulletin_snap: &str, ) -> Result> { let chain_spec_str = chain_spec .to_str() @@ -172,8 +153,7 @@ async fn spawn_with_snapshots( .ok_or_else(|| anyhow!("non-utf8 base dir"))? .to_string(); let relay = relay_snap.to_string(); - let bulletin_full = bulletin_full_snap.to_string(); - let bulletin_partial = bulletin_partial_snap.to_string(); + let bulletin = bulletin_snap.to_string(); let cfg = NetworkConfigBuilder::new() .with_relaychain(|rc| { @@ -212,14 +192,14 @@ async fn spawn_with_snapshots( .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_full.as_str()) + .with_db_snapshot(bulletin.as_str()) }) .with_collator(|c| { c.with_name("collator-2") .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_partial.as_str()) + .with_db_snapshot(bulletin.as_str()) }) }) .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) diff --git a/e2e-tests/tests/bulletin_fetch.rs b/e2e-tests/tests/bulletin_fetch.rs index cdd108229d..d885d9dc8d 100644 --- a/e2e-tests/tests/bulletin_fetch.rs +++ b/e2e-tests/tests/bulletin_fetch.rs @@ -27,10 +27,8 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; /// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. const DB_SNAPSHOT_RELAY: &str = "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_FULL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-2026-05-04.tgz"; #[derive(Serialize)] struct PayloadJson { @@ -38,11 +36,10 @@ struct PayloadJson { cid: String, sha256: String, size: u64, - on_partial: bool, } -/// Smoldot fetches every CID in `bulletin::payloads()`, asserts NotFound -/// for an unrelated CID, and exercises mixed-availability peer selection. +/// Smoldot fetches every CID in `bulletin::payloads()` and asserts +/// NotFound for an unrelated CID. Both collators share the same snapshot. #[tokio::test(flavor = "multi_thread")] async fn bulletin_fetch() -> Result<()> { env_logger::try_init().ok(); @@ -51,23 +48,10 @@ async fn bulletin_fetch() -> Result<()> { let base_dir = resolve_base_dir()?; let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); - let bulletin_full = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_FULL, - "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", - ); - let bulletin_partial = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_PARTIAL, - "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", - ); + let bulletin = get_snapshot_url(DB_SNAPSHOT_BULLETIN, "DB_SNAPSHOT_BULLETIN_OVERRIDE"); - let network = spawn_with_snapshots( - &base_dir, - &chain_spec, - &relay, - &bulletin_full, - &bulletin_partial, - ) - .await?; + let network = + spawn_with_snapshots(&base_dir, &chain_spec, &relay, &bulletin).await?; let (relay_spec, bulletin_spec) = chain_spec_paths(&network)?; @@ -82,7 +66,6 @@ async fn bulletin_fetch() -> Result<()> { cid: p.predicted_cid(), sha256: p.sha256_hex(), size: p.size(), - on_partial: p.on_partial, }) .collect::>(), )?; @@ -94,17 +77,54 @@ async fn bulletin_fetch() -> Result<()> { .to_str() .ok_or_else(|| anyhow!("non-utf8 bulletin spec path"))?; - run_js_test( - "js/bulletin_fetch.js", - &[ - ("RELAY_CHAIN_SPEC", relay_spec), - ("BULLETIN_CHAIN_SPEC", bulletin_spec), - ("PAYLOADS_JSON", payloads_json.as_str()), - ("MISSING_CID", missing_cid.as_str()), - ], - ) - .await - .map_err(|e| anyhow!("JS test failed: {e}")) + let env_pairs = [ + ("RELAY_CHAIN_SPEC", relay_spec), + ("BULLETIN_CHAIN_SPEC", bulletin_spec), + ("PAYLOADS_JSON", payloads_json.as_str()), + ("MISSING_CID", missing_cid.as_str()), + ]; + + // `DEV_MODE=1` skips the JS bitswap suite and keeps the network alive + // for `KEEP_ALIVE_SECS` seconds (default 36000) so a developer can + // run the JS client manually (the printed `node …` invocation has + // every env var the test would have set). + if std::env::var("DEV_MODE").is_ok() { + print_dev_mode_invocation(&env_pairs); + let secs: u64 = std::env::var("KEEP_ALIVE_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(36000); + eprintln!( + "DEV_MODE: keeping zombienet alive for {secs}s (set KEEP_ALIVE_SECS to override)..." + ); + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + return Ok(()); + } + + run_js_test("js/bulletin_fetch.js", &env_pairs) + .await + .map_err(|e| anyhow!("JS test failed: {e}")) +} + +/// Emit a copy-pasteable shell command equivalent to what `run_js_test` would +/// execute. Used in `DEV_MODE` so a developer can iterate on the JS client +/// against a long-lived zombienet without restarting the cargo harness. +fn print_dev_mode_invocation(env_pairs: &[(&str, &str)]) { + println!(); + println!("=== DEV_MODE: skipping JS test, run it manually with: ==="); + println!(); + println!("cd e2e-tests && \\"); + for (k, v) in env_pairs { + println!(" {}={} \\", k, shell_quote(v)); + } + println!(" node js/bulletin_fetch.js"); + println!(); +} + +/// Single-quote a string for safe shell pasting. Embedded single quotes are +/// escaped via the standard `'\''` trick. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) } /// Returns the GCS URL by default, or the contents of `env_var` if set @@ -121,8 +141,7 @@ async fn spawn_with_snapshots( base_dir: &Path, chain_spec: &Path, relay_snap: &str, - bulletin_full_snap: &str, - bulletin_partial_snap: &str, + bulletin_snap: &str, ) -> Result> { let chain_spec_str = chain_spec .to_str() @@ -133,8 +152,7 @@ async fn spawn_with_snapshots( .ok_or_else(|| anyhow!("non-utf8 base dir"))? .to_string(); let relay = relay_snap.to_string(); - let bulletin_full = bulletin_full_snap.to_string(); - let bulletin_partial = bulletin_partial_snap.to_string(); + let bulletin = bulletin_snap.to_string(); let cfg = NetworkConfigBuilder::new() .with_relaychain(|rc| { @@ -155,21 +173,26 @@ async fn spawn_with_snapshots( p.with_id(bulletin::PARA_ID) .with_chain_spec_path(chain_spec_str.as_str()) .cumulus_based(true) + // See `bulletin_generate_snapshot::spawn_network` for why + // `--relay-chain-rpc-urls` is used here instead of an + // embedded relay client. + .with_default_args(vec![ + "--ipfs-server".into(), + ("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into(), + ]) .with_collator(|c| { c.with_name("collator-1") .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_full.as_str()) - .with_args(vec!["--ipfs-server".into()]) + .with_db_snapshot(bulletin.as_str()) }) .with_collator(|c| { c.with_name("collator-2") .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_partial.as_str()) - .with_args(vec!["--ipfs-server".into()]) + .with_db_snapshot(bulletin.as_str()) }) }) .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) diff --git a/e2e-tests/tests/bulletin_generate_snapshot.rs b/e2e-tests/tests/bulletin_generate_snapshot.rs index 12fb1fef7d..e16ef15a77 100644 --- a/e2e-tests/tests/bulletin_generate_snapshot.rs +++ b/e2e-tests/tests/bulletin_generate_snapshot.rs @@ -22,8 +22,9 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use log::info; -use smoldot_e2e_tests::bulletin::{ - self, ArchiveChecksums, BulletinManifest, ManifestPayload, Payload, +use smoldot_e2e_tests::{ + bulletin::{self, ArchiveChecksums, BulletinManifest, ManifestPayload, Payload}, + resolve_base_dir, }; use zombienet_sdk::{ subxt::{ @@ -153,15 +154,10 @@ async fn bulletin_generate_snapshot() -> Result<()> { authorize_account(&api, &alice, &alice).await?; let payloads = bulletin::payloads(); - let (phase_1, phase_2) = payloads.split_at(bulletin::PARTIAL_FORK_INDEX); - info!( - "injecting {} pre-fork + {} post-fork payloads", - phase_1.len(), - phase_2.len() - ); + info!("injecting {} payloads", payloads.len()); let mut emitted_cids = Vec::new(); - for payload in phase_1 { + for payload in &payloads { let cid_str = submit_store(&api, &alice, payload).await?; emitted_cids.push((payload.label, cid_str)); } @@ -171,15 +167,6 @@ async fn bulletin_generate_snapshot() -> Result<()> { .base_dir() .ok_or_else(|| anyhow!("network has no base_dir"))?, ); - let staging_dir = base_dir.join("partial-staging"); - - info!("forking bulletin DB after {} payloads", phase_1.len()); - fork_collator_db(&network, &base_dir, &staging_dir).await?; - - for payload in phase_2 { - let cid_str = submit_store(&api, &alice, payload).await?; - emitted_cids.push((payload.label, cid_str)); - } info!("waiting for parachain height >= {}", opts.target_height); collator @@ -191,10 +178,10 @@ async fn bulletin_generate_snapshot() -> Result<()> { .await?; // The full snapshot (relay + bulletin-with-all-payloads) is taken via - // the same pause/copy/resume primitive as the partial fork so the on- - // disk RocksDB state is consistent. Calling `network.destroy()` instead - // would trigger zombienet's crash watcher, which `process::exit(1)`s - // before we finish tarring. + // the same pause/copy/resume primitive so the on-disk RocksDB state is + // consistent. Calling `network.destroy()` instead would trigger + // zombienet's crash watcher, which `process::exit(1)`s before we + // finish tarring. let final_staging = base_dir.join("final-staging"); info!("snapshotting full state"); snapshot_full_state(&network, &base_dir, &final_staging).await?; @@ -205,15 +192,10 @@ async fn bulletin_generate_snapshot() -> Result<()> { None, &opts.out_dir.join("relay.tgz"), )?; - let bulletin_full_archive = pack_node_dirs( + let bulletin_archive = pack_node_dirs( &final_staging.join("bulletin").join("data"), Some(&final_staging.join("bulletin").join("relay-data")), - &opts.out_dir.join("bulletin-full.tgz"), - )?; - let bulletin_partial_archive = pack_node_dirs( - &staging_dir.join("data"), - Some(&staging_dir.join("relay-data")), - &opts.out_dir.join("bulletin-partial.tgz"), + &opts.out_dir.join("bulletin.tgz"), )?; info!("writing manifest.json"); @@ -222,8 +204,7 @@ async fn bulletin_generate_snapshot() -> Result<()> { &emitted_cids, &payloads, &relay_archive, - &bulletin_full_archive, - &bulletin_partial_archive, + &bulletin_archive, )?; let manifest_path = opts.out_dir.join("manifest.json"); std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) @@ -233,38 +214,6 @@ async fn bulletin_generate_snapshot() -> Result<()> { Ok(()) } -/// Pauses both collators (SIGSTOP), copies collator-1's `data/` and -/// `relay-data/` into `staging`, then resumes the collators (SIGCONT). -/// The pause window is the only consistent point at which we can fork -/// RocksDB without risking a torn snapshot. -async fn fork_collator_db( - network: &Network, - base_dir: &Path, - staging: &Path, -) -> Result<()> { - let collator1 = network.get_node("collator-1")?; - let collator2 = network.get_node("collator-2")?; - - collator1.pause().await?; - collator2.pause().await?; - - let copy_result: Result<()> = (|| { - let src = base_dir.join("collator-1"); - std::fs::create_dir_all(staging) - .with_context(|| format!("creating {}", staging.display()))?; - copy_dir_all(&src.join("data"), &staging.join("data"))?; - let relay_data = src.join("relay-data"); - if relay_data.is_dir() { - copy_dir_all(&relay_data, &staging.join("relay-data"))?; - } - Ok(()) - })(); - - collator1.resume().await?; - collator2.resume().await?; - copy_result -} - /// Pauses every node, copies the relay (alice) and bulletin (collator-1) /// directories into `staging/{relay,bulletin}/`, and resumes. The pause /// window is shorter than the zombienet crash-watcher's poll interval so @@ -337,6 +286,14 @@ async fn spawn_network(chain_spec: &Path) -> Result> { .to_str() .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? .to_string(); + // Honour `ZOMBIENET_SDK_BASE_DIR` (set by `./g`) so working dirs and + // chain-spec outputs land under the project-local `./tmp/g-run/` + // instead of `/tmp/zombienet-/`. + let base_dir = resolve_base_dir()?; + let base_dir_str = base_dir + .to_str() + .ok_or_else(|| anyhow!("non-utf8 base dir"))? + .to_string(); let config = NetworkConfigBuilder::new() .with_relaychain(|rc| { @@ -349,21 +306,28 @@ async fn spawn_network(chain_spec: &Path) -> Result> { p.with_id(bulletin::PARA_ID) .with_chain_spec_path(chain_spec_str.as_str()) .cumulus_based(true) + // `--ipfs-server` exposes bitswap so the eventual CI test can + // dial against the snapshot. `--relay-chain-rpc-urls` skips + // the embedded relay client and proxies relay queries to + // alice's RPC, sidestepping the relay-side libp2p discovery + // quirks that otherwise leave collators unable to reach the + // validators (and the parachain unable to finalise). + .with_default_args(vec![ + "--ipfs-server".into(), + ("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into(), + ]) .with_collator(|c| { c.with_name("collator-1") .validator(true) .with_command(bulletin::PARA_BINARY) - // `--ipfs-server` exposes bitswap so the eventual - // CI test can dial against the snapshot. - .with_args(vec!["--ipfs-server".into()]) }) .with_collator(|c| { c.with_name("collator-2") .validator(true) .with_command(bulletin::PARA_BINARY) - .with_args(vec!["--ipfs-server".into()]) }) }) + .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) .build() .map_err(|e| anyhow!("network config errors: {e:?}"))?; @@ -496,13 +460,11 @@ fn pack_node_dirs(data: &Path, relay_data: Option<&Path>, archive_path: &Path) - .with_context(|| format!("creating {}", archive_path.display()))?; let gz = flate2::write::GzEncoder::new(f, flate2::Compression::default()); let mut tar = tar::Builder::new(gz); - tar.append_dir_all("data", data) - .with_context(|| format!("tarring {}", data.display()))?; + append_dir_skip_identity(&mut tar, "data", data)?; if let Some(rd) = relay_data { if rd.is_dir() { - tar.append_dir_all("relay-data", rd) - .with_context(|| format!("tarring {}", rd.display()))?; + append_dir_skip_identity(&mut tar, "relay-data", rd)?; } } @@ -513,13 +475,39 @@ fn pack_node_dirs(data: &Path, relay_data: Option<&Path>, archive_path: &Path) - Ok(hex::encode(Sha256::digest(&bytes))) } +/// Recursively adds `src` to the archive under `prefix`, skipping any +/// directory whose name is `keystore` or `network`. We only need db. +fn append_dir_skip_identity( + tar: &mut tar::Builder, + prefix: &str, + src: &Path, +) -> Result<()> { + for entry in std::fs::read_dir(src).with_context(|| format!("reading {}", src.display()))? { + let entry = entry?; + let name = entry.file_name(); + let archive_path = format!("{prefix}/{}", name.to_string_lossy()); + let file_type = entry.file_type()?; + if file_type.is_dir() { + if name == "keystore" || name == "network" { + continue; + } + append_dir_skip_identity(tar, &archive_path, &entry.path())?; + } else { + tar.append_path_with_name(entry.path(), &archive_path) + .with_context(|| { + format!("appending {} as {archive_path}", entry.path().display()) + })?; + } + } + Ok(()) +} + fn build_manifest( opts: &SnapshotOpts, emitted: &[(&'static str, String)], payloads: &[Payload], relay_sha256: &str, - bulletin_full_sha256: &str, - bulletin_partial_sha256: &str, + bulletin_sha256: &str, ) -> Result { let manifest_payloads = emitted .iter() @@ -533,7 +521,6 @@ fn build_manifest( cid: cid.clone(), sha256: p.sha256_hex(), size: p.size(), - on_partial: p.on_partial, }) }) .collect::>>()?; @@ -548,8 +535,7 @@ fn build_manifest( payloads: manifest_payloads, archives: ArchiveChecksums { relay_sha256: relay_sha256.to_string(), - bulletin_full_sha256: bulletin_full_sha256.to_string(), - bulletin_partial_sha256: bulletin_partial_sha256.to_string(), + bulletin_sha256: bulletin_sha256.to_string(), }, }) } From 7c957dba3761a0d807efd49d200bc2ecd15d978b Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Tue, 12 May 2026 17:27:49 +0200 Subject: [PATCH 06/15] fixing bitswap stream/getmany --- light-base/src/bitswap_service.rs | 94 ++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index 1068b28e15..a7a11f0ac0 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -189,8 +189,11 @@ impl BitswapService { /// been decided (Ok block, NotFound, or Timeout). The returned `Vec` echoes input CIDs in input /// order with a [`BlockResult`] per slot. /// - /// Top-level errors (`-32602 InvalidParams`): empty input is allowed (returns empty vec); - /// duplicate CIDs or batch size > [`MAX_CIDS_PER_REQUEST`] are rejected before any wire I/O. + /// Top-level errors: + /// * `-32602 InvalidParams`: empty input is allowed (returns empty vec); duplicate CIDs or + /// batch size > [`MAX_CIDS_PER_REQUEST`] are rejected before any wire I/O. + /// * `-32812 FailRetryBackoff`: the initial Have broadcast had no Bitswap peers to send to, or + /// the network send queue was full. Per spec, retry after a backoff delay (~5s). pub async fn bitswap_get_many( &self, cids: Vec, @@ -202,18 +205,21 @@ impl BitswapService { } let (result_tx, result_rx) = oneshot::channel(); - let (batch_id_tx, batch_id_rx) = oneshot::channel(); + let (ready_tx, ready_rx) = oneshot::channel(); self.messages_tx .send(ToBackground::BitswapBatch { entries, mode: BatchMode::GetMany { result_tx }, - batch_id_tx, + ready_tx, }) .await .unwrap(); - let batch_id = batch_id_rx.await.unwrap(); + // The service signals broadcast outcome here: `Ok(BatchId)` once the Have broadcast has + // landed (or the all-invalid-CID fast path resolves), `Err(...)` if the broadcast failed + // wholesale. Wholesale failures surface as top-level JSON-RPC errors per spec. + let batch_id = ready_rx.await.unwrap()?; // RAII guard: if the caller's future is dropped before result_rx resolves, the guard // drops and sends `CancelBatch` to the service so peers receive a Cancel wantlist. @@ -225,10 +231,15 @@ impl BitswapService { Ok(result_rx.await.unwrap()) } - /// Subscribe to a stream of Bitswap blocks. Returns immediately with a [`BitswapStreamHandle`] + /// Subscribe to a stream of Bitswap blocks. On success, returns a [`BitswapStreamHandle`] /// whose `events_rx` yields one `(cid_string, BlockResult)` event per input CID, in arrival /// order (the order in which each CID resolves), not input order. /// + /// Top-level errors mirror [`BitswapService::bitswap_get_many`]: wholesale Have-broadcast + /// failures (no peers / queue full) reject the subscription with `-32812 FailRetryBackoff` + /// so no events are emitted, per the `bitswap_v1_stream` spec ("whole-call failures cause + /// the subscription to be rejected"). + /// /// Dropping the returned handle (explicit unsubscribe or client disconnect) cancels remaining /// work and emits a Bitswap Cancel wantlist to peers we previously contacted. pub async fn bitswap_stream( @@ -240,18 +251,18 @@ impl BitswapService { // Channel size matches typical batch sizes; events_rx is drained promptly by the JSON-RPC // layer so back-pressure here is unlikely. let (events_tx, events_rx) = async_channel::bounded(MAX_CIDS_PER_REQUEST); - let (batch_id_tx, batch_id_rx) = oneshot::channel(); + let (ready_tx, ready_rx) = oneshot::channel(); self.messages_tx .send(ToBackground::BitswapBatch { entries, mode: BatchMode::Stream { events_tx }, - batch_id_tx, + ready_tx, }) .await .unwrap(); - let batch_id = batch_id_rx.await.unwrap(); + let batch_id = ready_rx.await.unwrap()?; Ok(BitswapStreamHandle { events_rx, @@ -469,14 +480,17 @@ enum ToBackground { cid: Cid, result_tx: oneshot::Sender, BitswapGetError>>, }, - /// Submit a batched request. The service allocates a [`BatchId`], reports it back via - /// `batch_id_tx`, then issues a single Have broadcast covering all valid input CIDs. + /// Submit a batched request. The service allocates a [`BatchId`], issues the Have broadcast, + /// and reports the outcome back via `ready_tx`: `Ok(BatchId)` once the broadcast has landed + /// (so the caller can construct its cancel guard and start receiving results), or + /// `Err(BitswapGetError)` if the broadcast failed wholesale. Wholesale failures surface as + /// top-level JSON-RPC errors (`-32812 FailRetryBackoff` for `NoPeers` / `QueueFull`). BitswapBatch { /// Validated and de-duplicated entries from [`parse_and_dedup`]. Per-slot `Err` carries /// an `InvalidCid` ParseError that gets surfaced as a per-CID error event. entries: Vec<(String, Result)>, mode: BatchMode, - batch_id_tx: oneshot::Sender, + ready_tx: oneshot::Sender>, }, /// Cancel an in-flight batch. Idempotent: if the batch already finished, this is a no-op. CancelBatch { @@ -564,6 +578,11 @@ enum HaveContext { /// Valid (slot_idx, cid) pairs. Invalid-CID slots are pre-resolved before the broadcast /// is queued and don't appear here. cids: Vec<(usize, Cid)>, + /// Channel for signalling broadcast outcome back to the caller of + /// [`BitswapService::bitswap_get_many`] / [`BitswapService::bitswap_stream`]. Sent exactly + /// once when the broadcast resolves: `Ok(batch_id)` on success (peer set non-empty), or + /// `Err(_)` on wholesale failure so the caller surfaces a top-level JSON-RPC error. + ready_tx: oneshot::Sender>, }, } @@ -941,14 +960,14 @@ async fn background_task(mut task: BackgroundTask) { WakeUpReason::Message(ToBackground::BitswapBatch { entries, mode, - batch_id_tx, + ready_tx, }) => { debug_assert!(task.pending_have_broadcast.is_none()); - // Allocate the batch up front and report the BatchId back so the caller's RAII - // cancel guard can address us if the call is dropped before resolution. + // Allocate the batch up front. The BatchId is reported back via `ready_tx` only + // after the Have broadcast resolves (success or wholesale failure), so wholesale + // failures surface as top-level JSON-RPC errors instead of per-slot ones. let batch_id = task.allocate_batch_id(); - let _ = batch_id_tx.send(batch_id); let total = entries.len(); let mut cid_strs: Vec = Vec::with_capacity(total); @@ -1013,8 +1032,10 @@ async fn background_task(mut task: BackgroundTask) { task.batches.insert(batch_id, batch); if valid_cids.is_empty() { - // No wire I/O needed. If everything resolved (all-invalid case), finalize - // immediately. If empty input slipped through somehow, also finalize. + // No wire I/O needed. Signal Ok(batch_id) so the caller can construct its + // cancel guard / stream handle, then finalize immediately (all-invalid case) + // or leave the batch to await ordinary resolution paths. + let _ = ready_tx.send(Ok(batch_id)); if task .batches .get(&batch_id) @@ -1048,6 +1069,7 @@ async fn background_task(mut task: BackgroundTask) { HaveContext::Batch { batch_id, cids: valid_cids, + ready_tx, }, ) })); @@ -1120,7 +1142,11 @@ async fn background_task(mut task: BackgroundTask) { .or_default() .push_back(request_id); } - HaveContext::Batch { batch_id, cids } => { + HaveContext::Batch { + batch_id, + cids, + ready_tx, + } => { let broadcast_to = match result { Ok(peers) => peers, Err(err) => { @@ -1132,17 +1158,11 @@ async fn background_task(mut task: BackgroundTask) { batch_id = batch_id.0, ?err ); - // Whole-broadcast failure: every still-pending slot fails with the - // same error. We resolve them via `deliver_batch_slot` so that - // Stream events fire and GetMany finalizes at zero. - let bsw_err: BitswapGetError = err.into(); - for (slot_idx, _) in cids { - task.deliver_batch_slot( - batch_id, - slot_idx, - Err(bsw_err.clone()), - ); - } + // Whole-broadcast failure: surface as a top-level JSON-RPC error to + // the caller. The batch holds no useful state yet (no per-slot + // Requests registered, `peers_for_cancel` empty), so drop it. + task.batches.remove(&batch_id); + let _ = ready_tx.send(Err(err.into())); continue; } }; @@ -1157,12 +1177,16 @@ async fn background_task(mut task: BackgroundTask) { peers = broadcast_to.len() ); + // Under the new ready-handshake protocol, the batch cannot have been + // cancelled before this point: cancel requires the caller's + // `BatchCancelGuard`, which is only constructed after we send `Ok(batch_id)` + // below. + debug_assert!(task.batches.contains_key(&batch_id)); if let Some(batch) = task.batches.get_mut(&batch_id) { batch.peers_for_cancel = broadcast_to.clone(); } else { - // The batch was cancelled while we were broadcasting. Don't bother - // registering per-CID requests; the cancel handler will have cleaned up - // any state already, and we have nothing to track. + // Drop `ready_tx` without sending — the caller's `ready_rx.await.unwrap()` + // will surface the broken invariant. continue; } @@ -1202,6 +1226,10 @@ async fn background_task(mut task: BackgroundTask) { batch.slots[slot_idx] = Some(request_id); } } + + // Signal broadcast success only after per-slot Requests are registered, so + // events can resolve immediately once the caller installs its handle. + let _ = ready_tx.send(Ok(batch_id)); } }, WakeUpReason::NetworkEvent(BitswapEvent::BitswapConnected { peer_id }) => { From eea65bca724c8f20a3c0c74bf104a49ee0cd959d Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Thu, 14 May 2026 11:46:19 +0200 Subject: [PATCH 07/15] partial snapshot restored --- e2e-tests/src/bulletin.rs | 44 ++++++++++-- e2e-tests/tests/bulletin_batch.rs | 40 ++++++++--- e2e-tests/tests/bulletin_fetch.rs | 42 ++++++++--- e2e-tests/tests/bulletin_generate_snapshot.rs | 72 +++++++++++++++++-- 4 files changed, 164 insertions(+), 34 deletions(-) diff --git a/e2e-tests/src/bulletin.rs b/e2e-tests/src/bulletin.rs index 54c4ecb5b3..a0057210af 100644 --- a/e2e-tests/src/bulletin.rs +++ b/e2e-tests/src/bulletin.rs @@ -33,6 +33,23 @@ pub const PARA_BINARY: &str = "polkadot-parachain"; /// Default snapshot height target. Must exceed 1000 blocks. pub const DEFAULT_SNAPSHOT_HEIGHT: u64 = 1024; +/// Index after which the partial bulletin snapshot is taken. +/// +/// The generator produces two bulletin DB snapshots from one network run: +/// +/// - `bulletin-full.tgz` — every payload in [`payloads`] is injected. +/// - `bulletin-partial.tgz` — only the first `PARTIAL_FORK_INDEX` payloads +/// are injected, then the partial snapshot is captured. +/// +/// The fetch test loads `bulletin-full` on one collator and +/// `bulletin-partial` on another, then fetches a CID that exists only in +/// `bulletin-full` to verify smoldot still finds it via gossip when a +/// peer reports `DontHave`. Bulletin's `transactionStorage` column does +/// not sync via libp2p block-sync, so a partial-snapshot collator +/// permanently lacks the blob bytes for CIDs stored after its snapshot +/// point. +pub const PARTIAL_FORK_INDEX: usize = 2; + /// CIDv1 multicodec for the `raw` codec. const CODEC_RAW: u64 = 0x55; @@ -42,6 +59,11 @@ const CODEC_RAW: u64 = 0x55; pub struct Payload { pub label: &'static str, pub content: &'static [u8], + /// `true` for payloads injected before `PARTIAL_FORK_INDEX`. Their + /// blob bytes are present on both `bulletin-full` and + /// `bulletin-partial` collators; the others are only on + /// `bulletin-full`. + pub on_partial: bool, } impl Payload { @@ -62,24 +84,32 @@ impl Payload { } } -/// Deterministic payloads the generator injects and the CI tests assert on. +/// Deterministic payloads the generator injects and the CI tests assert +/// on. Labels prefixed `all-nodes-*` are on both bulletin snapshots; +/// `one-node-*` payloads are only on `bulletin-full.tgz`. Order matters: +/// items at `[..PARTIAL_FORK_INDEX]` go in before the partial snapshot +/// is captured. pub fn payloads() -> Vec { vec![ Payload { - label: "payload-26b", + label: "all-nodes-with-26b-payload", content: b"smoldot-bitswap-both-small", + on_partial: true, }, Payload { - label: "payload-4kib", + label: "all-nodes-with-4kib-payload", content: rand_4k(), + on_partial: true, }, Payload { - label: "payload-31b", + label: "one-node-with-31b-payload", content: b"smoldot-bitswap-full-only-small", + on_partial: false, }, Payload { - label: "payload-1mib", + label: "one-node-with-1mib-payload", content: rand_1m(), + on_partial: false, }, ] } @@ -157,12 +187,14 @@ pub struct ManifestPayload { pub cid: String, pub sha256: String, pub size: u64, + pub on_partial: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArchiveChecksums { pub relay_sha256: String, - pub bulletin_sha256: String, + pub bulletin_full_sha256: String, + pub bulletin_partial_sha256: String, } /// Manifest emitted alongside the snapshots by the generator. Bumping diff --git a/e2e-tests/tests/bulletin_batch.rs b/e2e-tests/tests/bulletin_batch.rs index 7a87bdcef9..5b7e729b90 100644 --- a/e2e-tests/tests/bulletin_batch.rs +++ b/e2e-tests/tests/bulletin_batch.rs @@ -28,8 +28,10 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; /// artifacts as `bulletin_fetch.rs`. const DB_SNAPSHOT_RELAY: &str = "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN_FULL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; /// Mirrors `light-base/src/bitswap_service.rs::MAX_CIDS_PER_REQUEST`. const MAX_CIDS: u32 = 64; @@ -40,11 +42,13 @@ struct PayloadJson { cid: String, sha256: String, size: u64, + on_partial: bool, } /// Drives `bitswap_v1_getMany` and `bitswap_v1_stream` against a real bulletin /// chain. Both methods are exercised in the same JS run on the same chain -/// handle: happy path, dedup rejection, too-many rejection, per-CID errors. +/// handle: happy path, dedup rejection, too-many rejection, per-CID errors, +/// and mixed-availability (some CIDs only on the full-snapshot collator). #[tokio::test(flavor = "multi_thread")] async fn bulletin_batch() -> Result<()> { env_logger::try_init().ok(); @@ -53,10 +57,23 @@ async fn bulletin_batch() -> Result<()> { let base_dir = resolve_base_dir()?; let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); - let bulletin = get_snapshot_url(DB_SNAPSHOT_BULLETIN, "DB_SNAPSHOT_BULLETIN_OVERRIDE"); + let bulletin_full = get_snapshot_url( + DB_SNAPSHOT_BULLETIN_FULL, + "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", + ); + let bulletin_partial = get_snapshot_url( + DB_SNAPSHOT_BULLETIN_PARTIAL, + "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", + ); - let network = - spawn_with_snapshots(&base_dir, &chain_spec, &relay, &bulletin).await?; + let network = spawn_with_snapshots( + &base_dir, + &chain_spec, + &relay, + &bulletin_full, + &bulletin_partial, + ) + .await?; let (relay_spec, bulletin_spec) = chain_spec_paths(&network)?; @@ -71,6 +88,7 @@ async fn bulletin_batch() -> Result<()> { cid: p.predicted_cid(), sha256: p.sha256_hex(), size: p.size(), + on_partial: p.on_partial, }) .collect::>(), )?; @@ -142,7 +160,8 @@ async fn spawn_with_snapshots( base_dir: &Path, chain_spec: &Path, relay_snap: &str, - bulletin_snap: &str, + bulletin_full_snap: &str, + bulletin_partial_snap: &str, ) -> Result> { let chain_spec_str = chain_spec .to_str() @@ -153,7 +172,8 @@ async fn spawn_with_snapshots( .ok_or_else(|| anyhow!("non-utf8 base dir"))? .to_string(); let relay = relay_snap.to_string(); - let bulletin = bulletin_snap.to_string(); + let bulletin_full = bulletin_full_snap.to_string(); + let bulletin_partial = bulletin_partial_snap.to_string(); let cfg = NetworkConfigBuilder::new() .with_relaychain(|rc| { @@ -192,14 +212,14 @@ async fn spawn_with_snapshots( .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin.as_str()) + .with_db_snapshot(bulletin_full.as_str()) }) .with_collator(|c| { c.with_name("collator-2") .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin.as_str()) + .with_db_snapshot(bulletin_partial.as_str()) }) }) .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) diff --git a/e2e-tests/tests/bulletin_fetch.rs b/e2e-tests/tests/bulletin_fetch.rs index d885d9dc8d..2c0e9c827e 100644 --- a/e2e-tests/tests/bulletin_fetch.rs +++ b/e2e-tests/tests/bulletin_fetch.rs @@ -27,8 +27,10 @@ use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; /// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. const DB_SNAPSHOT_RELAY: &str = "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN_FULL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; +const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; #[derive(Serialize)] struct PayloadJson { @@ -36,10 +38,12 @@ struct PayloadJson { cid: String, sha256: String, size: u64, + on_partial: bool, } -/// Smoldot fetches every CID in `bulletin::payloads()` and asserts -/// NotFound for an unrelated CID. Both collators share the same snapshot. +/// Smoldot fetches every CID in `bulletin::payloads()`, asserts NotFound +/// for an unrelated CID, and exercises mixed-availability peer selection +/// (some CIDs only on the full-snapshot collator). #[tokio::test(flavor = "multi_thread")] async fn bulletin_fetch() -> Result<()> { env_logger::try_init().ok(); @@ -48,10 +52,23 @@ async fn bulletin_fetch() -> Result<()> { let base_dir = resolve_base_dir()?; let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); - let bulletin = get_snapshot_url(DB_SNAPSHOT_BULLETIN, "DB_SNAPSHOT_BULLETIN_OVERRIDE"); + let bulletin_full = get_snapshot_url( + DB_SNAPSHOT_BULLETIN_FULL, + "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", + ); + let bulletin_partial = get_snapshot_url( + DB_SNAPSHOT_BULLETIN_PARTIAL, + "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", + ); - let network = - spawn_with_snapshots(&base_dir, &chain_spec, &relay, &bulletin).await?; + let network = spawn_with_snapshots( + &base_dir, + &chain_spec, + &relay, + &bulletin_full, + &bulletin_partial, + ) + .await?; let (relay_spec, bulletin_spec) = chain_spec_paths(&network)?; @@ -66,6 +83,7 @@ async fn bulletin_fetch() -> Result<()> { cid: p.predicted_cid(), sha256: p.sha256_hex(), size: p.size(), + on_partial: p.on_partial, }) .collect::>(), )?; @@ -141,7 +159,8 @@ async fn spawn_with_snapshots( base_dir: &Path, chain_spec: &Path, relay_snap: &str, - bulletin_snap: &str, + bulletin_full_snap: &str, + bulletin_partial_snap: &str, ) -> Result> { let chain_spec_str = chain_spec .to_str() @@ -152,7 +171,8 @@ async fn spawn_with_snapshots( .ok_or_else(|| anyhow!("non-utf8 base dir"))? .to_string(); let relay = relay_snap.to_string(); - let bulletin = bulletin_snap.to_string(); + let bulletin_full = bulletin_full_snap.to_string(); + let bulletin_partial = bulletin_partial_snap.to_string(); let cfg = NetworkConfigBuilder::new() .with_relaychain(|rc| { @@ -185,14 +205,14 @@ async fn spawn_with_snapshots( .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin.as_str()) + .with_db_snapshot(bulletin_full.as_str()) }) .with_collator(|c| { c.with_name("collator-2") .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin.as_str()) + .with_db_snapshot(bulletin_partial.as_str()) }) }) .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) diff --git a/e2e-tests/tests/bulletin_generate_snapshot.rs b/e2e-tests/tests/bulletin_generate_snapshot.rs index e16ef15a77..4e58a825eb 100644 --- a/e2e-tests/tests/bulletin_generate_snapshot.rs +++ b/e2e-tests/tests/bulletin_generate_snapshot.rs @@ -154,10 +154,15 @@ async fn bulletin_generate_snapshot() -> Result<()> { authorize_account(&api, &alice, &alice).await?; let payloads = bulletin::payloads(); - info!("injecting {} payloads", payloads.len()); + let (phase_1, phase_2) = payloads.split_at(bulletin::PARTIAL_FORK_INDEX); + info!( + "injecting {} pre-fork + {} post-fork payloads", + phase_1.len(), + phase_2.len() + ); let mut emitted_cids = Vec::new(); - for payload in &payloads { + for payload in phase_1 { let cid_str = submit_store(&api, &alice, payload).await?; emitted_cids.push((payload.label, cid_str)); } @@ -167,6 +172,15 @@ async fn bulletin_generate_snapshot() -> Result<()> { .base_dir() .ok_or_else(|| anyhow!("network has no base_dir"))?, ); + let staging_dir = base_dir.join("partial-staging"); + + info!("forking bulletin DB after {} payloads", phase_1.len()); + fork_collator_db(&network, &base_dir, &staging_dir).await?; + + for payload in phase_2 { + let cid_str = submit_store(&api, &alice, payload).await?; + emitted_cids.push((payload.label, cid_str)); + } info!("waiting for parachain height >= {}", opts.target_height); collator @@ -192,10 +206,18 @@ async fn bulletin_generate_snapshot() -> Result<()> { None, &opts.out_dir.join("relay.tgz"), )?; - let bulletin_archive = pack_node_dirs( + let bulletin_full_archive = pack_node_dirs( &final_staging.join("bulletin").join("data"), Some(&final_staging.join("bulletin").join("relay-data")), - &opts.out_dir.join("bulletin.tgz"), + &opts.out_dir.join("bulletin-full.tgz"), + )?; + // No `relay-data/` on the partial — collators use + // `--relay-chain-rpc-urls`, so the embedded relay client doesn't load + // anything from disk anyway. + let bulletin_partial_archive = pack_node_dirs( + &staging_dir.join("data"), + None, + &opts.out_dir.join("bulletin-partial.tgz"), )?; info!("writing manifest.json"); @@ -204,7 +226,8 @@ async fn bulletin_generate_snapshot() -> Result<()> { &emitted_cids, &payloads, &relay_archive, - &bulletin_archive, + &bulletin_full_archive, + &bulletin_partial_archive, )?; let manifest_path = opts.out_dir.join("manifest.json"); std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) @@ -214,6 +237,38 @@ async fn bulletin_generate_snapshot() -> Result<()> { Ok(()) } +/// Pauses both collators (SIGSTOP), copies collator-1's `data/` into +/// `staging`, then resumes the collators (SIGCONT). The pause window is +/// the only consistent point at which we can fork RocksDB without +/// risking a torn snapshot. +/// +/// `relay-data/` is intentionally NOT copied: collators run with +/// `--relay-chain-rpc-urls`, so the embedded relay client doesn't load +/// anything from disk. +async fn fork_collator_db( + network: &Network, + base_dir: &Path, + staging: &Path, +) -> Result<()> { + let collator1 = network.get_node("collator-1")?; + let collator2 = network.get_node("collator-2")?; + + collator1.pause().await?; + collator2.pause().await?; + + let copy_result: Result<()> = (|| { + let src = base_dir.join("collator-1"); + std::fs::create_dir_all(staging) + .with_context(|| format!("creating {}", staging.display()))?; + copy_dir_all(&src.join("data"), &staging.join("data"))?; + Ok(()) + })(); + + collator1.resume().await?; + collator2.resume().await?; + copy_result +} + /// Pauses every node, copies the relay (alice) and bulletin (collator-1) /// directories into `staging/{relay,bulletin}/`, and resumes. The pause /// window is shorter than the zombienet crash-watcher's poll interval so @@ -507,7 +562,8 @@ fn build_manifest( emitted: &[(&'static str, String)], payloads: &[Payload], relay_sha256: &str, - bulletin_sha256: &str, + bulletin_full_sha256: &str, + bulletin_partial_sha256: &str, ) -> Result { let manifest_payloads = emitted .iter() @@ -521,6 +577,7 @@ fn build_manifest( cid: cid.clone(), sha256: p.sha256_hex(), size: p.size(), + on_partial: p.on_partial, }) }) .collect::>>()?; @@ -535,7 +592,8 @@ fn build_manifest( payloads: manifest_payloads, archives: ArchiveChecksums { relay_sha256: relay_sha256.to_string(), - bulletin_sha256: bulletin_sha256.to_string(), + bulletin_full_sha256: bulletin_full_sha256.to_string(), + bulletin_partial_sha256: bulletin_partial_sha256.to_string(), }, }) } From 3fe00d74b7c64c63639b0a3559343eebac46ef9a Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Fri, 15 May 2026 12:37:24 +0200 Subject: [PATCH 08/15] rework: unstable + getMany removed --- e2e-tests/README.md | 5 +- e2e-tests/js/bulletin_batch.js | 338 +++++++---------- e2e-tests/js/bulletin_fetch.js | 39 +- e2e-tests/tests/bulletin_batch.rs | 9 +- lib/src/json_rpc/methods.rs | 121 +++++-- lib/src/json_rpc/service/client_main_task.rs | 15 +- light-base/src/bitswap_service.rs | 342 ++++++++---------- light-base/src/json_rpc_service/background.rs | 127 ++----- light-base/src/lib.rs | 4 +- 9 files changed, 453 insertions(+), 547 deletions(-) diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 6f3c21e0eb..3aa57f1ed6 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -41,8 +41,9 @@ What happens inside: ## Bulletin / bitswap snapshots -The `bulletin_fetch` test drives smoldot's `bitswap_v1_get` JSON-RPC -against a polkadot-bulletin-chain network with pre-built DB snapshots. +The `bulletin_fetch` test drives smoldot's `bitswap_unstable_get` JSON-RPC +(plus an alias-coverage call to the legacy `bitswap_v1_get` name) against a +polkadot-bulletin-chain network with pre-built DB snapshots. The URLs CI fetches from are hardcoded in [`tests/bulletin_fetch.rs`](tests/bulletin_fetch.rs) and point at the `zombienet-db-snaps` GCS bucket under `smoldot/bulletin_fetch/`. To diff --git a/e2e-tests/js/bulletin_batch.js b/e2e-tests/js/bulletin_batch.js index b18ef34a32..4e9714e99e 100644 --- a/e2e-tests/js/bulletin_batch.js +++ b/e2e-tests/js/bulletin_batch.js @@ -25,6 +25,9 @@ import { } from "./helpers.js"; const ERR_INVALID_PARAMS = -32602; +const ERR_TOO_MANY_CIDS = -32801; +const ERR_EMPTY_CIDS = -32802; +const ERR_DUPLICATE_CIDS = -32803; const ERR_FAIL = -32810; const ERR_FAIL_RETRY = -32811; const ERR_FAIL_BACKOFF = -32812; @@ -51,19 +54,14 @@ try { potentialRelayChains: [relay], }); - // ===== getMany section ===== - await runGetManyHappy(bulletin); - await runGetManyDedup(bulletin); - await runGetManyTooMany(bulletin); - await runGetManyPerCidErrors(bulletin); - await runGetManyMixed(bulletin); - - // ===== stream section ===== + // ===== bitswap_unstable_stream ===== await runStreamHappy(bulletin); await runStreamDedup(bulletin); await runStreamTooMany(bulletin); + await runStreamEmpty(bulletin); await runStreamPerCidErrors(bulletin); await runStreamMixed(bulletin); + await runStreamUnstreamSuppressesStreamDone(bulletin); } catch (err) { console.error(`bulletin_batch error: ${err?.stack || err}`); exitCode = 1; @@ -78,104 +76,6 @@ try { // in practice (graceful close handshakes for the 6 peer connections). process.exit(exitCode || process.exitCode || 0); -// ---------- getMany tests ---------- - -async function runGetManyHappy(chain) { - const cids = payloads.map((p) => p.cid); - try { - const result = await getManyWithRetry(chain, cids); - const checkErr = await verifyGetManyResult(result, payloads); - report("gm-happy", checkErr === null, checkErr ?? `${cids.length} entries`); - } catch (err) { - report("gm-happy", false, err.message); - } -} - -async function runGetManyDedup(chain) { - const cids = [payloads[0].cid, payloads[0].cid]; - try { - await sendRpcAndWait(chain, "bitswap_v1_getMany", [cids]); - report("gm-dedup", false, "expected DuplicateCids rejection, got success"); - } catch (err) { - const code = errorCode(err); - const variant = errorVariant(err); - const ok = code === ERR_INVALID_PARAMS && variant === "DuplicateCids"; - report( - "gm-dedup", - ok, - ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/DuplicateCids, got ${code}/${variant}`, - ); - } -} - -async function runGetManyTooMany(chain) { - // parse_and_dedup() checks length BEFORE deduping, so identical CIDs work as - // long as the array length exceeds MAX_CIDS_PER_REQUEST. - const cids = Array(maxCids + 1).fill(payloads[0].cid); - try { - await sendRpcAndWait(chain, "bitswap_v1_getMany", [cids]); - report("gm-too-many", false, "expected TooManyCids rejection, got success"); - } catch (err) { - const code = errorCode(err); - const variant = errorVariant(err); - const ok = code === ERR_INVALID_PARAMS && variant === "TooManyCids"; - report( - "gm-too-many", - ok, - ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/TooManyCids, got ${code}/${variant}`, - ); - } -} - -async function runGetManyPerCidErrors(chain) { - const valid = payloads[0]; - const cids = [valid.cid, "not-a-cid", missingCid]; - try { - const result = await getManyWithRetry(chain, cids); - if (!Array.isArray(result) || result.length !== 3) { - report("gm-per-cid-errors", false, `expected 3-entry array, got ${JSON.stringify(result)}`); - return; - } - const [tup0, tup1, tup2] = result; - if (tup0[0] !== valid.cid || !isHexString(tup0[1])) { - report("gm-per-cid-errors", false, `slot 0 expected Ok hex, got ${JSON.stringify(tup0)}`); - return; - } - const okBytes = await verifyHexAgainstPayload(tup0[1], valid); - if (okBytes !== null) { - report("gm-per-cid-errors", false, `slot 0 bytes mismatch: ${okBytes}`); - return; - } - if (tup1[0] !== "not-a-cid" || !isErrObject(tup1[1]) || tup1[1].code !== ERR_INVALID_PARAMS) { - report("gm-per-cid-errors", false, `slot 1 expected ${ERR_INVALID_PARAMS}, got ${JSON.stringify(tup1)}`); - return; - } - if (tup2[0] !== missingCid || !isErrObject(tup2[1]) || tup2[1].code !== ERR_FAIL) { - report("gm-per-cid-errors", false, `slot 2 expected ${ERR_FAIL}, got ${JSON.stringify(tup2)}`); - return; - } - report("gm-per-cid-errors", true, `Ok, ${tup1[1].code}, ${tup2[1].code}`); - } catch (err) { - report("gm-per-cid-errors", false, err.message); - } -} - -async function runGetManyMixed(chain) { - const fullOnly = payloads.filter((p) => !p.on_partial); - if (fullOnly.length === 0) { - report("gm-mixed", true, "skipped (no full-only payloads)"); - return; - } - const cids = fullOnly.map((p) => p.cid); - try { - const result = await getManyWithRetry(chain, cids); - const checkErr = await verifyGetManyResult(result, fullOnly); - report("gm-mixed", checkErr === null, checkErr ?? `${cids.length} entries`); - } catch (err) { - report("gm-mixed", false, err.message); - } -} - // ---------- stream tests ---------- async function runStreamHappy(chain) { @@ -192,16 +92,15 @@ async function runStreamHappy(chain) { async function runStreamDedup(chain) { const cids = [payloads[0].cid, payloads[0].cid]; try { - await sendRpcAndWait(chain, "bitswap_v1_stream", [cids]); + await sendRpcAndWait(chain, "bitswap_unstable_stream", [cids]); report("st-dedup", false, "expected DuplicateCids rejection at subscription, got success"); } catch (err) { const code = errorCode(err); - const variant = errorVariant(err); - const ok = code === ERR_INVALID_PARAMS && variant === "DuplicateCids"; + const ok = code === ERR_DUPLICATE_CIDS; report( "st-dedup", ok, - ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/DuplicateCids, got ${code}/${variant}`, + ok ? `code ${code}` : `expected ${ERR_DUPLICATE_CIDS}, got ${code}`, ); } } @@ -209,16 +108,30 @@ async function runStreamDedup(chain) { async function runStreamTooMany(chain) { const cids = Array(maxCids + 1).fill(payloads[0].cid); try { - await sendRpcAndWait(chain, "bitswap_v1_stream", [cids]); + await sendRpcAndWait(chain, "bitswap_unstable_stream", [cids]); report("st-too-many", false, "expected TooManyCids rejection at subscription, got success"); } catch (err) { const code = errorCode(err); - const variant = errorVariant(err); - const ok = code === ERR_INVALID_PARAMS && variant === "TooManyCids"; + const ok = code === ERR_TOO_MANY_CIDS; report( "st-too-many", ok, - ok ? `code ${code} variant ${variant}` : `expected ${ERR_INVALID_PARAMS}/TooManyCids, got ${code}/${variant}`, + ok ? `code ${code}` : `expected ${ERR_TOO_MANY_CIDS}, got ${code}`, + ); + } +} + +async function runStreamEmpty(chain) { + try { + await sendRpcAndWait(chain, "bitswap_unstable_stream", [[]]); + report("st-empty", false, "expected EmptyCids rejection, got success"); + } catch (err) { + const code = errorCode(err); + const ok = code === ERR_EMPTY_CIDS; + report( + "st-empty", + ok, + ok ? `code ${code}` : `expected ${ERR_EMPTY_CIDS}, got ${code}`, ); } } @@ -231,20 +144,20 @@ async function runStreamPerCidErrors(chain) { const okEntry = events.get(valid.cid); const invalidEntry = events.get("not-a-cid"); const missingEntry = events.get(missingCid); - if (!okEntry || !isHexString(okEntry)) { - report("st-per-cid-errors", false, `valid slot expected Ok hex, got ${JSON.stringify(okEntry)}`); + if (!okEntry || !isHexString(okEntry.value)) { + report("st-per-cid-errors", false, `valid slot expected streamItem hex, got ${JSON.stringify(okEntry)}`); return; } - const okBytes = await verifyHexAgainstPayload(okEntry, valid); + const okBytes = await verifyHexAgainstPayload(okEntry.value, valid); if (okBytes !== null) { report("st-per-cid-errors", false, `valid slot bytes mismatch: ${okBytes}`); return; } - if (!isErrObject(invalidEntry) || invalidEntry.code !== ERR_INVALID_PARAMS) { + if (!isErrEntry(invalidEntry) || invalidEntry.code !== ERR_INVALID_PARAMS) { report("st-per-cid-errors", false, `invalid-cid expected ${ERR_INVALID_PARAMS}, got ${JSON.stringify(invalidEntry)}`); return; } - if (!isErrObject(missingEntry) || missingEntry.code !== ERR_FAIL) { + if (!isErrEntry(missingEntry) || missingEntry.code !== ERR_FAIL) { report("st-per-cid-errors", false, `missing-cid expected ${ERR_FAIL}, got ${JSON.stringify(missingEntry)}`); return; } @@ -270,29 +183,73 @@ async function runStreamMixed(chain) { } } +/// Asserts that calling `bitswap_unstable_unstream` mid-stream prevents the +/// `streamDone` event from being emitted (per spec: cancellation is silent). +async function runStreamUnstreamSuppressesStreamDone(chain) { + const cids = payloads.map((p) => p.cid); + try { + const subscription = await subscribeWithRetry(chain, cids, 60_000); + + // Wait for at least one event so we know the subscription is live. + const firstEventDeadline = Date.now() + 60_000; + const firstEvent = await readJsonRpcUntil( + chain, + (msg) => streamEventResult(msg, subscription), + firstEventDeadline, + ); + if (firstEvent === undefined) { + report("st-unstream-silence", false, "timed out waiting for first event"); + return; + } + if (firstEvent.event === "streamDone") { + // Single-CID streams may complete before unstream fires; the assertion is moot. + report("st-unstream-silence", true, "stream completed before unstream had a chance"); + return; + } + + // Cancel. + try { + await sendRpcAndWait(chain, "bitswap_unstable_unstream", [subscription], 5_000); + } catch (err) { + report("st-unstream-silence", false, `unstream failed: ${err.message}`); + return; + } + + // Poll for a small window and assert no streamDone arrives. + const silentUntil = Date.now() + 1_000; + const ghost = await readJsonRpcUntil( + chain, + (msg) => { + const res = streamEventResult(msg, subscription); + return res && res.event === "streamDone" ? res : undefined; + }, + silentUntil, + ); + if (ghost === undefined) { + report("st-unstream-silence", true, "no streamDone after unstream"); + } else { + report("st-unstream-silence", false, `unexpected streamDone after unstream: ${JSON.stringify(ghost)}`); + } + } catch (err) { + report("st-unstream-silence", false, err.message); + } +} + // ---------- subscription helper ---------- -/// Subscribes via bitswap_v1_stream, collects exactly `cids.length` events, -/// then politely unsubscribes. Returns a Map. The order in -/// which events arrive is not asserted (per spec, arrival order, not input -/// order). +/// Subscribes via bitswap_unstable_stream, collects events until `streamDone`, +/// then asserts that exactly `cids.length` per-CID events arrived before the +/// done marker. Returns a Map. Arrival order +/// is not asserted (per spec). async function streamCollect(chain, cids, totalBudgetMs = 180_000) { const subscription = await subscribeWithRetry(chain, cids, totalBudgetMs); const collected = new Map(); const deadline = Date.now() + totalBudgetMs; - while (collected.size < cids.length) { + let sawStreamDone = false; + while (!sawStreamDone) { const got = await readJsonRpcUntil( chain, - (msg) => { - if ( - msg.method === "bitswap_v1_streamEvent" && - msg.params && - msg.params.subscription === subscription - ) { - return msg.params.result; - } - return undefined; - }, + (msg) => streamEventResult(msg, subscription), deadline, ); if (got === undefined) { @@ -300,54 +257,55 @@ async function streamCollect(chain, cids, totalBudgetMs = 180_000) { `stream timed out: collected ${collected.size}/${cids.length} events`, ); } - const [cid, blockResult] = got; - collected.set(cid, blockResult); + switch (got.event) { + case "streamItem": + collected.set(got.cid, { value: got.value }); + break; + case "streamItemError": + collected.set(got.cid, { code: got.code, message: got.message }); + break; + case "streamDone": + sawStreamDone = true; + break; + default: + throw new Error(`unknown stream event: ${JSON.stringify(got)}`); + } + } + if (collected.size !== cids.length) { + throw new Error( + `streamDone arrived after ${collected.size}/${cids.length} per-CID events`, + ); } - // Polite cancel; we don't assert on the response. - try { - await sendRpcAndWait(chain, "bitswap_v1_unstream", [subscription], 10_000); - } catch (_) {} return collected; } -async function subscribeWithRetry(chain, cids, totalBudgetMs) { - const deadline = Date.now() + totalBudgetMs; - let attempt = 0; - while (true) { - attempt += 1; - const remaining = deadline - Date.now(); - if (remaining <= 0) { - throw new Error(`bitswap_v1_stream timed out after ${totalBudgetMs}ms`); - } - try { - return await sendRpcAndWait(chain, "bitswap_v1_stream", [cids], Math.min(60_000, remaining)); - } catch (err) { - const code = errorCode(err); - if (code === ERR_FAIL_BACKOFF || code === ERR_FAIL_RETRY) { - const backoff = Math.min(5_000, 500 * 2 ** Math.min(attempt - 1, 3)); - await new Promise((r) => setTimeout(r, backoff)); - continue; - } - throw err; - } +function streamEventResult(msg, subscription) { + if ( + msg.method === "bitswap_unstable_streamEvent" && + msg.params && + msg.params.subscription === subscription + ) { + return msg.params.result; } + return undefined; } -// ---------- getMany helper ---------- - -/// Same retry strategy as bulletin_fetch.js's `bitswapGetWithRetry`: retry on -/// transient FailRetry / FailRetryBackoff while smoldot's peer set warms up. -async function getManyWithRetry(chain, cids, totalBudgetMs = 180_000) { +async function subscribeWithRetry(chain, cids, totalBudgetMs) { const deadline = Date.now() + totalBudgetMs; let attempt = 0; while (true) { attempt += 1; const remaining = deadline - Date.now(); if (remaining <= 0) { - throw new Error(`bitswap_v1_getMany timed out after ${totalBudgetMs}ms`); + throw new Error(`bitswap_unstable_stream timed out after ${totalBudgetMs}ms`); } try { - return await sendRpcAndWait(chain, "bitswap_v1_getMany", [cids], Math.min(60_000, remaining)); + return await sendRpcAndWait( + chain, + "bitswap_unstable_stream", + [cids], + Math.min(60_000, remaining), + ); } catch (err) { const code = errorCode(err); if (code === ERR_FAIL_BACKOFF || code === ERR_FAIL_RETRY) { @@ -362,46 +320,21 @@ async function getManyWithRetry(chain, cids, totalBudgetMs = 180_000) { // ---------- verification helpers ---------- -/// Asserts a `bitswap_v1_getMany` response is an array of `[cid, hex]` tuples -/// in input order, and each tuple's hex content matches the corresponding -/// payload. Returns null on success, or a string explaining the first -/// mismatch. -async function verifyGetManyResult(result, expectedPayloads) { - if (!Array.isArray(result) || result.length !== expectedPayloads.length) { - return `expected ${expectedPayloads.length}-entry array, got ${JSON.stringify(result)}`; - } - for (let i = 0; i < expectedPayloads.length; i++) { - const tup = result[i]; - const p = expectedPayloads[i]; - if (!Array.isArray(tup) || tup.length !== 2 || tup[0] !== p.cid) { - return `slot ${i}: expected cid ${p.cid}, got ${JSON.stringify(tup)}`; - } - if (!isHexString(tup[1])) { - return `slot ${i}: expected Ok hex, got ${JSON.stringify(tup[1])}`; - } - const mismatch = await verifyHexAgainstPayload(tup[1], p); - if (mismatch !== null) { - return `slot ${i} (${p.label}): ${mismatch}`; - } - } - return null; -} - -/// Asserts the collected `bitswap_v1_streamEvent` map contains every expected -/// payload by CID, with bytes matching size and sha256. Order-agnostic. +/// Asserts the collected stream map contains every expected payload by CID, +/// with bytes matching size and sha256. Order-agnostic. async function verifyStreamMap(events, expectedPayloads) { if (events.size !== expectedPayloads.length) { return `expected ${expectedPayloads.length} events, got ${events.size}`; } for (const p of expectedPayloads) { - const blockResult = events.get(p.cid); - if (blockResult === undefined) { + const entry = events.get(p.cid); + if (entry === undefined) { return `missing event for cid ${p.cid} (${p.label})`; } - if (!isHexString(blockResult)) { - return `${p.label}: expected Ok hex, got ${JSON.stringify(blockResult)}`; + if (!entry.value || !isHexString(entry.value)) { + return `${p.label}: expected streamItem hex, got ${JSON.stringify(entry)}`; } - const mismatch = await verifyHexAgainstPayload(blockResult, p); + const mismatch = await verifyHexAgainstPayload(entry.value, p); if (mismatch !== null) { return `${p.label}: ${mismatch}`; } @@ -425,7 +358,7 @@ function isHexString(v) { return typeof v === "string" && v.startsWith("0x"); } -function isErrObject(v) { +function isErrEntry(v) { return typeof v === "object" && v !== null && typeof v.code === "number"; } @@ -434,11 +367,6 @@ function errorCode(err) { return m ? Number.parseInt(m[1], 10) : null; } -function errorVariant(err) { - const m = /"variant":"([^"]+)"/.exec(err.message ?? ""); - return m ? m[1] : null; -} - function hexToBytes(hex) { const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; if (stripped.length % 2 !== 0) { diff --git a/e2e-tests/js/bulletin_fetch.js b/e2e-tests/js/bulletin_fetch.js index 4fa4749fdf..b334c8a64e 100644 --- a/e2e-tests/js/bulletin_fetch.js +++ b/e2e-tests/js/bulletin_fetch.js @@ -70,6 +70,30 @@ try { } } + // Alias coverage: the legacy `bitswap_v1_get` name must still resolve via + // the macro alias to the same handler. + if (payloads.length > 0) { + const payload = payloads[0]; + try { + const hex = await bitswapGetWithRetry( + bulletin, + payload.cid, + 180_000, + "bitswap_v1_get", + ); + const bytes = hexToBytes(hex); + const sha = await sha256Hex(bytes); + const ok = bytes.length === payload.size && sha === payload.sha256; + report( + "alias-v1-get", + ok, + ok ? `${bytes.length} bytes via bitswap_v1_get alias` : `size/sha256 mismatch via alias`, + ); + } catch (err) { + report("alias-v1-get", false, err.message); + } + } + try { // Given const cid = missingCid; @@ -151,18 +175,25 @@ if (exitCode || process.exitCode) { } // Retries the transient BlockRequestFailed/Timeout and NoPeers/QueueFull -// errors smoldot returns while its peer set is warming up. -async function bitswapGetWithRetry(chain, cid, totalBudgetMs = 180_000) { +// errors smoldot returns while its peer set is warming up. `method` lets us +// exercise both the canonical `bitswap_unstable_get` and the legacy +// `bitswap_v1_get` alias. +async function bitswapGetWithRetry( + chain, + cid, + totalBudgetMs = 180_000, + method = "bitswap_unstable_get", +) { const deadline = Date.now() + totalBudgetMs; let attempt = 0; while (true) { attempt += 1; const remaining = deadline - Date.now(); if (remaining <= 0) { - throw new Error(`bitswap_v1_get timed out after ${totalBudgetMs}ms`); + throw new Error(`${method} timed out after ${totalBudgetMs}ms`); } try { - return await sendRpcAndWait(chain, "bitswap_v1_get", [cid], Math.min(60_000, remaining)); + return await sendRpcAndWait(chain, method, [cid], Math.min(60_000, remaining)); } catch (err) { const code = errorCode(err); if (code === ERR_FAIL_BACKOFF || code === ERR_FAIL_RETRY) { diff --git a/e2e-tests/tests/bulletin_batch.rs b/e2e-tests/tests/bulletin_batch.rs index 5b7e729b90..b4cc9d13d6 100644 --- a/e2e-tests/tests/bulletin_batch.rs +++ b/e2e-tests/tests/bulletin_batch.rs @@ -45,10 +45,11 @@ struct PayloadJson { on_partial: bool, } -/// Drives `bitswap_v1_getMany` and `bitswap_v1_stream` against a real bulletin -/// chain. Both methods are exercised in the same JS run on the same chain -/// handle: happy path, dedup rejection, too-many rejection, per-CID errors, -/// and mixed-availability (some CIDs only on the full-snapshot collator). +/// Drives `bitswap_unstable_stream` against a real bulletin chain. The JS run +/// exercises: happy path, dedup rejection (-32803), too-many rejection +/// (-32801), empty-input rejection (-32802), per-CID errors, mixed-availability +/// (some CIDs only on the full-snapshot collator), and the spec requirement +/// that `bitswap_unstable_unstream` mid-stream silently suppresses `streamDone`. #[tokio::test(flavor = "multi_thread")] async fn bulletin_batch() -> Result<()> { env_logger::try_init().ok(); diff --git a/lib/src/json_rpc/methods.rs b/lib/src/json_rpc/methods.rs index 8a4df4f3f5..c7a4b5bd40 100644 --- a/lib/src/json_rpc/methods.rs +++ b/lib/src/json_rpc/methods.rs @@ -525,17 +525,16 @@ define_methods! { transactionWatch_v1_unwatch(subscription: Cow<'a, str>) -> (), /// Request a data chunk by its CID from one of the connected peers that have it. - bitswap_v1_get(cid: String) -> HexString, - /// Request multiple data chunks by CID in a single call. - /// Returns one `[cid, BlockResult]` tuple per input CID, in input order. - bitswap_v1_getMany(cids: Vec) -> Vec, + /// `bitswap_v1_get` is accepted as a legacy alias. + bitswap_unstable_get(cid: String) -> HexString [bitswap_v1_get], /// Subscribe to a stream of data chunks. Each input CID produces exactly one - /// `bitswap_v1_streamEvent` notification, emitted as soon as that CID resolves - /// (in arrival order, not input order). - bitswap_v1_stream(cids: Vec) -> Cow<'a, str>, - /// Cancel a `bitswap_v1_stream` subscription. No-op if the subscription does - /// not exist or has already completed. - bitswap_v1_unstream(subscription: Cow<'a, str>) -> (), + /// `bitswap_unstable_streamEvent` notification, emitted as soon as that CID + /// resolves (in arrival order, not input order). After all per-CID events + /// have been emitted, a single `streamDone` event marks end-of-stream. + bitswap_unstable_stream(cids: Vec) -> Cow<'a, str>, + /// Cancel a `bitswap_unstable_stream` subscription. No-op if the subscription + /// does not exist or has already completed. + bitswap_unstable_unstream(subscription: Cow<'a, str>) -> (), // These functions are a custom addition in smoldot. As of the writing of this comment, there // is no plan to standardize them. See and @@ -559,7 +558,7 @@ define_methods! { // The functions below are experimental and are defined in the document https://github.com/paritytech/json-rpc-interface-spec/ chainHead_v1_followEvent(subscription: Cow<'a, str>, result: FollowEvent<'a>) -> (), transactionWatch_v1_watchEvent(subscription: Cow<'a, str>, result: TransactionWatchEvent<'a>) -> (), - bitswap_v1_streamEvent(subscription: Cow<'a, str>, result: BitswapBlockResultEntry) -> (), + bitswap_unstable_streamEvent(subscription: Cow<'a, str>, result: BitswapStreamEvent<'a>) -> (), // This function is a custom addition in smoldot. As of the writing of this comment, there is // no plan to standardize it. See https://github.com/paritytech/smoldot/issues/2245. @@ -1317,33 +1316,36 @@ impl serde::Serialize for HexString { } } -/// Per-CID outcome returned by `bitswap_v1_getMany` and `bitswap_v1_streamEvent`. +/// Event payload of a `bitswap_unstable_streamEvent` notification. /// -/// Serializes to a JSON 2-element array `[cid, BlockResult]`. The `cid` is echoed verbatim from -/// the input so callers can correlate without keeping the input array around. +/// Serializes as a flat tagged object: every variant carries a top-level +/// `"event"` discriminator string and its fields appear next to it at the same +/// level (e.g. `{"event":"streamItem","cid":"...","value":"0x..."}`). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BitswapBlockResultEntry(pub String, pub BitswapBlockResult); - -/// Per-CID outcome carried inside [`BitswapBlockResultEntry`]. -/// -/// On success, a `0x`-prefixed hex string carrying the chunk data (same encoding as the return -/// value of `bitswap_v1_get`). On failure, a JSON-RPC error code and human-readable diagnostic -/// message — the `code` carries the same retry categories as the top-level error of -/// `bitswap_v1_get`. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(untagged)] -pub enum BitswapBlockResult { - Ok(HexString), - Err(BitswapBlockError), -} - -/// Per-CID error embedded inside [`BitswapBlockResult::Err`]. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BitswapBlockError { - /// Error code identifying the retry category. See `bitswap_v1_get` error categories. - pub code: i32, - /// Human-readable diagnostic message. Not stable for programmatic dispatch. - pub message: String, +#[serde(tag = "event", rename_all = "camelCase")] +pub enum BitswapStreamEvent<'a> { + /// A chunk for one of the input CIDs has been received successfully. + StreamItem { + /// The CID, echoed verbatim from the input. + cid: Cow<'a, str>, + /// The chunk data, hex-encoded with a `0x` prefix. + value: HexString, + }, + /// One of the input CIDs could not be resolved. The stream continues for + /// the remaining CIDs. + StreamItemError { + /// The CID, echoed verbatim from the input. + cid: Cow<'a, str>, + /// JSON-RPC error code identifying the retry category. See + /// `bitswap_unstable_get` error categories. + code: i32, + /// Human-readable diagnostic message. Not stable for programmatic dispatch. + message: Cow<'a, str>, + }, + /// End-of-stream marker. Emitted exactly once after all per-CID events, + /// only on natural completion (not on `bitswap_unstable_unstream` + /// cancellation or client disconnect). + StreamDone, } impl serde::Serialize for RpcMethods { @@ -1699,4 +1701,51 @@ mod tests { .collect(); assert!(super::TopicFilter::match_any(topics).is_err()); } + + #[test] + fn bitswap_stream_event_stream_item_serialization() { + // Spec: `{"event":"streamItem","cid":"...","value":"0x..."}` — flat object, no nesting. + let evt = super::BitswapStreamEvent::StreamItem { + cid: "abc".into(), + value: super::HexString(vec![0x48, 0x69]), + }; + assert_eq!( + serde_json::to_string(&evt).unwrap(), + r#"{"event":"streamItem","cid":"abc","value":"0x4869"}"# + ); + } + + #[test] + fn bitswap_stream_event_stream_item_error_serialization() { + // Spec: `code` and `message` appear at the top level of `result`, alongside `event` and + // `cid`. They are NOT nested under a sub-object. + let evt = super::BitswapStreamEvent::StreamItemError { + cid: "abc".into(), + code: -32811, + message: "request timeout".into(), + }; + let json = serde_json::to_string(&evt).unwrap(); + assert_eq!( + json, + r#"{"event":"streamItemError","cid":"abc","code":-32811,"message":"request timeout"}"# + ); + // Belt-and-braces: assert the structure explicitly so a future serde change that + // accidentally re-nests is caught. + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["event"], "streamItemError"); + assert_eq!(parsed["cid"], "abc"); + assert_eq!(parsed["code"], -32811); + assert!(parsed["message"].is_string()); + assert!(parsed.get("data").is_none()); + } + + #[test] + fn bitswap_stream_event_stream_done_serialization() { + // Spec: `{"event":"streamDone"}` — no other fields. + let evt = super::BitswapStreamEvent::StreamDone; + assert_eq!( + serde_json::to_string(&evt).unwrap(), + r#"{"event":"streamDone"}"# + ); + } } diff --git a/lib/src/json_rpc/service/client_main_task.rs b/lib/src/json_rpc/service/client_main_task.rs index fa6431f6a7..17eb75d968 100644 --- a/lib/src/json_rpc/service/client_main_task.rs +++ b/lib/src/json_rpc/service/client_main_task.rs @@ -394,8 +394,7 @@ impl ClientMainTask { | methods::MethodCall::author_rotateKeys { .. } | methods::MethodCall::author_submitExtrinsic { .. } | methods::MethodCall::babe_epochAuthorship { .. } - | methods::MethodCall::bitswap_v1_get { .. } - | methods::MethodCall::bitswap_v1_getMany { .. } + | methods::MethodCall::bitswap_unstable_get { .. } | methods::MethodCall::chain_getBlock { .. } | methods::MethodCall::chain_getBlockHash { .. } | methods::MethodCall::chain_getFinalizedHead { .. } @@ -474,7 +473,7 @@ impl ClientMainTask { | methods::MethodCall::transaction_v1_broadcast { .. } | methods::MethodCall::transactionWatch_v1_submitAndWatch { .. } | methods::MethodCall::sudo_network_unstable_watch { .. } - | methods::MethodCall::bitswap_v1_stream { .. } + | methods::MethodCall::bitswap_unstable_stream { .. } | methods::MethodCall::chainHead_v1_follow { .. } => { // Subscription starting requests. @@ -550,7 +549,7 @@ impl ClientMainTask { } | methods::MethodCall::transactionWatch_v1_unwatch { subscription, .. } | methods::MethodCall::sudo_network_unstable_unwatch { subscription, .. } - | methods::MethodCall::bitswap_v1_unstream { subscription, .. } + | methods::MethodCall::bitswap_unstable_unstream { subscription, .. } | methods::MethodCall::chainHead_v1_unfollow { follow_subscription: subscription, .. @@ -581,8 +580,8 @@ impl ClientMainTask { methods::MethodCall::sudo_network_unstable_unwatch { .. } => methods::Response::sudo_network_unstable_unwatch(()), - methods::MethodCall::bitswap_v1_unstream { .. } => { - methods::Response::bitswap_v1_unstream(()) + methods::MethodCall::bitswap_unstable_unstream { .. } => { + methods::Response::bitswap_unstable_unstream(()) } methods::MethodCall::chainHead_v1_unfollow { .. } => { methods::Response::chainHead_v1_unfollow(()) @@ -609,9 +608,9 @@ impl ClientMainTask { methods::Response::state_unsubscribeStorage(false) .to_json_response(request_id) } - methods::MethodCall::bitswap_v1_unstream { .. } => { + methods::MethodCall::bitswap_unstable_unstream { .. } => { // Per spec: no error if subscription is unknown or already-completed. - methods::Response::bitswap_v1_unstream(()) + methods::Response::bitswap_unstable_unstream(()) .to_json_response(request_id) } _ => parse::build_error_response( diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index a7a11f0ac0..352c013601 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -18,7 +18,7 @@ //! Background Bitswap service. //! //! The role of Bitswap service is to handle Bitswap RPC requests, specifically -//! `bitswap_v1_get(cid)`. +//! `bitswap_unstable_get(cid)` and `bitswap_unstable_stream(cids)`. //! //! In order to handle a request for a Bitswap block with a given CID, [`BitswapService`] issues //! Bitswap "have" request to all the connected Bitswap peers, then issues Bitswap "block" request @@ -80,7 +80,7 @@ use smoldot::{ // TODO: how many parallel requests to expect? const PARALLEL_REQUESTS: usize = 50; // 100 MiB of 2 MiB chunks. -/// Maximum number of CIDs accepted in a single `bitswap_v1_getMany` or `bitswap_v1_stream` call. +/// Maximum number of CIDs accepted in a single `bitswap_unstable_stream` call. /// Mirrors the limit used by the polkadot-sdk full-node implementation. The spec requires /// implementations to accept at least 16 CIDs. pub const MAX_CIDS_PER_REQUEST: usize = 64; @@ -185,60 +185,15 @@ impl BitswapService { result_rx.await.unwrap() } - /// Request multiple Bitswap blocks in a single batched want-list. Resolves once every CID has - /// been decided (Ok block, NotFound, or Timeout). The returned `Vec` echoes input CIDs in input - /// order with a [`BlockResult`] per slot. - /// - /// Top-level errors: - /// * `-32602 InvalidParams`: empty input is allowed (returns empty vec); duplicate CIDs or - /// batch size > [`MAX_CIDS_PER_REQUEST`] are rejected before any wire I/O. - /// * `-32812 FailRetryBackoff`: the initial Have broadcast had no Bitswap peers to send to, or - /// the network send queue was full. Per spec, retry after a backoff delay (~5s). - pub async fn bitswap_get_many( - &self, - cids: Vec, - ) -> Result, BitswapGetError> { - let entries = parse_and_dedup(cids)?; - - if entries.is_empty() { - return Ok(Vec::new()); - } - - let (result_tx, result_rx) = oneshot::channel(); - let (ready_tx, ready_rx) = oneshot::channel(); - - self.messages_tx - .send(ToBackground::BitswapBatch { - entries, - mode: BatchMode::GetMany { result_tx }, - ready_tx, - }) - .await - .unwrap(); - - // The service signals broadcast outcome here: `Ok(BatchId)` once the Have broadcast has - // landed (or the all-invalid-CID fast path resolves), `Err(...)` if the broadcast failed - // wholesale. Wholesale failures surface as top-level JSON-RPC errors per spec. - let batch_id = ready_rx.await.unwrap()?; - - // RAII guard: if the caller's future is dropped before result_rx resolves, the guard - // drops and sends `CancelBatch` to the service so peers receive a Cancel wantlist. - let _cancel_guard = BatchCancelGuard { - batch_id, - messages_tx: self.messages_tx.clone(), - }; - - Ok(result_rx.await.unwrap()) - } - /// Subscribe to a stream of Bitswap blocks. On success, returns a [`BitswapStreamHandle`] /// whose `events_rx` yields one `(cid_string, BlockResult)` event per input CID, in arrival /// order (the order in which each CID resolves), not input order. /// - /// Top-level errors mirror [`BitswapService::bitswap_get_many`]: wholesale Have-broadcast - /// failures (no peers / queue full) reject the subscription with `-32812 FailRetryBackoff` - /// so no events are emitted, per the `bitswap_v1_stream` spec ("whole-call failures cause - /// the subscription to be rejected"). + /// Top-level errors: wholesale Have-broadcast failures (no peers / queue full) reject the + /// subscription with `-32812 FailRetryBackoff` so no events are emitted, per the + /// `bitswap_unstable_stream` spec ("whole-call failures cause the subscription to be + /// rejected"). Empty/duplicate/oversized inputs are rejected at the top level with + /// `-32802 EmptyCids` / `-32803 DuplicateCids` / `-32801 TooManyCids` respectively. /// /// Dropping the returned handle (explicit unsubscribe or client disconnect) cancels remaining /// work and emits a Bitswap Cancel wantlist to peers we previously contacted. @@ -256,7 +211,7 @@ impl BitswapService { self.messages_tx .send(ToBackground::BitswapBatch { entries, - mode: BatchMode::Stream { events_tx }, + events_tx, ready_tx, }) .await @@ -274,7 +229,7 @@ impl BitswapService { } } -/// Per-CID outcome of [`BitswapService::bitswap_get_many`] / [`BitswapService::bitswap_stream`]. +/// Per-CID outcome of [`BitswapService::bitswap_stream`]. #[derive(Debug, Clone)] pub enum BlockResult { /// Block bytes received from a peer. @@ -295,9 +250,8 @@ pub struct BitswapStreamHandle { _cancel_guard: BatchCancelGuard, } -/// Internal RAII guard that fires `ToBackground::CancelBatch` on drop. Held by both -/// [`BitswapStreamHandle`] (covers explicit unsubscribe and client disconnect) and the inner -/// future of [`BitswapService::bitswap_get_many`] (covers caller cancellation mid-await). +/// Internal RAII guard that fires `ToBackground::CancelBatch` on drop. Held by +/// [`BitswapStreamHandle`] (covers explicit unsubscribe and client disconnect). struct BatchCancelGuard { batch_id: BatchId, messages_tx: async_channel::Sender, @@ -344,17 +298,30 @@ pub enum BitswapGetError { /// Number of CIDs in the rejected request. got: usize, }, + /// Input array is empty. + #[display("Input array is empty.")] + EmptyCids, /// Same CID appears more than once in the input. Two-stage detection: literal-string match, /// or two distinct strings decoding to the same content digest. #[display("Input contains duplicate CIDs.")] DuplicateCids, } -/// JSON-RPC error categories for `bitswap_v1_get` method. +/// JSON-RPC error codes used by the `bitswap_*` namespace. /// -/// Clients should use the error code to determine recovery action, -/// not parse the human-readable message string. +/// Clients should use the numeric code to determine recovery action, not parse the +/// human-readable message string. Top-level batch-input validation codes +/// (`TooManyCids`, `EmptyCids`, `DuplicateCids`) only appear as top-level errors of +/// `bitswap_unstable_stream`. Per-request/per-event codes (`Fail`, `FailRetry`, +/// `FailRetryBackoff`) appear both as the top-level error of `bitswap_unstable_get` +/// and inside `streamItemError` events. enum BitswapJsonRpcError { + /// Batch-input validation: `cids.len()` exceeds the implementation maximum. + TooManyCids = -32801, + /// Batch-input validation: `cids` is empty. + EmptyCids = -32802, + /// Batch-input validation: duplicate CID in input. + DuplicateCids = -32803, /// Permanent failure for this request. E.g., there is no requested data in the network. /// Doesn't make sense to retry until you put the data on chain. Fail = -32810, @@ -374,43 +341,50 @@ impl BitswapGetError { pub fn to_json_rpc_error(&self, request_id_json: &str) -> String { let message = self.to_string(); - // Even though the spec says the error variants like `NoPeers` etc. are not stable and - // provided for debugging purposes only, any changes to the variant names should be avoided - // to not surprise anybody. - let (variant, category) = match self { - BitswapGetError::InvalidCid(_) => ("InvalidCid", None), - BitswapGetError::NotFound => ("NotFound", Some(BitswapJsonRpcError::Fail)), - BitswapGetError::BlockRequestFailed => { - ("BlockRequestFailed", Some(BitswapJsonRpcError::FailRetry)) + let error_response = match self { + BitswapGetError::InvalidCid(_) => parse::ErrorResponse::InvalidParams(Some(&message)), + BitswapGetError::NotFound => { + parse::ErrorResponse::ApplicationDefined(BitswapJsonRpcError::Fail as i64, &message) } - BitswapGetError::Timeout => ("Timeout", Some(BitswapJsonRpcError::FailRetry)), - BitswapGetError::QueueFull => { - ("QueueFull", Some(BitswapJsonRpcError::FailRetryBackoff)) + BitswapGetError::BlockRequestFailed | BitswapGetError::Timeout => { + parse::ErrorResponse::ApplicationDefined( + BitswapJsonRpcError::FailRetry as i64, + &message, + ) } - BitswapGetError::NoPeers => ("NoPeers", Some(BitswapJsonRpcError::FailRetryBackoff)), - BitswapGetError::TooManyCids { .. } => ("TooManyCids", None), - BitswapGetError::DuplicateCids => ("DuplicateCids", None), - }; - - let data = format!("{{\"variant\":\"{variant}\"}}"); - - let error_response = match category { - None => parse::ErrorResponse::InvalidParams(Some(&message)), - Some(cat) => parse::ErrorResponse::ApplicationDefined(cat as i64, &message), + BitswapGetError::QueueFull | BitswapGetError::NoPeers => { + parse::ErrorResponse::ApplicationDefined( + BitswapJsonRpcError::FailRetryBackoff as i64, + &message, + ) + } + BitswapGetError::TooManyCids { .. } => parse::ErrorResponse::ApplicationDefined( + BitswapJsonRpcError::TooManyCids as i64, + &message, + ), + BitswapGetError::EmptyCids => parse::ErrorResponse::ApplicationDefined( + BitswapJsonRpcError::EmptyCids as i64, + &message, + ), + BitswapGetError::DuplicateCids => parse::ErrorResponse::ApplicationDefined( + BitswapJsonRpcError::DuplicateCids as i64, + &message, + ), }; - parse::build_error_response(request_id_json, error_response, Some(&data)) + parse::build_error_response(request_id_json, error_response, None) } - /// Returns the JSON-RPC `(code, message)` pair to embed inside a per-CID `BlockResult::Err` - /// in `bitswap_v1_getMany` / `bitswap_v1_streamEvent`. The code uses the same four categories - /// as the top-level error of `bitswap_v1_get`, so callers can reuse retry logic. + /// Returns the JSON-RPC `(code, message)` pair to embed inside a per-CID `streamItemError` + /// event of `bitswap_unstable_streamEvent`. The code uses the same retry categories as the + /// top-level error of `bitswap_unstable_get`, so callers can reuse retry logic. + /// + /// The top-level batch-validation variants (`TooManyCids`, `EmptyCids`, `DuplicateCids`) + /// never appear per-event — they reject the subscription before any event is emitted. pub fn to_block_result_err(&self) -> (i32, String) { const INVALID_PARAMS: i32 = -32602; let code = match self { - BitswapGetError::InvalidCid(_) - | BitswapGetError::TooManyCids { .. } - | BitswapGetError::DuplicateCids => INVALID_PARAMS, + BitswapGetError::InvalidCid(_) => INVALID_PARAMS, BitswapGetError::NotFound => BitswapJsonRpcError::Fail as i32, BitswapGetError::BlockRequestFailed | BitswapGetError::Timeout => { BitswapJsonRpcError::FailRetry as i32 @@ -418,6 +392,11 @@ impl BitswapGetError { BitswapGetError::QueueFull | BitswapGetError::NoPeers => { BitswapJsonRpcError::FailRetryBackoff as i32 } + BitswapGetError::TooManyCids { .. } + | BitswapGetError::EmptyCids + | BitswapGetError::DuplicateCids => { + unreachable!("top-level batch-validation error, never emitted per-CID") + } }; (code, self.to_string()) } @@ -432,7 +411,7 @@ impl From for BitswapGetError { } } -/// Validates and de-duplicates the input CIDs of `bitswap_v1_getMany` / `bitswap_v1_stream`. +/// Validates and de-duplicates the input CIDs of `bitswap_unstable_stream`. /// /// On success returns one entry per input CID, in input order, preserving the original string and /// the parse result. Caller-side per-CID `Err(InvalidCid)` reporting is left to the JSON-RPC layer @@ -445,6 +424,9 @@ impl From for BitswapGetError { pub fn parse_and_dedup( cids: Vec, ) -> Result)>, BitswapGetError> { + if cids.is_empty() { + return Err(BitswapGetError::EmptyCids); + } if cids.len() > MAX_CIDS_PER_REQUEST { return Err(BitswapGetError::TooManyCids { max: MAX_CIDS_PER_REQUEST, @@ -489,7 +471,8 @@ enum ToBackground { /// Validated and de-duplicated entries from [`parse_and_dedup`]. Per-slot `Err` carries /// an `InvalidCid` ParseError that gets surfaced as a per-CID error event. entries: Vec<(String, Result)>, - mode: BatchMode, + /// Per-CID outcomes are pushed here as they become available, in arrival order. + events_tx: async_channel::Sender<(String, BlockResult)>, ready_tx: oneshot::Sender>, }, /// Cancel an in-flight batch. Idempotent: if the batch already finished, this is a no-op. @@ -498,17 +481,6 @@ enum ToBackground { }, } -/// Mode of a batched request. `GetMany` collects all outcomes and replies once; `Stream` pushes -/// each outcome to `events_tx` as it becomes available. -enum BatchMode { - GetMany { - result_tx: oneshot::Sender>, - }, - Stream { - events_tx: async_channel::Sender<(String, BlockResult)>, - }, -} - #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct RequestId(u64); @@ -530,7 +502,7 @@ enum RequestStage { } /// Per-request output destination. A request resolves into either a single oneshot reply -/// (for `bitswap_get`) or a slot of a `Batch` (for `bitswap_get_many` / `bitswap_stream`). +/// (for `bitswap_get`) or a slot of a `Batch` (for `bitswap_stream`). #[derive(Debug)] enum SlotOutput { Single(oneshot::Sender, BitswapGetError>>), @@ -548,17 +520,14 @@ struct Request { /// State for an in-flight batch. Allocated when `BitswapBatch` is received and removed once all /// slots have been resolved (or the batch is explicitly cancelled). struct Batch { - mode: BatchMode, - /// Per-slot CID strings, indexed by slot index. Echoed back in outcomes/events. + /// Sender for per-CID outcomes, in arrival order. Dropping it closes the channel, signalling + /// end-of-stream to the JSON-RPC pump. + events_tx: async_channel::Sender<(String, BlockResult)>, + /// Per-slot CID strings, indexed by slot index. Echoed back in events. cid_strs: Vec, /// `Some(RequestId)` while a slot is in-flight; `None` once the slot has been resolved. /// Invalid-CID slots start as `None` (resolved synchronously when the batch is created). slots: Vec>, - /// Per-slot collected outcomes. For `BatchMode::GetMany` this fills in until `pending_count` - /// reaches zero, then is drained into the response. For `BatchMode::Stream` outcomes go - /// directly to `events_tx` and these slots stay `None` (kept allocated to mirror `cid_strs` - /// for diagnostic readability — checked by `pending_count`). - outcomes: Vec>, /// Peers we sent this batch's Have broadcast to. Used to address Cancel wantlist on /// `CancelBatch`. Empty until `HaveBroadcastResult` arrives successfully. peers_for_cancel: Vec, @@ -579,7 +548,7 @@ enum HaveContext { /// is queued and don't appear here. cids: Vec<(usize, Cid)>, /// Channel for signalling broadcast outcome back to the caller of - /// [`BitswapService::bitswap_get_many`] / [`BitswapService::bitswap_stream`]. Sent exactly + /// [`BitswapService::bitswap_stream`]. Sent exactly /// once when the broadcast resolves: `Ok(batch_id)` on success (peer set non-empty), or /// `Err(_)` on wholesale failure so the caller surfaces a top-level JSON-RPC error. ready_tx: oneshot::Sender>, @@ -627,7 +596,7 @@ struct BackgroundTask { /// the platform implementation of `now` is monothonic (true for /// [`crate::platform::DefaultPlatform`]). requests_by_cid: hashbrown::HashMap, util::SipHasherBuild>, - /// In-flight batches. Each entry corresponds to a `bitswap_get_many` / `bitswap_stream` call. + /// In-flight batches. Each entry corresponds to a `bitswap_stream` call. batches: hashbrown::HashMap, /// Set of peers with an open Bitswap substream — i.e. the targets of /// `broadcast_bitswap_message`. Maintained from `BitswapEvent::BitswapConnected`/ @@ -691,31 +660,24 @@ impl BackgroundTask { let cid_str = batch.cid_strs[slot_idx].clone(); let mut should_cancel = false; - match &mut batch.mode { - BatchMode::Stream { events_tx } => { - match events_tx.try_send((cid_str.clone(), block_result)) { - Ok(()) => {} - Err(async_channel::TrySendError::Closed(_)) => { - // Receiver dropped — JSON-RPC client disconnected or unsubscribed. - // Cancel the rest of the batch and emit a Bitswap Cancel wantlist. - should_cancel = true; - } - Err(async_channel::TrySendError::Full(_)) => { - // Bounded channel saturated. With a `bounded(MAX_CIDS_PER_REQUEST)` - // channel and a JSON-RPC pump that drains it eagerly this should be - // unreachable in practice. Log and drop the event rather than - // blocking the service. - log!( - &self.platform, - Warn, - &self.log_target, - "stream events channel full, dropping per-CID event" - ); - } - } + match batch.events_tx.try_send((cid_str.clone(), block_result)) { + Ok(()) => {} + Err(async_channel::TrySendError::Closed(_)) => { + // Receiver dropped — JSON-RPC client disconnected or unsubscribed. + // Cancel the rest of the batch and emit a Bitswap Cancel wantlist. + should_cancel = true; } - BatchMode::GetMany { .. } => { - batch.outcomes[slot_idx] = Some(block_result); + Err(async_channel::TrySendError::Full(_)) => { + // Bounded channel saturated. With a `bounded(MAX_CIDS_PER_REQUEST)` + // channel and a JSON-RPC pump that drains it eagerly this should be + // unreachable in practice. Log and drop the event rather than + // blocking the service. + log!( + &self.platform, + Warn, + &self.log_target, + "stream events channel full, dropping per-CID event" + ); } } @@ -731,8 +693,8 @@ impl BackgroundTask { } } - /// Finalize a batch whose slots have all been resolved. Drains accumulated outcomes for - /// `GetMany` mode and closes the events channel for `Stream` mode. + /// Finalize a batch whose slots have all been resolved. Closes the events channel so the + /// JSON-RPC pump can emit the spec-required `streamDone` event. fn finalize_batch(&mut self, batch_id: BatchId) { let Some(batch) = self.batches.remove(&batch_id) else { return; @@ -748,24 +710,9 @@ impl BackgroundTask { slots = batch.cid_strs.len() ); - match batch.mode { - BatchMode::GetMany { result_tx } => { - let mut out = Vec::with_capacity(batch.cid_strs.len()); - for (cid_str, outcome) in batch.cid_strs.into_iter().zip(batch.outcomes.into_iter()) - { - out.push(( - cid_str, - outcome.expect("pending_count == 0 implies all slots resolved; qed"), - )); - } - let _ = result_tx.send(out); - } - BatchMode::Stream { events_tx } => { - // Dropping the sender closes the channel. The JSON-RPC pump task will see the - // channel close and end its loop after the last event has been delivered. - drop(events_tx); - } - } + // Dropping the sender closes the channel. The JSON-RPC pump task will see the channel + // close and end its loop after the last event has been delivered. + drop(batch.events_tx); } /// Cancel a batch: tear down all its still-pending slots, then send a Bitswap Cancel wantlist @@ -959,7 +906,7 @@ async fn background_task(mut task: BackgroundTask) { } WakeUpReason::Message(ToBackground::BitswapBatch { entries, - mode, + events_tx, ready_tx, }) => { debug_assert!(task.pending_have_broadcast.is_none()); @@ -972,48 +919,36 @@ async fn background_task(mut task: BackgroundTask) { let total = entries.len(); let mut cid_strs: Vec = Vec::with_capacity(total); let mut slots: Vec> = Vec::with_capacity(total); - let mut outcomes: Vec> = Vec::with_capacity(total); // Slots whose CID parsed successfully — these will have their RequestId set // after the Have broadcast lands. (slot_idx, cid). let mut valid_cids: Vec<(usize, Cid)> = Vec::with_capacity(total); // Slots whose CID failed to parse — pre-resolve as `InvalidCid` per spec. - // (slot_idx, cid_str, err). - let mut invalid_slots: Vec<(usize, String, BitswapGetError)> = Vec::new(); + // (cid_str, err). + let mut invalid_slots: Vec<(String, BitswapGetError)> = Vec::new(); for (slot_idx, (cid_str, parsed)) in entries.into_iter().enumerate() { cid_strs.push(cid_str.clone()); slots.push(None); - outcomes.push(None); match parsed { Ok(c) => valid_cids.push((slot_idx, c)), Err(e) => { - invalid_slots.push((slot_idx, cid_str, BitswapGetError::InvalidCid(e))); + invalid_slots.push((cid_str, BitswapGetError::InvalidCid(e))); } } } let mut batch = Batch { - mode, + events_tx, cid_strs, slots, - outcomes, peers_for_cancel: Vec::new(), pending_count: total, }; - // Pre-resolve invalid-CID slots. For Stream we push events immediately; for - // GetMany we accumulate in `outcomes`. - for (slot_idx, cid_str, err) in invalid_slots { + // Pre-resolve invalid-CID slots by pushing them straight into the events channel. + for (cid_str, err) in invalid_slots { batch.pending_count -= 1; - let block_result = BlockResult::Err(err); - match &mut batch.mode { - BatchMode::Stream { events_tx } => { - let _ = events_tx.try_send((cid_str, block_result)); - } - BatchMode::GetMany { .. } => { - batch.outcomes[slot_idx] = Some(block_result); - } - } + let _ = batch.events_tx.try_send((cid_str, BlockResult::Err(err))); } log!( @@ -1529,13 +1464,14 @@ mod tests { parsed["error"]["code"].as_i64().unwrap() } - /// Parse the data.variant field from the JSON-RPC error response string. - fn extract_variant(json: &str) -> String { + /// Assert that the JSON-RPC error response carries no `data` field. Per the revised spec, + /// bitswap errors are bare `{ code, message }` like every other method in this repo. + fn assert_no_error_data(json: &str) { let parsed: serde_json::Value = serde_json::from_str(json).unwrap(); - parsed["error"]["data"]["variant"] - .as_str() - .unwrap() - .to_owned() + assert!( + parsed["error"].get("data").is_none(), + "error object must not carry `data`: {json}" + ); } #[test] @@ -1543,42 +1479,42 @@ mod tests { let err = BitswapGetError::InvalidCid(Cid::from_str("not-a-cid").unwrap_err()); let json = err.to_json_rpc_error("\"1\""); assert_eq!(extract_error_code(&json), -32602); // InvalidParams - assert_eq!(extract_variant(&json), "InvalidCid"); + assert_no_error_data(&json); } #[test] fn error_not_found_maps_to_fail() { let json = BitswapGetError::NotFound.to_json_rpc_error("\"1\""); assert_eq!(extract_error_code(&json), -32810); // Fail - assert_eq!(extract_variant(&json), "NotFound"); + assert_no_error_data(&json); } #[test] fn error_block_request_failed_maps_to_fail_retry() { let json = BitswapGetError::BlockRequestFailed.to_json_rpc_error("\"1\""); assert_eq!(extract_error_code(&json), -32811); // FailRetry - assert_eq!(extract_variant(&json), "BlockRequestFailed"); + assert_no_error_data(&json); } #[test] fn error_timeout_maps_to_fail_retry() { let json = BitswapGetError::Timeout.to_json_rpc_error("\"1\""); assert_eq!(extract_error_code(&json), -32811); // FailRetry - assert_eq!(extract_variant(&json), "Timeout"); + assert_no_error_data(&json); } #[test] fn error_queue_full_maps_to_fail_retry_backoff() { let json = BitswapGetError::QueueFull.to_json_rpc_error("\"1\""); assert_eq!(extract_error_code(&json), -32812); // FailRetryBackoff - assert_eq!(extract_variant(&json), "QueueFull"); + assert_no_error_data(&json); } #[test] fn error_no_peers_maps_to_fail_retry_backoff() { let json = BitswapGetError::NoPeers.to_json_rpc_error("\"1\""); assert_eq!(extract_error_code(&json), -32812); // FailRetryBackoff - assert_eq!(extract_variant(&json), "NoPeers"); + assert_no_error_data(&json); } #[test] @@ -1603,23 +1539,33 @@ mod tests { } #[test] - fn error_too_many_cids_maps_to_invalid_params() { + fn error_too_many_cids_maps_to_too_many_cids() { let err = BitswapGetError::TooManyCids { max: 64, got: 100 }; let json = err.to_json_rpc_error("\"1\""); - assert_eq!(extract_error_code(&json), -32602); - assert_eq!(extract_variant(&json), "TooManyCids"); + assert_eq!(extract_error_code(&json), -32801); // TooManyCids + assert_no_error_data(&json); + } + + #[test] + fn error_empty_cids_maps_to_empty_cids() { + let json = BitswapGetError::EmptyCids.to_json_rpc_error("\"1\""); + assert_eq!(extract_error_code(&json), -32802); // EmptyCids + assert_no_error_data(&json); } #[test] - fn error_duplicate_cids_maps_to_invalid_params() { + fn error_duplicate_cids_maps_to_duplicate_cids() { let json = BitswapGetError::DuplicateCids.to_json_rpc_error("\"1\""); - assert_eq!(extract_error_code(&json), -32602); - assert_eq!(extract_variant(&json), "DuplicateCids"); + assert_eq!(extract_error_code(&json), -32803); // DuplicateCids + assert_no_error_data(&json); } #[test] fn block_result_err_codes_match_top_level_codes() { - // Per spec, per-CID error codes use the same retry categories as `bitswap_v1_get`. + // Per-CID error codes use the same retry categories as `bitswap_unstable_get`. + // Top-level batch-validation variants (TooManyCids / EmptyCids / DuplicateCids) are + // intentionally absent here — they reject the subscription before any per-CID event is + // emitted. assert_eq!(BitswapGetError::NotFound.to_block_result_err().0, -32810); assert_eq!(BitswapGetError::Timeout.to_block_result_err().0, -32811); assert_eq!( @@ -1642,9 +1588,11 @@ mod tests { const VALID_CID_B: &str = "bafkreigh2akiscaildc3rdvuwhszwgrtgvybsh7lhxavhgqitanwh4kc6q"; #[test] - fn parse_and_dedup_empty_input_is_ok() { - let out = parse_and_dedup(vec![]).unwrap(); - assert!(out.is_empty()); + fn parse_and_dedup_empty_input_is_rejected() { + // Per the revised spec, an empty `cids` array is rejected at the top level with + // -32802 EmptyCids. + let err = parse_and_dedup(vec![]).unwrap_err(); + assert!(matches!(err, BitswapGetError::EmptyCids)); } #[test] diff --git a/light-base/src/json_rpc_service/background.rs b/light-base/src/json_rpc_service/background.rs index 63588d48fc..998cc16beb 100644 --- a/light-base/src/json_rpc_service/background.rs +++ b/light-base/src/json_rpc_service/background.rs @@ -179,9 +179,9 @@ struct Background { /// unsubscribes. transactions_subscriptions: hashbrown::HashMap, - /// Active `bitswap_v1_stream` subscriptions, keyed by subscription ID. Holds the + /// Active `bitswap_unstable_stream` subscriptions, keyed by subscription ID. Holds the /// [`bitswap_service::BitswapStreamHandle`] alive — dropping the entry (via - /// `bitswap_v1_unstream` or task shutdown) drops the embedded cancel guard, which sends a + /// `bitswap_unstable_unstream` or task shutdown) drops the embedded cancel guard, which sends a /// Bitswap Cancel wantlist to peers we contacted on this subscription's behalf. bitswap_subscriptions: hashbrown::HashMap, @@ -518,16 +518,9 @@ enum Event { request_id_json: String, result: Result, bitswap_service::BitswapGetError>, }, - BitswapGetManyResult { - request_id_json: String, - result: Result< - Vec<(String, bitswap_service::BlockResult)>, - bitswap_service::BitswapGetError, - >, - }, - /// One iteration of the `bitswap_v1_stream` events pump. `event` is `None` if the events - /// channel closed (no more notifications). The receiver is shipped along so the main loop - /// can re-arm the next pump iteration. + /// One iteration of the `bitswap_unstable_stream` events pump. `event` is `None` if the + /// events channel closed (no more notifications). The receiver is shipped along so the main + /// loop can re-arm the next pump iteration. BitswapStreamEvent { subscription_id: String, event: Option<(String, bitswap_service::BlockResult)>, @@ -1039,10 +1032,9 @@ pub(super) async fn run( | methods::MethodCall::sudo_network_unstable_watch { .. } | methods::MethodCall::sudo_network_unstable_unwatch { .. } | methods::MethodCall::chainHead_unstable_finalizedDatabase { .. } - | methods::MethodCall::bitswap_v1_get { .. } - | methods::MethodCall::bitswap_v1_getMany { .. } - | methods::MethodCall::bitswap_v1_stream { .. } - | methods::MethodCall::bitswap_v1_unstream { .. } => {} + | methods::MethodCall::bitswap_unstable_get { .. } + | methods::MethodCall::bitswap_unstable_stream { .. } + | methods::MethodCall::bitswap_unstable_unstream { .. } => {} } // Actual requests handler. @@ -1165,7 +1157,7 @@ pub(super) async fn run( // renewing itself the next time it generates a notification. } - methods::MethodCall::bitswap_v1_get { cid } => { + methods::MethodCall::bitswap_unstable_get { cid } => { log!( &me.platform, Debug, @@ -1189,30 +1181,7 @@ pub(super) async fn run( }); } - methods::MethodCall::bitswap_v1_getMany { cids } => { - log!( - &me.platform, - Debug, - &me.log_target, - format!("Bitswap getMany request: {} cids", cids.len()) - ); - - me.background_tasks.push({ - let bitswap_service = me.bitswap_service.clone(); - let request_id_json = request_id_json.to_owned(); - - Box::pin(async move { - let result = bitswap_service.bitswap_get_many(cids).await; - - Event::BitswapGetManyResult { - request_id_json, - result, - } - }) - }); - } - - methods::MethodCall::bitswap_v1_stream { cids } => { + methods::MethodCall::bitswap_unstable_stream { cids } => { log!( &me.platform, Debug, @@ -1238,7 +1207,7 @@ pub(super) async fn run( let _ = me .responses_tx .send( - methods::Response::bitswap_v1_stream(Cow::Borrowed( + methods::Response::bitswap_unstable_stream(Cow::Borrowed( &subscription_id, )) .to_json_response(request_id_json), @@ -1266,18 +1235,21 @@ pub(super) async fn run( } } - methods::MethodCall::bitswap_v1_unstream { subscription } => { + methods::MethodCall::bitswap_unstable_unstream { subscription } => { // Removing the entry drops the embedded `BitswapStreamHandle`, which // drops the cancel guard, which sends `ToBackground::CancelBatch` to the // bitswap service. The service then evicts pending slots and emits a - // Bitswap Cancel wantlist to peers we'd contacted. + // Bitswap Cancel wantlist to peers we'd contacted. Once the entry is + // gone, the `contains_key` gate in the `BitswapStreamEvent` drain + // ensures neither per-CID events nor `streamDone` are emitted on the + // cancelled subscription. me.bitswap_subscriptions.remove(&*subscription); // Per spec: success even if the subscription is unknown or already // completed. let _ = me .responses_tx .send( - methods::Response::bitswap_v1_unstream(()) + methods::Response::bitswap_unstable_unstream(()) .to_json_response(request_id_json), ) .await; @@ -6082,44 +6054,13 @@ pub(super) async fn run( result, }) => { let response = match result { - Ok(block) => methods::Response::bitswap_v1_get(methods::HexString(block)) + Ok(block) => methods::Response::bitswap_unstable_get(methods::HexString(block)) .to_json_response(&request_id_json), Err(error) => error.to_json_rpc_error(&request_id_json), }; let _ = me.responses_tx.send(response).await; } - WakeUpReason::Event(Event::BitswapGetManyResult { - request_id_json, - result, - }) => { - let response = match result { - Ok(entries) => { - let result_array: Vec = entries - .into_iter() - .map(|(cid, br)| { - let block_result = match br { - bitswap_service::BlockResult::Ok(bytes) => { - methods::BitswapBlockResult::Ok(methods::HexString(bytes)) - } - bitswap_service::BlockResult::Err(err) => { - let (code, message) = err.to_block_result_err(); - methods::BitswapBlockResult::Err( - methods::BitswapBlockError { code, message }, - ) - } - }; - methods::BitswapBlockResultEntry(cid, block_result) - }) - .collect(); - methods::Response::bitswap_v1_getMany(result_array) - .to_json_response(&request_id_json) - } - Err(error) => error.to_json_rpc_error(&request_id_json), - }; - let _ = me.responses_tx.send(response).await; - } - WakeUpReason::Event(Event::BitswapStreamEvent { subscription_id, event, @@ -6127,28 +6068,32 @@ pub(super) async fn run( }) => { // If the JSON-RPC client unsubscribed (or this is a stale event), the // subscription will not be in the map and we must not emit notifications for it. + // Per spec, cancellation via `unstream` is silent (no `streamDone`). if !me.bitswap_subscriptions.contains_key(&subscription_id) { continue; } match event { Some((cid, br)) => { - let block_result = match br { + let result = match br { bitswap_service::BlockResult::Ok(bytes) => { - methods::BitswapBlockResult::Ok(methods::HexString(bytes)) + methods::BitswapStreamEvent::StreamItem { + cid: Cow::Owned(cid), + value: methods::HexString(bytes), + } } bitswap_service::BlockResult::Err(err) => { let (code, message) = err.to_block_result_err(); - methods::BitswapBlockResult::Err(methods::BitswapBlockError { + methods::BitswapStreamEvent::StreamItemError { + cid: Cow::Owned(cid), code, - message, - }) + message: Cow::Owned(message), + } } }; - let entry = methods::BitswapBlockResultEntry(cid, block_result); - let notification = methods::ServerToClient::bitswap_v1_streamEvent { + let notification = methods::ServerToClient::bitswap_unstable_streamEvent { subscription: Cow::Borrowed(&subscription_id), - result: entry, + result, } .to_json_request_object_parameters(None); @@ -6166,10 +6111,14 @@ pub(super) async fn run( } None => { // Channel closed — the bitswap service has emitted exactly one event per - // input CID and dropped its sender. Per spec, the JSON-RPC subscription - // remains addressable until the client calls `bitswap_v1_unstream`; we - // can drop our internal entry early since `unstream` is a no-op for an - // unknown subscription. + // input CID and dropped its sender. Emit the spec-required `streamDone` + // marker before tearing down the subscription entry. + let notification = methods::ServerToClient::bitswap_unstable_streamEvent { + subscription: Cow::Borrowed(&subscription_id), + result: methods::BitswapStreamEvent::StreamDone, + } + .to_json_request_object_parameters(None); + let _ = me.responses_tx.send(notification).await; me.bitswap_subscriptions.remove(&subscription_id); } } diff --git a/light-base/src/lib.rs b/light-base/src/lib.rs index 86137c3a64..87da079c85 100644 --- a/light-base/src/lib.rs +++ b/light-base/src/lib.rs @@ -1278,8 +1278,8 @@ fn start_services( }, )); - // The Bitswap service fulfils `bitswap_v1_get(cid)` JSON-RPC requests by querying remote - // nodes for IPFS blocks. + // The Bitswap service fulfils `bitswap_unstable_get(cid)` and `bitswap_unstable_stream(cids)` + // JSON-RPC requests by querying remote nodes for IPFS blocks. let bitswap_service = Arc::new(bitswap_service::BitswapService::new( bitswap_service::Config { log_name, From 1af046e43487f9b65c730ad31fb99b3c89903567 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Mon, 18 May 2026 16:57:41 +0200 Subject: [PATCH 09/15] review findings --- e2e-tests/js/bulletin_batch.js | 123 +++++++++++++++++++- e2e-tests/src/harness.rs | 180 ++++++++++++++++++++++++++++++ e2e-tests/src/lib.rs | 1 + e2e-tests/tests/bulletin_batch.rs | 156 +++----------------------- e2e-tests/tests/bulletin_fetch.rs | 149 +++---------------------- light-base/src/bitswap_service.rs | 139 ++++++++++++----------- light-base/src/network_service.rs | 35 +++--- 7 files changed, 421 insertions(+), 362 deletions(-) create mode 100644 e2e-tests/src/harness.rs diff --git a/e2e-tests/js/bulletin_batch.js b/e2e-tests/js/bulletin_batch.js index 4e9714e99e..48a80e71ae 100644 --- a/e2e-tests/js/bulletin_batch.js +++ b/e2e-tests/js/bulletin_batch.js @@ -55,6 +55,10 @@ try { }); // ===== bitswap_unstable_stream ===== + // MUST be first: relies on the freshly-created smoldot client having no + // Bitswap peers connected yet, which exercises the wholesale Have-broadcast + // failure path. + await runStreamColdStartOpensSubscription(bulletin); await runStreamHappy(bulletin); await runStreamDedup(bulletin); await runStreamTooMany(bulletin); @@ -78,10 +82,98 @@ process.exit(exitCode || process.exitCode || 0); // ---------- stream tests ---------- +/// Asserts that on a freshly-created smoldot client (no Bitswap peers yet), +/// `bitswap_unstable_stream` STILL opens the subscription successfully and +/// fans the wholesale-broadcast-failure out as per-CID `streamItemError` +/// events followed by `streamDone` — per the revised +/// `bitswap_unstable_stream` spec, which forbids top-level `-32812`. +/// +/// MUST run before any other stream test: subsequent tests run a small retry +/// loop that warms the peer set up as a side effect, so the cold-start path +/// only fires on the first call. +/// +/// Subscribes via `sendRpcAndWait` DIRECTLY (no `subscribeWithRetry`) so that +/// a regression — top-level error coming back instead of the spec-mandated +/// per-CID fanout — is surfaced as a thrown error rather than silently +/// retried away. +async function runStreamColdStartOpensSubscription(chain) { + const cids = [payloads[0].cid]; + let subscription; + try { + subscription = await sendRpcAndWait(chain, "bitswap_unstable_stream", [cids], 30_000); + } catch (err) { + // Pre-(1)-fix this was the normal path: cold smoldot → top-level -32812. + // Post-(1)-fix this MUST NOT happen. + report( + "st-cold-open", + false, + `subscription rejected at top level (regression of wholesale-failure fanout): ${err.message}`, + ); + return; + } + // Subscription opened — now drain to streamDone and check shape. + const deadline = Date.now() + 60_000; + const collected = []; + let sawStreamDone = false; + while (!sawStreamDone) { + const got = await readJsonRpcUntil( + chain, + (msg) => streamEventResult(msg, subscription), + deadline, + ); + if (got === undefined) { + report("st-cold-open", false, `timed out before streamDone (collected ${collected.length})`); + return; + } + switch (got.event) { + case "streamItem": + collected.push({ kind: "ok", cid: got.cid }); + break; + case "streamItemError": + collected.push({ kind: "err", cid: got.cid, code: got.code }); + break; + case "streamDone": + sawStreamDone = true; + break; + default: + report("st-cold-open", false, `unknown event: ${JSON.stringify(got)}`); + return; + } + } + if (collected.length !== cids.length) { + report( + "st-cold-open", + false, + `streamDone after ${collected.length} per-CID events, expected ${cids.length}`, + ); + return; + } + // Two acceptable outcomes here: + // 1. The peer set happened to come up fast and we got a streamItem. + // 2. We got streamItemError(-32812 / -32811) — the warm-up path the (1) + // fix turned into per-CID errors. This is the case we care about. + const entry = collected[0]; + if (entry.kind === "ok") { + report("st-cold-open", true, "got streamItem immediately (peer set was already up)"); + } else if (entry.code === ERR_FAIL_BACKOFF || entry.code === ERR_FAIL_RETRY) { + report( + "st-cold-open", + true, + `warm-up path exercised: streamItemError(${entry.code}) + streamDone, no top-level error`, + ); + } else { + report( + "st-cold-open", + false, + `unexpected first-event code on cold start: ${entry.code}`, + ); + } +} + async function runStreamHappy(chain) { const cids = payloads.map((p) => p.cid); try { - const events = await streamCollect(chain, cids); + const events = await streamCollectWithRetry(chain, cids); const checkErr = await verifyStreamMap(events, payloads); report("st-happy", checkErr === null, checkErr ?? `${cids.length} events`); } catch (err) { @@ -175,7 +267,7 @@ async function runStreamMixed(chain) { } const cids = fullOnly.map((p) => p.cid); try { - const events = await streamCollect(chain, cids); + const events = await streamCollectWithRetry(chain, cids); const checkErr = await verifyStreamMap(events, fullOnly); report("st-mixed", checkErr === null, checkErr ?? `${cids.length} events`); } catch (err) { @@ -237,6 +329,33 @@ async function runStreamUnstreamSuppressesStreamDone(chain) { // ---------- subscription helper ---------- +/// Wraps `streamCollect`. If every per-CID outcome is a transient/retryable +/// error (`-32811 FailRetry` or `-32812 FailRetryBackoff`), back off and +/// re-subscribe. Otherwise return the result as-is. +/// +/// Per the revised spec, the smoldot "peers warming up" condition no longer +/// surfaces as a top-level subscription rejection — it arrives as N retryable +/// `streamItemError` events followed by `streamDone`. This wrapper turns that +/// into the same effective retry behaviour the old top-level retry gave us. +async function streamCollectWithRetry(chain, cids, totalBudgetMs = 180_000) { + const deadline = Date.now() + totalBudgetMs; + let attempt = 0; + while (true) { + attempt += 1; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`bitswap_unstable_stream timed out after ${totalBudgetMs}ms`); + } + const events = await streamCollect(chain, cids, remaining); + const allRetryable = [...events.values()].every( + (e) => isErrEntry(e) && (e.code === ERR_FAIL_BACKOFF || e.code === ERR_FAIL_RETRY), + ); + if (!allRetryable) return events; + const backoff = Math.min(5_000, 500 * 2 ** Math.min(attempt - 1, 3)); + await new Promise((r) => setTimeout(r, backoff)); + } +} + /// Subscribes via bitswap_unstable_stream, collects events until `streamDone`, /// then asserts that exactly `cids.length` per-CID events arrived before the /// done marker. Returns a Map. Arrival order diff --git a/e2e-tests/src/harness.rs b/e2e-tests/src/harness.rs new file mode 100644 index 0000000000..47c062dbcb --- /dev/null +++ b/e2e-tests/src/harness.rs @@ -0,0 +1,180 @@ +// Smoldot +// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Shared scaffolding for the bulletin-chain integration tests +//! (`bulletin_fetch.rs`, `bulletin_batch.rs`). Contains the snapshot URLs, +//! zombienet spawn helper, dev-mode invocation printer, and chain-spec +//! path resolver. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; +use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; + +use crate::bulletin; + +/// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. +pub const DB_SNAPSHOT_RELAY: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; +pub const DB_SNAPSHOT_BULLETIN_FULL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; +pub const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; + +/// Bundle of snapshot URLs passed to [`spawn_with_snapshots`]. Borrowed — +/// the caller owns the strings. +pub struct SnapshotUrls<'a> { + pub relay: &'a str, + pub bulletin_full: &'a str, + pub bulletin_partial: &'a str, +} + +/// Path to the bulletin chain spec shipped with the `smoldot-e2e-tests` crate. +pub fn bulletin_chain_spec() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("chain-specs/bulletin-westend-local-spec.json") +} + +/// Returns the value of `env_var` if set, or `default` otherwise. Useful for +/// pointing snapshot URLs at a locally-staged `.tgz` while iterating. +pub fn get_snapshot_url(default: &str, env_var: &str) -> String { + std::env::var(env_var).unwrap_or_else(|_| default.to_string()) +} + +/// Emit a copy-pasteable shell command equivalent to what `run_js_test` +/// would execute. Used in `DEV_MODE` so a developer can iterate on the JS +/// client against a long-lived zombienet without restarting the cargo +/// harness. `js_script` is the path relative to `e2e-tests/`, e.g. +/// `"js/bulletin_fetch.js"`. +pub fn print_dev_mode_invocation(env_pairs: &[(&str, &str)], js_script: &str) { + println!(); + println!("=== DEV_MODE: skipping JS test, run it manually with: ==="); + println!(); + println!("cd e2e-tests && \\"); + for (k, v) in env_pairs { + println!(" {}={} \\", k, shell_quote(v)); + } + println!(" node {js_script}"); + println!(); +} + +/// Single-quote a string for safe shell pasting. Embedded single quotes are +/// escaped via the standard `'\''` trick. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Spawns a zombienet network running a westend relay + the bulletin +/// parachain, restoring the supplied DB snapshots on the relay and on each +/// of the two collators. `extra_para_args` are appended verbatim to the +/// parachain's default arg list — used by `bulletin_batch` to crank up log +/// verbosity on the collator side. +pub async fn spawn_with_snapshots( + base_dir: &Path, + chain_spec: &Path, + snaps: SnapshotUrls<'_>, + extra_para_args: &[&str], +) -> Result> { + let chain_spec_str = chain_spec + .to_str() + .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? + .to_string(); + let base_dir_str = base_dir + .to_str() + .ok_or_else(|| anyhow!("non-utf8 base dir"))? + .to_string(); + let relay = snaps.relay.to_string(); + let bulletin_full = snaps.bulletin_full.to_string(); + let bulletin_partial = snaps.bulletin_partial.to_string(); + let extra_para_args: Vec = extra_para_args.iter().map(|s| s.to_string()).collect(); + + let cfg = NetworkConfigBuilder::new() + .with_relaychain(|rc| { + rc.with_chain(bulletin::RELAY_CHAIN) + .with_default_command(bulletin::RELAY_BINARY) + .with_validator(|n| { + n.with_name("alice") + .bootnode(true) + .with_db_snapshot(relay.as_str()) + }) + .with_validator(|n| { + n.with_name("bob") + .bootnode(true) + .with_db_snapshot(relay.as_str()) + }) + }) + .with_parachain(|p| { + // Skip the embedded relay client and proxy relay-chain queries + // through alice/bob's RPC. Zombienet expands the + // `{{ZOMBIE::ws_uri}}` templates at spawn time. This + // sidesteps the relay-side libp2p discovery quirks we hit with + // the embedded relay (see polkadot-sdk's + // `full_node_warp_sync/common.rs` for the same pattern on + // collators "four" / "five", and + // `bulletin_generate_snapshot::spawn_network` for the original + // investigation). + let mut args = vec!["--ipfs-server".into()]; + for arg in &extra_para_args { + args.push(arg.as_str().into()); + } + args.push(("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into()); + + p.with_id(bulletin::PARA_ID) + .with_chain_spec_path(chain_spec_str.as_str()) + .cumulus_based(true) + .with_default_args(args) + .with_collator(|c| { + c.with_name("collator-1") + .validator(true) + .bootnode(true) + .with_command(bulletin::PARA_BINARY) + .with_db_snapshot(bulletin_full.as_str()) + }) + .with_collator(|c| { + c.with_name("collator-2") + .validator(true) + .bootnode(true) + .with_command(bulletin::PARA_BINARY) + .with_db_snapshot(bulletin_partial.as_str()) + }) + }) + .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) + .build() + .map_err(|e| anyhow!("network config errors: {e:?}"))?; + + let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); + let network = spawn_fn(cfg).await?; + network.detach().await; + network.wait_until_is_up(180).await?; + Ok(network) +} + +/// Returns the raw chain-spec files zombienet emits for the relay and the +/// bulletin parachain. Smoldot consumes these directly. +pub fn chain_spec_paths(network: &Network) -> Result<(PathBuf, PathBuf)> { + let base_dir = PathBuf::from( + network + .base_dir() + .ok_or_else(|| anyhow!("network has no base_dir"))?, + ); + let relay_chain = network.relaychain().chain(); + let relay_path = base_dir.join(format!("{relay_chain}.json")); + let para = network + .parachain(bulletin::PARA_ID) + .ok_or_else(|| anyhow!("parachain {} not found", bulletin::PARA_ID))?; + let para_path = base_dir.join(format!("{}.json", para.unique_id())); + Ok((relay_path, para_path)) +} diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 49684423f5..0e30f75d9c 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -18,6 +18,7 @@ use std::path::{Path, PathBuf}; pub mod bulletin; +pub mod harness; pub mod network; pub mod snapshot; pub mod statement; diff --git a/e2e-tests/tests/bulletin_batch.rs b/e2e-tests/tests/bulletin_batch.rs index b4cc9d13d6..df931ce6f5 100644 --- a/e2e-tests/tests/bulletin_batch.rs +++ b/e2e-tests/tests/bulletin_batch.rs @@ -15,23 +15,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::path::{Path, PathBuf}; - use anyhow::{anyhow, Result}; use serde::Serialize; use smoldot_e2e_tests::{ - bulletin, ensure_js_deps_installed, ensure_smoldot_built, resolve_base_dir, run_js_test, + bulletin, ensure_js_deps_installed, ensure_smoldot_built, + harness::{ + bulletin_chain_spec, chain_spec_paths, get_snapshot_url, print_dev_mode_invocation, + spawn_with_snapshots, SnapshotUrls, DB_SNAPSHOT_BULLETIN_FULL, + DB_SNAPSHOT_BULLETIN_PARTIAL, DB_SNAPSHOT_RELAY, + }, + resolve_base_dir, run_js_test, }; -use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; - -/// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. Same -/// artifacts as `bulletin_fetch.rs`. -const DB_SNAPSHOT_RELAY: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_FULL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; /// Mirrors `light-base/src/bitswap_service.rs::MAX_CIDS_PER_REQUEST`. const MAX_CIDS: u32 = 64; @@ -70,9 +64,12 @@ async fn bulletin_batch() -> Result<()> { let network = spawn_with_snapshots( &base_dir, &chain_spec, - &relay, - &bulletin_full, - &bulletin_partial, + SnapshotUrls { + relay: &relay, + bulletin_full: &bulletin_full, + bulletin_partial: &bulletin_partial, + }, + &["-lsub-libp2p::bitswap=trace", "-lsync=debug"], ) .await?; @@ -111,7 +108,7 @@ async fn bulletin_batch() -> Result<()> { ]; if std::env::var("DEV_MODE").is_ok() { - print_dev_mode_invocation(&env_pairs); + print_dev_mode_invocation(&env_pairs, "js/bulletin_batch.js"); let secs: u64 = std::env::var("KEEP_ALIVE_SECS") .ok() .and_then(|s| s.parse().ok()) @@ -125,128 +122,3 @@ async fn bulletin_batch() -> Result<()> { .await .map_err(|e| anyhow!("JS test failed: {e}")) } - -/// Emit a copy-pasteable shell command equivalent to what `run_js_test` would -/// execute. Used in `DEV_MODE` so a developer can iterate on the JS client -/// against a long-lived zombienet without restarting the cargo harness. -fn print_dev_mode_invocation(env_pairs: &[(&str, &str)]) { - println!(); - println!("=== DEV_MODE: skipping JS test, run it manually with: ==="); - println!(); - println!("cd e2e-tests && \\"); - for (k, v) in env_pairs { - println!(" {}={} \\", k, shell_quote(v)); - } - println!(" node js/bulletin_batch.js"); - println!(); -} - -/// Single-quote a string for safe shell pasting. Embedded single quotes are -/// escaped via the standard `'\''` trick. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -/// Returns the GCS URL by default, or the contents of `env_var` if set -/// (so a developer can point at a local `.tgz` for iteration). -fn get_snapshot_url(default: &str, env_var: &str) -> String { - std::env::var(env_var).unwrap_or_else(|_| default.to_string()) -} - -fn bulletin_chain_spec() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("chain-specs/bulletin-westend-local-spec.json") -} - -async fn spawn_with_snapshots( - base_dir: &Path, - chain_spec: &Path, - relay_snap: &str, - bulletin_full_snap: &str, - bulletin_partial_snap: &str, -) -> Result> { - let chain_spec_str = chain_spec - .to_str() - .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? - .to_string(); - let base_dir_str = base_dir - .to_str() - .ok_or_else(|| anyhow!("non-utf8 base dir"))? - .to_string(); - let relay = relay_snap.to_string(); - let bulletin_full = bulletin_full_snap.to_string(); - let bulletin_partial = bulletin_partial_snap.to_string(); - - let cfg = NetworkConfigBuilder::new() - .with_relaychain(|rc| { - rc.with_chain(bulletin::RELAY_CHAIN) - .with_default_command(bulletin::RELAY_BINARY) - .with_validator(|n| { - n.with_name("alice") - .bootnode(true) - .with_db_snapshot(relay.as_str()) - }) - .with_validator(|n| { - n.with_name("bob") - .bootnode(true) - .with_db_snapshot(relay.as_str()) - }) - }) - .with_parachain(|p| { - p.with_id(bulletin::PARA_ID) - .with_chain_spec_path(chain_spec_str.as_str()) - .cumulus_based(true) - // Skip the embedded relay client and proxy relay-chain - // queries through alice/bob's RPC. Zombienet expands the - // `{{ZOMBIE::ws_uri}}` templates at spawn time. This - // sidesteps the relay-side libp2p discovery quirks we hit - // with the embedded relay (see polkadot-sdk's - // `full_node_warp_sync/common.rs` for the same pattern on - // collators "four" / "five"). - .with_default_args(vec![ - "--ipfs-server".into(), - "-lsub-libp2p::bitswap=trace".into(), - "-lsync=debug".into(), - ("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into(), - ]) - .with_collator(|c| { - c.with_name("collator-1") - .validator(true) - .bootnode(true) - .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_full.as_str()) - }) - .with_collator(|c| { - c.with_name("collator-2") - .validator(true) - .bootnode(true) - .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_partial.as_str()) - }) - }) - .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) - .build() - .map_err(|e| anyhow!("network config errors: {e:?}"))?; - - let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); - let network = spawn_fn(cfg).await?; - network.detach().await; - network.wait_until_is_up(180).await?; - Ok(network) -} - -/// Returns the raw chain-spec files zombienet emits for the relay and the -/// bulletin parachain. Smoldot consumes these directly. -fn chain_spec_paths(network: &Network) -> Result<(PathBuf, PathBuf)> { - let base_dir = PathBuf::from( - network - .base_dir() - .ok_or_else(|| anyhow!("network has no base_dir"))?, - ); - let relay_chain = network.relaychain().chain(); - let relay_path = base_dir.join(format!("{relay_chain}.json")); - let para = network - .parachain(bulletin::PARA_ID) - .ok_or_else(|| anyhow!("parachain {} not found", bulletin::PARA_ID))?; - let para_path = base_dir.join(format!("{}.json", para.unique_id())); - Ok((relay_path, para_path)) -} diff --git a/e2e-tests/tests/bulletin_fetch.rs b/e2e-tests/tests/bulletin_fetch.rs index 2c0e9c827e..e9ed15e570 100644 --- a/e2e-tests/tests/bulletin_fetch.rs +++ b/e2e-tests/tests/bulletin_fetch.rs @@ -15,22 +15,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::path::{Path, PathBuf}; - use anyhow::{anyhow, Result}; use serde::Serialize; use smoldot_e2e_tests::{ - bulletin, ensure_js_deps_installed, ensure_smoldot_built, resolve_base_dir, run_js_test, + bulletin, ensure_js_deps_installed, ensure_smoldot_built, + harness::{ + bulletin_chain_spec, chain_spec_paths, get_snapshot_url, print_dev_mode_invocation, + spawn_with_snapshots, SnapshotUrls, DB_SNAPSHOT_BULLETIN_FULL, + DB_SNAPSHOT_BULLETIN_PARTIAL, DB_SNAPSHOT_RELAY, + }, + resolve_base_dir, run_js_test, }; -use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; - -/// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. -const DB_SNAPSHOT_RELAY: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_FULL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; -const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; #[derive(Serialize)] struct PayloadJson { @@ -64,9 +59,12 @@ async fn bulletin_fetch() -> Result<()> { let network = spawn_with_snapshots( &base_dir, &chain_spec, - &relay, - &bulletin_full, - &bulletin_partial, + SnapshotUrls { + relay: &relay, + bulletin_full: &bulletin_full, + bulletin_partial: &bulletin_partial, + }, + &[], ) .await?; @@ -107,7 +105,7 @@ async fn bulletin_fetch() -> Result<()> { // run the JS client manually (the printed `node …` invocation has // every env var the test would have set). if std::env::var("DEV_MODE").is_ok() { - print_dev_mode_invocation(&env_pairs); + print_dev_mode_invocation(&env_pairs, "js/bulletin_fetch.js"); let secs: u64 = std::env::var("KEEP_ALIVE_SECS") .ok() .and_then(|s| s.parse().ok()) @@ -123,122 +121,3 @@ async fn bulletin_fetch() -> Result<()> { .await .map_err(|e| anyhow!("JS test failed: {e}")) } - -/// Emit a copy-pasteable shell command equivalent to what `run_js_test` would -/// execute. Used in `DEV_MODE` so a developer can iterate on the JS client -/// against a long-lived zombienet without restarting the cargo harness. -fn print_dev_mode_invocation(env_pairs: &[(&str, &str)]) { - println!(); - println!("=== DEV_MODE: skipping JS test, run it manually with: ==="); - println!(); - println!("cd e2e-tests && \\"); - for (k, v) in env_pairs { - println!(" {}={} \\", k, shell_quote(v)); - } - println!(" node js/bulletin_fetch.js"); - println!(); -} - -/// Single-quote a string for safe shell pasting. Embedded single quotes are -/// escaped via the standard `'\''` trick. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -/// Returns the GCS URL by default, or the contents of `env_var` if set -/// (so a developer can point at a local `.tgz` for iteration). -fn get_snapshot_url(default: &str, env_var: &str) -> String { - std::env::var(env_var).unwrap_or_else(|_| default.to_string()) -} - -fn bulletin_chain_spec() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("chain-specs/bulletin-westend-local-spec.json") -} - -async fn spawn_with_snapshots( - base_dir: &Path, - chain_spec: &Path, - relay_snap: &str, - bulletin_full_snap: &str, - bulletin_partial_snap: &str, -) -> Result> { - let chain_spec_str = chain_spec - .to_str() - .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? - .to_string(); - let base_dir_str = base_dir - .to_str() - .ok_or_else(|| anyhow!("non-utf8 base dir"))? - .to_string(); - let relay = relay_snap.to_string(); - let bulletin_full = bulletin_full_snap.to_string(); - let bulletin_partial = bulletin_partial_snap.to_string(); - - let cfg = NetworkConfigBuilder::new() - .with_relaychain(|rc| { - rc.with_chain(bulletin::RELAY_CHAIN) - .with_default_command(bulletin::RELAY_BINARY) - .with_validator(|n| { - n.with_name("alice") - .bootnode(true) - .with_db_snapshot(relay.as_str()) - }) - .with_validator(|n| { - n.with_name("bob") - .bootnode(true) - .with_db_snapshot(relay.as_str()) - }) - }) - .with_parachain(|p| { - p.with_id(bulletin::PARA_ID) - .with_chain_spec_path(chain_spec_str.as_str()) - .cumulus_based(true) - // See `bulletin_generate_snapshot::spawn_network` for why - // `--relay-chain-rpc-urls` is used here instead of an - // embedded relay client. - .with_default_args(vec![ - "--ipfs-server".into(), - ("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into(), - ]) - .with_collator(|c| { - c.with_name("collator-1") - .validator(true) - .bootnode(true) - .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_full.as_str()) - }) - .with_collator(|c| { - c.with_name("collator-2") - .validator(true) - .bootnode(true) - .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_partial.as_str()) - }) - }) - .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) - .build() - .map_err(|e| anyhow!("network config errors: {e:?}"))?; - - let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); - let network = spawn_fn(cfg).await?; - network.detach().await; - network.wait_until_is_up(180).await?; - Ok(network) -} - -/// Returns the raw chain-spec files zombienet emits for the relay and the -/// bulletin parachain. Smoldot consumes these directly. -fn chain_spec_paths(network: &Network) -> Result<(PathBuf, PathBuf)> { - let base_dir = PathBuf::from( - network - .base_dir() - .ok_or_else(|| anyhow!("network has no base_dir"))?, - ); - let relay_chain = network.relaychain().chain(); - let relay_path = base_dir.join(format!("{relay_chain}.json")); - let para = network - .parachain(bulletin::PARA_ID) - .ok_or_else(|| anyhow!("parachain {} not found", bulletin::PARA_ID))?; - let para_path = base_dir.join(format!("{}.json", para.unique_id())); - Ok((relay_path, para_path)) -} diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index 352c013601..bad93451aa 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -149,14 +149,6 @@ impl BitswapService { PARALLEL_REQUESTS, fnv::FnvBuildHasher::default(), ), - bitswap_peers: hashbrown::HashSet::with_capacity_and_hasher( - 4, - util::SipHasherBuild::new({ - let mut seed = [0; 16]; - platform.fill_random_bytes(&mut seed); - seed - }), - ), })); platform.spawn_task(log_target.clone().into(), { @@ -189,11 +181,12 @@ impl BitswapService { /// whose `events_rx` yields one `(cid_string, BlockResult)` event per input CID, in arrival /// order (the order in which each CID resolves), not input order. /// - /// Top-level errors: wholesale Have-broadcast failures (no peers / queue full) reject the - /// subscription with `-32812 FailRetryBackoff` so no events are emitted, per the - /// `bitswap_unstable_stream` spec ("whole-call failures cause the subscription to be - /// rejected"). Empty/duplicate/oversized inputs are rejected at the top level with - /// `-32802 EmptyCids` / `-32803 DuplicateCids` / `-32801 TooManyCids` respectively. + /// Top-level errors: only the batch-input validation cases — `-32801 TooManyCids`, + /// `-32802 EmptyCids`, `-32803 DuplicateCids` — are surfaced at the top level. Wholesale + /// Have-broadcast failures (no peers connected / network send queue full) are NOT top-level + /// errors per the `bitswap_unstable_stream` spec: the subscription opens normally and the + /// failure fans out as one `streamItemError(-32812 FailRetryBackoff)` per remaining valid + /// CID, followed by `streamDone`. /// /// Dropping the returned handle (explicit unsubscribe or client disconnect) cancels remaining /// work and emits a Bitswap Cancel wantlist to peers we previously contacted. @@ -547,10 +540,12 @@ enum HaveContext { /// Valid (slot_idx, cid) pairs. Invalid-CID slots are pre-resolved before the broadcast /// is queued and don't appear here. cids: Vec<(usize, Cid)>, - /// Channel for signalling broadcast outcome back to the caller of - /// [`BitswapService::bitswap_stream`]. Sent exactly - /// once when the broadcast resolves: `Ok(batch_id)` on success (peer set non-empty), or - /// `Err(_)` on wholesale failure so the caller surfaces a top-level JSON-RPC error. + /// Channel for signalling subscription readiness back to the caller of + /// [`BitswapService::bitswap_stream`]. Always sent as `Ok(batch_id)` exactly once when + /// the broadcast resolves: on success because the peer set is non-empty, or on wholesale + /// failure because the spec mandates the subscription open regardless (the failure is + /// reported via per-CID `streamItemError` events in `events_tx` instead). The `Result` + /// type is kept for structural symmetry with `bitswap_get`'s error path. ready_tx: oneshot::Sender>, }, } @@ -598,11 +593,6 @@ struct BackgroundTask { requests_by_cid: hashbrown::HashMap, util::SipHasherBuild>, /// In-flight batches. Each entry corresponds to a `bitswap_stream` call. batches: hashbrown::HashMap, - /// Set of peers with an open Bitswap substream — i.e. the targets of - /// `broadcast_bitswap_message`. Maintained from `BitswapEvent::BitswapConnected`/ - /// `BitswapDisconnected`. Used purely for diagnostic logging; the wire-level set lives in - /// the network service. - bitswap_peers: hashbrown::HashSet, } impl BackgroundTask { @@ -647,7 +637,7 @@ impl BackgroundTask { // Touch state in two passes so we can release the borrow before potentially calling // `cancel_batch` (which also borrows `self.batches`). - let (cid_str, should_cancel, should_finalize) = { + let (should_cancel, should_finalize) = { let Some(batch) = self.batches.get_mut(&batch_id) else { // The batch was already finalized or cancelled; the resolution is stale. return; @@ -660,7 +650,7 @@ impl BackgroundTask { let cid_str = batch.cid_strs[slot_idx].clone(); let mut should_cancel = false; - match batch.events_tx.try_send((cid_str.clone(), block_result)) { + match batch.events_tx.try_send((cid_str, block_result)) { Ok(()) => {} Err(async_channel::TrySendError::Closed(_)) => { // Receiver dropped — JSON-RPC client disconnected or unsubscribed. @@ -668,23 +658,22 @@ impl BackgroundTask { should_cancel = true; } Err(async_channel::TrySendError::Full(_)) => { - // Bounded channel saturated. With a `bounded(MAX_CIDS_PER_REQUEST)` - // channel and a JSON-RPC pump that drains it eagerly this should be - // unreachable in practice. Log and drop the event rather than - // blocking the service. + // Invariant: parse_and_dedup caps entries.len() at MAX_CIDS_PER_REQUEST + // and events_tx is bounded(MAX_CIDS_PER_REQUEST). At most `total` items + // are ever pushed for a batch, so the buffer cannot saturate. log!( &self.platform, - Warn, + Error, &self.log_target, - "stream events channel full, dropping per-CID event" + "BUG: stream events channel full — invariant violated" ); + unreachable!() } } let should_finalize = !should_cancel && batch.pending_count == 0; - (cid_str, should_cancel, should_finalize) + (should_cancel, should_finalize) }; - let _ = cid_str; if should_cancel { self.cancel_batch(batch_id); @@ -946,9 +935,15 @@ async fn background_task(mut task: BackgroundTask) { }; // Pre-resolve invalid-CID slots by pushing them straight into the events channel. + // try_send cannot fail here: events_tx is bounded(MAX_CIDS_PER_REQUEST) and the + // total events ever pushed for this batch is bounded by entries.len(); the + // receiver is also held by BitswapStreamHandle so the channel cannot be Closed. for (cid_str, err) in invalid_slots { batch.pending_count -= 1; - let _ = batch.events_tx.try_send((cid_str, BlockResult::Err(err))); + batch + .events_tx + .try_send((cid_str, BlockResult::Err(err))) + .unwrap_or_else(|_| unreachable!()); } log!( @@ -1085,19 +1080,46 @@ async fn background_task(mut task: BackgroundTask) { let broadcast_to = match result { Ok(peers) => peers, Err(err) => { + let err: BitswapGetError = err.into(); log!( &task.platform, Trace, &task.log_target, - "have broadcast failed (batch)", + "have broadcast failed (batch), fanning out per-CID errors", batch_id = batch_id.0, ?err ); - // Whole-broadcast failure: surface as a top-level JSON-RPC error to - // the caller. The batch holds no useful state yet (no per-slot - // Requests registered, `peers_for_cancel` empty), so drop it. - task.batches.remove(&batch_id); - let _ = ready_tx.send(Err(err.into())); + // Per spec (bitswap_unstable_stream.md): a wholesale broadcast + // failure is NOT a top-level subscription rejection. The + // subscription opens normally, one streamItemError per remaining + // valid CID is emitted, then streamDone. + // + // Invalid CIDs have already been pre-resolved at batch creation + // and their per-CID events are already in events_tx. We only need + // to fan out for the valid slots (those in `cids`). The per-CID + // code mapping for NoPeers / QueueFull → -32812 is locked by + // `to_block_result_err` and the + // `block_result_err_codes_match_top_level_codes` unit test. + if let Some(batch) = task.batches.remove(&batch_id) { + for (slot_idx, _cid) in cids { + let cid_str = batch.cid_strs[slot_idx].clone(); + match batch + .events_tx + .try_send((cid_str, BlockResult::Err(err.clone()))) + { + Ok(()) + | Err(async_channel::TrySendError::Closed(_)) => {} + Err(async_channel::TrySendError::Full(_)) => { + // Invariant: see deliver_batch_slot's Full arm. + unreachable!() + } + } + } + // Dropping events_tx closes the channel — the JSON-RPC pump + // sees the close and emits the spec-required streamDone. + drop(batch.events_tx); + } + let _ = ready_tx.send(Ok(batch_id)); continue; } }; @@ -1126,17 +1148,22 @@ async fn background_task(mut task: BackgroundTask) { } // Register one Request per valid slot, sharing the same Have peer set. + // SipHasher seed drawn once per batch — each slot's HashSet uses the same + // randomness. Reseeding per slot would only add cost: the seed is for + // intra-set collision distribution, not cross-set isolation, and the + // contents (broadcast peer IDs) aren't secrets. let timeout = task.platform.now() + Duration::from_secs(10); + let have_peers_seed = { + let mut seed = [0; 16]; + task.randomness.fill_bytes(&mut seed); + seed + }; for (slot_idx, cid) in cids { let request_id = task.allocate_request_id(); let have_peers = { let mut have_peers = hashbrown::HashSet::with_capacity_and_hasher( broadcast_to.len(), - util::SipHasherBuild::new({ - let mut seed = [0; 16]; - task.randomness.fill_bytes(&mut seed); - seed - }), + util::SipHasherBuild::new(have_peers_seed), ); have_peers.extend(broadcast_to.iter().cloned()); have_peers @@ -1167,30 +1194,6 @@ async fn background_task(mut task: BackgroundTask) { let _ = ready_tx.send(Ok(batch_id)); } }, - WakeUpReason::NetworkEvent(BitswapEvent::BitswapConnected { peer_id }) => { - let inserted = task.bitswap_peers.insert(peer_id.clone()); - log!( - &task.platform, - Trace, - &task.log_target, - "bitswap peer joined desired set", - peer_id, - new = inserted, - total = task.bitswap_peers.len() - ); - } - WakeUpReason::NetworkEvent(BitswapEvent::BitswapDisconnected { peer_id }) => { - let removed = task.bitswap_peers.remove(&peer_id); - log!( - &task.platform, - Trace, - &task.log_target, - "bitswap peer left desired set", - peer_id, - was_known = removed, - total = task.bitswap_peers.len() - ); - } WakeUpReason::NetworkEvent(BitswapEvent::BitswapMessage { peer_id, message }) => { let message = message.decode(); diff --git a/light-base/src/network_service.rs b/light-base/src/network_service.rs index 7dc2815c8e..a39fd7b971 100644 --- a/light-base/src/network_service.rs +++ b/light-base/src/network_service.rs @@ -259,6 +259,7 @@ impl NetworkService { event_senders: either::Left(Vec::new()), pending_new_subscriptions: Vec::new(), bitswap_event_pending_send: None, + bitswap_connected_peers: 0, bitswap_event_senders: either::Left(Vec::new()), pending_new_bitswap_subscriptions: Vec::new(), important_nodes: HashSet::with_capacity_and_hasher(16, Default::default()), @@ -819,11 +820,6 @@ pub enum BitswapEvent { peer_id: PeerId, message: service::EncodedBitswapMessage, }, - /// A peer has joined the set of peers we have an open Bitswap substream with. Subsequent - /// [`NetworkServiceChain::broadcast_bitswap_message`] calls will reach this peer. - BitswapConnected { peer_id: PeerId }, - /// A peer has left the Bitswap-desired set; subsequent broadcasts will skip it. - BitswapDisconnected { peer_id: PeerId }, } /// Error returned by [`NetworkServiceChain::blocks_request`]. @@ -1080,6 +1076,12 @@ struct BackgroundTask { /// Bitswap event about to be sent on the senders of [`BackgroundTask::bitswap_event_senders`]. bitswap_event_pending_send: Option, + /// Running count of peers with an open Bitswap substream. Maintained from + /// `service::Event::BitswapConnected` / `BitswapDisconnected`. Used for diagnostic logging + /// only; the authoritative per-peer state lives in + /// [`BackgroundTask::bitswap_peering_strategy`]. + bitswap_connected_peers: usize, + /// Sending events through the public API. /// /// Contains either senders, or a `Future` that is currently sending an event and will yield @@ -2601,16 +2603,15 @@ async fn background_task(mut task: BackgroundTask) { task.event_pending_send = Some((chain_id, Event::Disconnected { peer_id })); } WakeUpReason::NetworkEvent(service::Event::BitswapConnected { peer_id }) => { + task.bitswap_connected_peers = task.bitswap_connected_peers.saturating_add(1); log!( &task.platform, Debug, "network", "bitswap-open-success", - peer_id + peer_id, + total = task.bitswap_connected_peers ); - debug_assert!(task.bitswap_event_pending_send.is_none()); - task.bitswap_event_pending_send = - Some(BitswapEvent::BitswapConnected { peer_id }); } WakeUpReason::NetworkEvent(service::Event::BitswapOpenFailed { peer_id, error }) => { log!( @@ -2656,12 +2657,16 @@ async fn background_task(mut task: BackgroundTask) { Some(BitswapEvent::BitswapMessage { peer_id, message }); } WakeUpReason::NetworkEvent(service::Event::BitswapDisconnected { peer_id }) => { - log!(&task.platform, Debug, "network", "bitswap-closed", peer_id); - debug_assert!(task.bitswap_event_pending_send.is_none()); - task.bitswap_event_pending_send = - Some(BitswapEvent::BitswapDisconnected { - peer_id: peer_id.clone(), - }); + debug_assert!(task.bitswap_connected_peers > 0); + task.bitswap_connected_peers = task.bitswap_connected_peers.saturating_sub(1); + log!( + &task.platform, + Debug, + "network", + "bitswap-closed", + peer_id, + total = task.bitswap_connected_peers + ); let ban_duration = Duration::from_secs(10); if matches!( task.bitswap_peering_strategy From 53fc2c4eb5db7078b8916eef2a77cdc7838b244b Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Tue, 19 May 2026 15:10:45 +0200 Subject: [PATCH 10/15] review --- lib/src/libp2p/cid.rs | 6 ++++++ light-base/src/bitswap_service.rs | 22 +++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/src/libp2p/cid.rs b/lib/src/libp2p/cid.rs index a1b0360159..d2c56bd433 100644 --- a/lib/src/libp2p/cid.rs +++ b/lib/src/libp2p/cid.rs @@ -75,6 +75,12 @@ impl Cid { CidPrefix(self.0[..prefix_len].to_vec()) } + + /// 32-byte hash digest portion of this CID. The result borrows from `self`. + pub fn digest(&self) -> &[u8; 32] { + let decoded = decode_cid(&self.0).expect("Cid is always valid; qed"); + decoded.digest + } } impl FromStr for Cid { diff --git a/light-base/src/bitswap_service.rs b/light-base/src/bitswap_service.rs index bad93451aa..a79506e66d 100644 --- a/light-base/src/bitswap_service.rs +++ b/light-base/src/bitswap_service.rs @@ -429,7 +429,11 @@ pub fn parse_and_dedup( let mut seen_strings: hashbrown::HashSet = hashbrown::HashSet::with_capacity(cids.len()); - let mut seen_cids: hashbrown::HashSet = hashbrown::HashSet::with_capacity(cids.len()); + // Dedup by 32-byte content digest, matching the literal spec wording (and the polkadot-sdk + // reference impl). Two cosmetically different CID strings that decode to the same digest + // collide here, regardless of multicodec or multihash type. + let mut seen_digests: hashbrown::HashSet<[u8; 32]> = + hashbrown::HashSet::with_capacity(cids.len()); let mut out = Vec::with_capacity(cids.len()); for cid_str in cids { @@ -439,7 +443,7 @@ pub fn parse_and_dedup( let parsed = Cid::from_str(&cid_str); if let Ok(c) = &parsed { - if !seen_cids.insert(c.clone()) { + if !seen_digests.insert(*c.digest()) { return Err(BitswapGetError::DuplicateCids); } } @@ -704,15 +708,20 @@ impl BackgroundTask { drop(batch.events_tx); } - /// Cancel a batch: tear down all its still-pending slots, then send a Bitswap Cancel wantlist - /// to every peer we contacted on its behalf. Idempotent. + /// Cancel a batch: tear down its still-pending slots, then send a wire-level `Cancel(cid)` + /// for each CID this batch was the *last* local requester of. Idempotent. + /// + /// Only the last-reference case emits a Cancel — `requests_by_cid` is a multi-map and peers + /// track want-state per-CID, not per-requester, so an unconditional Cancel would starve any + /// concurrent batch or `bitswap_get` waiting on the same CID. fn cancel_batch(&mut self, batch_id: BatchId) { let Some(batch) = self.batches.remove(&batch_id) else { return; }; // Walk pending slots, evict their `RequestId`s from the global tracking maps, and gather - // the CIDs to be cancelled on the wire. + // the CIDs whose last local reference just went away (those are the ones safe to Cancel + // on the wire). let mut pending_cids: Vec = Vec::new(); for slot in batch.slots.into_iter() { let Some(request_id) = slot else { continue }; @@ -729,10 +738,9 @@ impl BackgroundTask { entry.get_mut().retain(|id| *id != request_id); if entry.get().is_empty() { entry.remove(); + pending_cids.push(request.cid); } } - - pending_cids.push(request.cid); } if pending_cids.is_empty() || batch.peers_for_cancel.is_empty() { From cbfd70c9eb5e6a03357b2f514bc23bbeaa0566f4 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Mon, 25 May 2026 12:52:25 +0200 Subject: [PATCH 11/15] snaphots updated --- e2e-tests/README.md | 13 +++++++++---- e2e-tests/src/harness.rs | 6 +++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 3aa57f1ed6..e6198b14e3 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -45,8 +45,9 @@ The `bulletin_fetch` test drives smoldot's `bitswap_unstable_get` JSON-RPC (plus an alias-coverage call to the legacy `bitswap_v1_get` name) against a polkadot-bulletin-chain network with pre-built DB snapshots. The URLs CI fetches from are hardcoded in -[`tests/bulletin_fetch.rs`](tests/bulletin_fetch.rs) and point at the -`zombienet-db-snaps` GCS bucket under `smoldot/bulletin_fetch/`. To +[`src/harness.rs`](src/harness.rs) (used by both `bulletin_fetch` and +`bulletin_batch`) and point at the `zombienet-db-snaps` GCS bucket under +`smoldot/bulletin_fetch/`. To refresh those snapshots, regenerate them with `bulletin_generate_snapshot` and upload via `gsutil` (only needed when the bulletin runtime or `bulletin::payloads()` changes). @@ -68,11 +69,15 @@ cargo test --manifest-path e2e-tests/Cargo.toml \ -- --ignored bulletin_generate_snapshot --nocapture # Tag the archives with the generation date and upload. Bump the date in -# the DB_SNAPSHOT_* constants in tests/bulletin_fetch.rs to match. +# the DB_SNAPSHOT_* constants in src/harness.rs to match. The +# `--canned-acl=publicRead` flag ensures anonymous HTTPS access works for +# CI (the bucket uses fine-grained per-object ACLs and doesn't default to +# public read). DATE=$(date +%F) cd e2e-tests/target/snapshots for f in relay bulletin-full bulletin-partial; do - gsutil cp "$f.tgz" "gs://zombienet-db-snaps/smoldot/bulletin_fetch/$f-$DATE.tgz" + gcloud storage cp --canned-acl=publicRead \ + "$f.tgz" "gs://zombienet-db-snaps/smoldot/bulletin_fetch/$f-$DATE.tgz" done ``` diff --git a/e2e-tests/src/harness.rs b/e2e-tests/src/harness.rs index 47c062dbcb..47e9d6f68a 100644 --- a/e2e-tests/src/harness.rs +++ b/e2e-tests/src/harness.rs @@ -29,11 +29,11 @@ use crate::bulletin; /// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. pub const DB_SNAPSHOT_RELAY: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-04.tgz"; + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-25.tgz"; pub const DB_SNAPSHOT_BULLETIN_FULL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-04.tgz"; + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-25.tgz"; pub const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-04.tgz"; + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-25.tgz"; /// Bundle of snapshot URLs passed to [`spawn_with_snapshots`]. Borrowed — /// the caller owns the strings. From 9d61b8ce8ead73cf66f7ae8ad587352edbcdebd5 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Mon, 25 May 2026 12:59:36 +0200 Subject: [PATCH 12/15] timeout fix --- e2e-tests/js/bulletin_fetch.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e-tests/js/bulletin_fetch.js b/e2e-tests/js/bulletin_fetch.js index b334c8a64e..7aca125d00 100644 --- a/e2e-tests/js/bulletin_fetch.js +++ b/e2e-tests/js/bulletin_fetch.js @@ -170,9 +170,9 @@ try { } catch (_) {} } -if (exitCode || process.exitCode) { - process.exit(exitCode || 1); -} +// Force exit so Node doesn't sit waiting for the smoldot client's underlying +// WebSocket / TCP handles to drain on their own (takes a few minutes). +process.exit(exitCode || process.exitCode || 0); // Retries the transient BlockRequestFailed/Timeout and NoPeers/QueueFull // errors smoldot returns while its peer set is warming up. `method` lets us From 1a1f68d8b8a1e44f09f95fbd4185c26408d979ca Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Fri, 29 May 2026 17:32:15 +0200 Subject: [PATCH 13/15] tests: snapshot migrated to zombienet-sdk --- e2e-tests/Cargo.lock | 881 +++++++++++------- e2e-tests/Cargo.toml | 4 +- e2e-tests/src/bulletin.rs | 29 - e2e-tests/src/harness.rs | 165 +++- e2e-tests/src/snapshot.rs | 4 +- e2e-tests/tests/bulletin_batch.rs | 21 +- e2e-tests/tests/bulletin_fetch.rs | 27 +- e2e-tests/tests/bulletin_generate_snapshot.rs | 387 ++------ 8 files changed, 756 insertions(+), 762 deletions(-) diff --git a/e2e-tests/Cargo.lock b/e2e-tests/Cargo.lock index d0529fb11e..55bb4f2521 100644 --- a/e2e-tests/Cargo.lock +++ b/e2e-tests/Cargo.lock @@ -12,22 +12,13 @@ dependencies = [ "regex", ] -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli 0.31.1", -] - [[package]] name = "addr2line" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli 0.32.3", + "gimli", ] [[package]] @@ -54,7 +45,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -452,9 +443,9 @@ dependencies = [ [[package]] name = "ark-vrf" -version = "0.1.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d63e9780640021b74d02b32895d8cec1b4abe8e5547b560a6bda6b14b78c6da" +checksum = "f69f34cb5a7d5d4627792c7a6343508becc19deab59a87ef73367cc112dfd02c" dependencies = [ "ark-bls12-381 0.5.0", "ark-ec 0.5.0", @@ -463,7 +454,7 @@ dependencies = [ "ark-serialize 0.5.0", "ark-std 0.5.0", "digest 0.10.7", - "rand_chacha 0.3.1", + "generic-array", "sha2 0.10.9", "w3f-ring-proof", "zeroize", @@ -776,11 +767,11 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line 0.25.1", + "addr2line", "cfg-if", "libc", "miniz_oxide", - "object 0.37.3", + "object", "rustc-demangle", "windows-link", ] @@ -854,7 +845,9 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.14.1", + "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", "serde", "unicode-normalization", ] @@ -874,28 +867,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bitcoin-internals" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" - [[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" -[[package]] -name = "bitcoin_hashes" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" -dependencies = [ - "bitcoin-internals", - "hex-conservative 0.1.2", -] - [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -903,7 +880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.2", + "hex-conservative", ] [[package]] @@ -960,31 +937,6 @@ dependencies = [ "constant_time_eq 0.4.2", ] -[[package]] -name = "blake2s_simd" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee29928bad1e3f94c9d1528da29e07a1d3d04817ae8332de1e8b846c8439f4b3" -dependencies = [ - "arrayref", - "arrayvec 0.7.6", - "constant_time_eq 0.4.2", -] - -[[package]] -name = "blake3" -version = "1.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" -dependencies = [ - "arrayref", - "arrayvec 0.7.6", - "cc", - "cfg-if", - "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", -] - [[package]] name = "block-buffer" version = "0.9.0" @@ -1103,7 +1055,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -1133,19 +1085,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cid" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b68e3193982cd54187d71afdb2a271ad4cf8af157858e9cb911b91321de143" -dependencies = [ - "core2", - "multibase", - "multihash 0.17.0", - "serde", - "unsigned-varint 0.7.2", -] - [[package]] name = "cid" version = "0.11.2" @@ -1253,6 +1192,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1315,47 +1263,38 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "cranelift-assembler-x64" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae7b60ec3fd7162427d3b3801520a1908bef7c035b52983cd3ca11b8e7deb51" +checksum = "de2be1bdbf929c2a2242cbbe15d6583c56f1cc723c6c8452d0179362de28c9d5" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6511c200fed36452697b4b6b161eae57d917a2044e6333b1c1389ed63ccadeee" +checksum = "9a0336914de11298290783a95a9a7154b894da601659eb5f8f8bc62d1bea98f8" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7086a645aa58bae979312f64e3029ac760ac1b577f5cd2417844842a2ca07f" +checksum = "fb972cba51a52c1b2a329fec993b911e4d1f9cfab3795811a319b6746c28e014" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5225b4dec45f3f3dbf383f12560fac5ce8d780f399893607e21406e12e77f491" +checksum = "642c920666bfed9aebca39d8c6e7cb76f09314cc7a4074b1db5edcccdde771b9" dependencies = [ "serde", "serde_derive", @@ -1363,9 +1302,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "858fb3331e53492a95979378d6df5208dd1d0d315f19c052be8115f4efc888e0" +checksum = "0e1231caaeee3d2363d9b2dba9d6c1f7ff835b8ede6612fba98120af73df44bd" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1376,7 +1315,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli 0.31.1", + "gimli", "hashbrown 0.15.5", "log", "pulley-interpreter", @@ -1390,36 +1329,37 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456715b9d5f12398f156d5081096e7b5d039f01b9ecc49790a011c8e43e65b5f" +checksum = "eb83e89be8b413e4f7a4215a02d5c5f3e6f04b1060f5db293dd1007b2871dcf5" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", "cranelift-srcgen", + "heck", "pulley-interpreter", ] [[package]] name = "cranelift-codegen-shared" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0306041099499833f167a0ddb707e1e54100f1a84eab5631bc3dad249708f482" +checksum = "9d14f8068a98f0a85ffa63dc5fe73cb486a955adbe7311465d13cde54c656d5f" [[package]] name = "cranelift-control" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1672945e1f9afc2297f49c92623f5eabc64398e2cb0d824f8f72a2db2df5af23" +checksum = "c070aee9312b9736028e99b58d45e1099683386082af38529d5e2ce8c76648f3" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa3cd55eb5f3825b9ae5de1530887907360a6334caccdc124c52f6d75246c98a" +checksum = "f2d619bb3d14251e96dc9b6a846d6955d78048a168cc3876eb2b789b855c1c22" dependencies = [ "cranelift-bitset", "serde", @@ -1428,9 +1368,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "781f9905f8139b8de22987b66b522b416fe63eb76d823f0b3a8c02c8fd9500c7" +checksum = "2350fcff24d78be5e4201e1eeb4b306e474b9f21e452722b21ffc4f773e8d49a" dependencies = [ "cranelift-codegen", "log", @@ -1440,15 +1380,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a05337a2b02c3df00b4dd9a263a027a07b3dff49f61f7da3b5d195c21eaa633d" +checksum = "8bdc2b14d7491c53c2989b967b4c07511374733abbc01a895fb01ea31e97bfc8" [[package]] name = "cranelift-native" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eee7a496dd66380082c9c5b6f2d5fa149cec0ec383feec5caf079ca2b3671c2" +checksum = "e98dbe1326d0001a17b3b0675e3adafcfbd0e7f25f1f845a2f1bb9ce3029f359" dependencies = [ "cranelift-codegen", "libc", @@ -1457,9 +1397,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.122.0" +version = "0.123.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b530783809a55cb68d070e0de60cfbb3db0dc94c8850dd5725411422bedcf6bb" +checksum = "36d7af563cd300c8a1e4e64387929b40e32867112143f0a0e1ce90f977ce4a41" [[package]] name = "crc32fast" @@ -1589,7 +1529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -1704,6 +1644,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -1820,7 +1769,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -2059,6 +2008,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enum-display" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02058bb25d8d0605829af88230427dd5cd50661590bd2b09d1baf7c64c417f24" +dependencies = [ + "enum-display-macro", +] + +[[package]] +name = "enum-display-macro" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4be2cf2fe7b971b1865febbacd4d8df544aa6bd377cca011a6d69dcf4c60d94" +dependencies = [ + "convert_case 0.6.0", + "quote", + "syn 1.0.109", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -2505,6 +2474,28 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags 2.11.0", + "debugid", + "fxhash", + "serde", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2578,21 +2569,15 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" dependencies = [ "fallible-iterator", "indexmap", "stable_deref_trait", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "glob-match" version = "0.2.1" @@ -2722,12 +2707,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hex-conservative" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" - [[package]] name = "hex-conservative" version = "0.2.2" @@ -3434,6 +3413,26 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jam-codec" version = "0.1.1" @@ -3666,7 +3665,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -4318,19 +4317,21 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litep2p" -version = "0.10.0" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c666ef772d123a7643323ad4979c30dd825e9c68ec1aa5b387a6c9a9871c11ea" +checksum = "cbf3924cf539a761465543592b34c4198d60db2cda16594769edd43451e5ab41" dependencies = [ "async-trait", "bs58", "bytes", - "cid 0.11.2", + "cid", "ed25519-dalek", + "enum-display", "futures", "futures-timer", "hickory-resolver 0.25.2", "indexmap", + "ip_network", "libc", "mockall", "multiaddr 0.17.1", @@ -4339,8 +4340,9 @@ dependencies = [ "parking_lot 0.12.5", "pin-project", "prost 0.13.5", - "prost-build", + "prost-build 0.14.3", "rand 0.8.6", + "ring 0.17.14", "serde", "sha2 0.10.9", "simple-dns", @@ -4617,8 +4619,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835d6ff01d610179fbce3de1694d007e500bf33a7f29689838941d6bf783ae40" dependencies = [ "blake2b_simd", - "blake2s_simd", - "blake3", "core2", "digest 0.10.7", "multihash-derive", @@ -4877,15 +4877,25 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.36.7" +name = "num_enum" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ - "crc32fast", - "hashbrown 0.15.5", - "indexmap", - "memchr", + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -4894,6 +4904,9 @@ version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", "memchr", ] @@ -4996,19 +5009,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "parity-bip39" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e69bf016dc406eff7d53a7d3f7cf1c2e72c82b9088aac1118591e36dd2cd3e9" -dependencies = [ - "bitcoin_hashes 0.13.0", - "rand 0.8.6", - "rand_core 0.6.4", - "serde", - "unicode-normalization", -] - [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -5210,6 +5210,23 @@ dependencies = [ "indexmap", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "picosimd" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8cf1ae70818c6476eb2da0ac8f3f55ecdea41a7aa16824ea6efc4a31cccf41" + [[package]] name = "pin-project" version = "1.1.11" @@ -5277,12 +5294,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "polkavm" -version = "0.26.0" +version = "0.33.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa028f713d0613f0f08b8b3367402cb859218854f6b96fcbe39a501862894d6f" +checksum = "d90ece49c68657299648e20469517e22c6ec38321307bb14a69c27a33927a491" dependencies = [ "libc", "log", + "picosimd", "polkavm-assembler", "polkavm-common", "polkavm-linux-raw", @@ -5290,37 +5308,38 @@ dependencies = [ [[package]] name = "polkavm-assembler" -version = "0.26.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4859a29e1f4ad64610c4bc2bfc40bb9a535068a034933a5b56b5e7a0febf105a" +checksum = "00010f7924647dbf6f468d85d0fcfe4c3587cfb4557ef13f3682dbece8fd57f0" dependencies = [ "log", ] [[package]] name = "polkavm-common" -version = "0.26.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a5794b695626ba70d29e66e3f4f4835767452a6723f3a0bc20884b07088fe8" +checksum = "9e44a9487003cf5b9fc4462bbcf105cc37d5d9b18b40edf5ed50dd20ed1fdb27" dependencies = [ "log", + "picosimd", "polkavm-assembler", ] [[package]] name = "polkavm-derive" -version = "0.26.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95282a203ae1f6828a04ff334145c3f6dc718bba6d3959805d273358b45eab93" +checksum = "3ef966bc8518a66ce12d4edb73f2c4094cae72bb23258bc9e9b2802cc9d6cd79" dependencies = [ "polkavm-derive-impl-macro", ] [[package]] name = "polkavm-derive-impl" -version = "0.26.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6069dc7995cde6e612b868a02ce48b54397c6d2582bd1b97b63aabbe962cd779" +checksum = "f0c2166ad71dd7f51dcdd0d91b70d408a8b3610fa6e94d8202dd4b7185607181" dependencies = [ "polkavm-common", "proc-macro2", @@ -5330,9 +5349,9 @@ dependencies = [ [[package]] name = "polkavm-derive-impl-macro" -version = "0.26.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581d34cafec741dc5ffafbb341933c205b6457f3d76257a9d99fb56687219c91" +checksum = "c7ac2ac8ec5b938e249fa97b5ebb1e6fa47000c81a25eba6bf0f13edb8d430e4" dependencies = [ "polkavm-derive-impl", "syn 2.0.117", @@ -5340,9 +5359,9 @@ dependencies = [ [[package]] name = "polkavm-linux-raw" -version = "0.26.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28919f542476f4158cc71e6c072b1051f38f4b514253594ac3ad80e3c0211fc8" +checksum = "42063d4a1c52e569f7794df27dab3e19c9fa8946184023257bdbb43eb4a94be5" [[package]] name = "polling" @@ -5364,7 +5383,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", "opaque-debug", "universal-hash", ] @@ -5376,7 +5395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "opaque-debug", "universal-hash", ] @@ -5624,6 +5643,16 @@ dependencies = [ "prost-derive 0.13.5", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive 0.14.3", +] + [[package]] name = "prost-build" version = "0.13.5" @@ -5635,10 +5664,29 @@ dependencies = [ "log", "multimap", "once_cell", - "petgraph", + "petgraph 0.7.1", "prettyplease", "prost 0.13.5", - "prost-types", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph 0.8.3", + "prettyplease", + "prost 0.14.3", + "prost-types 0.14.3", "regex", "syn 2.0.117", "tempfile", @@ -5670,6 +5718,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-types" version = "0.13.5" @@ -5679,11 +5740,20 @@ dependencies = [ "prost 0.13.5", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + [[package]] name = "pulley-interpreter" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c4319786b16c1a6a38ee04788d32c669b61ba4b69da2162c868c18be99c1b" +checksum = "329f575a931601f71fbcb3b31d32d16273da5ba7f532fc10be2e432e710b02de" dependencies = [ "cranelift-bitset", "log", @@ -5693,9 +5763,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938543690519c20c3a480d20a8efcc8e69abeb44093ab1df4e7c1f81f26c677a" +checksum = "8bccae89ed67a40989e780105fab43e6c71a077b9fc8ae4c805ff5f73d2a79c8" dependencies = [ "proc-macro2", "quote", @@ -6318,9 +6388,9 @@ dependencies = [ [[package]] name = "sc-allocator" -version = "34.0.0" +version = "37.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01733879c581defda6f49ff4076033c675d7127bfab6fd0bd0e6cf10696d0564" +checksum = "c0d3f1e8a1ed997d5fa6f86c005fb47c10b64184c68e611b1f310e3d039e44c7" dependencies = [ "log", "sp-core", @@ -6330,9 +6400,9 @@ dependencies = [ [[package]] name = "sc-chain-spec" -version = "46.0.0" +version = "50.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962282c6d40861610814dac5159a99a5b4251d89269bb4e828ff766956f1833" +checksum = "124e67d20414d12f73640ba2586a5d44d7646ec58ea41d2f1dfd5d98ff56b1aa" dependencies = [ "array-bytes", "docify", @@ -6369,9 +6439,9 @@ dependencies = [ [[package]] name = "sc-client-api" -version = "42.0.0" +version = "46.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6de05f4f496f2261981b7d293ff4f5ba804bdfa924bf0cd1b48252a8a7051913" +checksum = "f9736f1d5bf62be76f7a2f629be4443c4a14896f7eefc9987a68a0b4fd023538" dependencies = [ "fnv", "futures", @@ -6396,9 +6466,9 @@ dependencies = [ [[package]] name = "sc-executor" -version = "0.45.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90511c3ab41be12af1ce88753de8993e0b8a5fc0453c0f48069ace06eb4a99d" +checksum = "cef04379da853c1c14df0ebe8a394ddc6c042ff6e164d285db137edccf6eca14" dependencies = [ "parity-scale-codec", "parking_lot 0.12.5", @@ -6420,9 +6490,9 @@ dependencies = [ [[package]] name = "sc-executor-common" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81bc77ad5df120ef1ffab877d71539aae878e916c0946a067e8d6b0508a7ea5" +checksum = "f06c9a86b3475f8182b4e69b75c2b8563e6c71398ddce742f326ba1c2a136efd" dependencies = [ "polkavm", "sc-allocator", @@ -6434,21 +6504,22 @@ dependencies = [ [[package]] name = "sc-executor-polkavm" -version = "0.38.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8976f310f09818f42ec389e727c91c0a75a8c363a29e3ac97d56492d83fc144f" +checksum = "10bb402bec955b45c3a7882c8f3de3764dff9ce7705a4936ee7deb7c8ce01377" dependencies = [ "log", "polkavm", "sc-executor-common", + "sp-runtime-interface", "sp-wasm-interface", ] [[package]] name = "sc-executor-wasmtime" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8f9b2a912f0cb435d2b8e33d67010e494b07f5c6e497d8756a8c21abad199e" +checksum = "fe2b72c4c65ae32931f36979dd0ca27055f24a556f0308083b2101c1fddf09d1" dependencies = [ "anyhow", "log", @@ -6457,22 +6528,23 @@ dependencies = [ "sc-allocator", "sc-executor-common", "sp-runtime-interface", + "sp-virtualization", "sp-wasm-interface", "wasmtime", ] [[package]] name = "sc-network" -version = "0.53.1" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71350e21abf285249978eaedcca8b9a368118b8903571a27cb9501dd0e6072c8" +checksum = "fc0ac2bd45f377dec09ed4ff99aebaec8cad8c042dedd796766b0e4f1bf99ff9" dependencies = [ "array-bytes", "async-channel 1.9.0", "async-trait", "asynchronous-codec 0.6.2", "bytes", - "cid 0.9.0", + "cid", "either", "fnv", "futures", @@ -6488,7 +6560,7 @@ dependencies = [ "partial_sort", "pin-project", "prost 0.12.6", - "prost-build", + "prost-build 0.13.5", "rand 0.8.6", "sc-client-api", "sc-network-common", @@ -6514,9 +6586,9 @@ dependencies = [ [[package]] name = "sc-network-common" -version = "0.51.0" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7419cbc4a107ec4f430b263408db1527f2ce5fd6ed136c279f22057d3d202965" +checksum = "0143967106a7ad82e11667c4522519d78e204147369e7d0b9dc2d345ca52c855" dependencies = [ "bitflags 1.3.2", "parity-scale-codec", @@ -6525,9 +6597,9 @@ dependencies = [ [[package]] name = "sc-network-types" -version = "0.19.0" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79011e96426caf5240631af9c4d0f841a752ee2be606d782406745e76b1123dd" +checksum = "7dad571c070fc5d7665b443a4b5f7c4644270159d92c19e9cc43ecb490c266fb" dependencies = [ "bs58", "bytes", @@ -6547,9 +6619,9 @@ dependencies = [ [[package]] name = "sc-telemetry" -version = "30.0.1" +version = "31.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36b14e2a6c9b1542c6e94aa463f6365f7e4ce75ccd8abe4a55717235d2275477" +checksum = "7d8e894e2929df91a5b42cd98533673cd4846fd7a1ebd6fab2076a7bd24f6726" dependencies = [ "chrono", "futures", @@ -6567,9 +6639,9 @@ dependencies = [ [[package]] name = "sc-transaction-pool-api" -version = "42.0.0" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04c8e6a886fd4563be1cfe487af2f11280ea797298b8d831e1ee5a273cc17d" +checksum = "1cd995c2b649e05d26f911326ab001ef62ed624caa213b5589a20f81f3ebf79c" dependencies = [ "async-trait", "futures", @@ -6580,14 +6652,15 @@ dependencies = [ "sp-blockchain", "sp-core", "sp-runtime", + "strum", "thiserror 1.0.69", ] [[package]] name = "sc-utils" -version = "20.1.0" +version = "21.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "347bdbd75c82b548636988199a24c402c942aade1f092026503a636e4d52fbca" +checksum = "00454290e81f64bad3377a099543449bc4c6c909e1ade78ca307e20007c9b12c" dependencies = [ "async-channel 1.9.0", "futures", @@ -6830,7 +6903,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "bitcoin_hashes 0.14.1", + "bitcoin_hashes", "rand 0.8.6", "secp256k1-sys 0.10.1", ] @@ -7055,7 +7128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.10.7", ] @@ -7067,7 +7140,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.9.0", "opaque-debug", ] @@ -7079,7 +7152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.10.7", ] @@ -7136,9 +7209,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple-dns" -version = "0.9.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ "bitflags 2.11.0", ] @@ -7299,14 +7372,12 @@ dependencies = [ "anyhow", "ed25519-dalek", "env_logger", - "flate2", "hex", "log", "serde", "serde_json", "sha2 0.10.9", "smoldot 1.1.1", - "tar", "tempfile", "tokio", "zombienet-sdk", @@ -7402,9 +7473,9 @@ dependencies = [ [[package]] name = "sp-api" -version = "39.0.0" +version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc9635cc2a860eff0b2d8b05ba217085c8292f41793f9cadfd931dc54976c00" +checksum = "8977f83b4672cff5fff35a89a042e925d02f2a7dbfd63b7f4d12c80a2581f16b" dependencies = [ "docify", "hash-db", @@ -7425,9 +7496,9 @@ dependencies = [ [[package]] name = "sp-api-proc-macro" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d832cd107113d389340dc80a632330fe7ed7d20f3db50aeeb6abe40e23b6f4e" +checksum = "32c95e3a2cadf60408a228d175d10bbacacdd2b1cd4a15115bc97e8d667ab0eb" dependencies = [ "Inflector", "blake2", @@ -7440,9 +7511,9 @@ dependencies = [ [[package]] name = "sp-application-crypto" -version = "43.0.0" +version = "46.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6067f30cf3fb9270471cf24a65d73b33330f32573abab2d97196f83fc076de0" +checksum = "fb47c341b52ca668e68929c982c9f4698b467012e1760b71f5be6cf8cf0fb49a" dependencies = [ "parity-scale-codec", "scale-info", @@ -7468,9 +7539,9 @@ dependencies = [ [[package]] name = "sp-blockchain" -version = "42.0.0" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "082c634447671551ea1cb8f1182d1b8a7109f7316a044b974ad9e663935f56c8" +checksum = "282bf400700c98decd130b61afd95a15c6d3064ad0366871bb400720ae203455" dependencies = [ "futures", "parity-scale-codec", @@ -7488,27 +7559,31 @@ dependencies = [ [[package]] name = "sp-consensus" -version = "0.45.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cdbfa4f10a4c0aac84f9fa3327386988aea983c503b9ec7f0bd8aa8c34c3f01" +checksum = "e0151233b7598dbacd8a29982444e26240ea32cdf28370a844f3fa25d727187a" dependencies = [ "async-trait", "futures", "log", + "sp-api", + "sp-externalities", "sp-inherents", "sp-runtime", "sp-state-machine", + "sp-trie", "thiserror 1.0.69", ] [[package]] name = "sp-core" -version = "38.1.0" +version = "41.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707602208776d0e19d4269bb3f68c5306cacbdfabbb2e4d8d499af7b907bb0a3" +checksum = "571e6da2db8de83fa2a6f9fab2a86befdcd004c90cfa19c641b072d3f322a60d" dependencies = [ "ark-vrf", "array-bytes", + "bip39", "bitflags 1.3.2", "blake2", "bounded-collections", @@ -7524,7 +7599,6 @@ dependencies = [ "libsecp256k1", "log", "merlin", - "parity-bip39", "parity-scale-codec", "parking_lot 0.12.5", "paste", @@ -7586,10 +7660,11 @@ dependencies = [ [[package]] name = "sp-debug-derive" -version = "14.0.0" +version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d09fa0a5f7299fb81ee25ae3853d26200f7a348148aed6de76be905c007dbe" +checksum = "d61809bf52be994e4d0a0485bb18a78509ed185e1418736c1ff9011bd1528999" dependencies = [ + "proc-macro-warning", "proc-macro2", "quote", "syn 2.0.117", @@ -7597,9 +7672,9 @@ dependencies = [ [[package]] name = "sp-externalities" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cbf059dce180a8bf8b6c8b08b6290fa3d1c7f069a60f1df038ab5dd5fc0ba6" +checksum = "ccab635fdf03594e8bec6213457df38389ad54ac15ce12fea22e9a88ba039c2f" dependencies = [ "environmental", "parity-scale-codec", @@ -7608,9 +7683,9 @@ dependencies = [ [[package]] name = "sp-genesis-builder" -version = "0.20.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f929edd118b6332b016e0e5a3eb962b8568b14eee024f818685f8ea5f80d53" +checksum = "2cee8b7ba45f4b2d8a5720248e024489daf58a9b795ac3e24ac9a697938b0254" dependencies = [ "parity-scale-codec", "scale-info", @@ -7621,9 +7696,9 @@ dependencies = [ [[package]] name = "sp-inherents" -version = "39.0.0" +version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2522693c705c1245ef8dbdbcf09d7cc6b139f0184d5e0a46856c546666b494d7" +checksum = "4a298a87658bf4420b179fb15ee45ed885d77b53dc9627b1e858c16e1705417f" dependencies = [ "async-trait", "impl-trait-for-tuples", @@ -7635,9 +7710,9 @@ dependencies = [ [[package]] name = "sp-io" -version = "43.0.0" +version = "46.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2059e3b338c0174e8dc9e144cc7e612165ca4c960c3a23c6c99c29ef34768f" +checksum = "cc8f8415b05edacb24866790c540e29ff967250faffda9996709c2d48f0f6ca4" dependencies = [ "bytes", "docify", @@ -7662,9 +7737,9 @@ dependencies = [ [[package]] name = "sp-keystore" -version = "0.44.1" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a5c0b829014afc22e992be2c198f2677592db43267fc218e9f3207dbbfb6fbb" +checksum = "74fead48d67f4374903b78624bca25fbf15f95a79d8dbefdecd407bb01544c3d" dependencies = [ "parity-scale-codec", "parking_lot 0.12.5", @@ -7684,10 +7759,11 @@ dependencies = [ [[package]] name = "sp-metadata-ir" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb04cf79ea9c576c8cf3f493a9e6e432a81b181e64e9bdcc485b0004505fb5a" +checksum = "fb285f8d35b3fbb553e31c66182c25985717526f21920a08d23fdca88e3bdd1e" dependencies = [ + "derive-where", "frame-metadata", "parity-scale-codec", "scale-info", @@ -7705,11 +7781,12 @@ dependencies = [ [[package]] name = "sp-runtime" -version = "44.0.0" +version = "47.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee57bb77e94c26306501426ac82aca401bb80ee2279ecdba148f68e76cf58247" +checksum = "00f3c9491215e9d640c05ea651723f6834699f1088aa53043dc9badeb6177935" dependencies = [ "binary-merkle-tree", + "bytes", "docify", "either", "hash256-std-hasher", @@ -7729,15 +7806,16 @@ dependencies = [ "sp-std", "sp-trie", "sp-weights", + "strum", "tracing", "tuplex", ] [[package]] name = "sp-runtime-interface" -version = "32.0.0" +version = "35.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efdc2bc2adbfb9b4396ae07c7d94db20414d2351608e29e1f44e4f643b387c70" +checksum = "1263162adb7ffe06c762467495dca536794667abe24567c7a00d5edd2b3e727c" dependencies = [ "bytes", "impl-trait-for-tuples", @@ -7754,9 +7832,9 @@ dependencies = [ [[package]] name = "sp-runtime-interface-proc-macro" -version = "20.0.0" +version = "21.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04178084ae654b3924934a56943ee73e3562db4d277e948393561b08c3b5b5fe" +checksum = "c7d9650b5186fd32bf5198adfe08d8fecc76f2ebe8a8927f28aaf2a537d75b2e" dependencies = [ "Inflector", "expander", @@ -7768,9 +7846,9 @@ dependencies = [ [[package]] name = "sp-state-machine" -version = "0.48.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "042677239cca40eb6a0d70e0b220f5693516f59853c2d678de471a79652cd16e" +checksum = "3789d2895faa6e5d0a88c34c19b8ca7f3fdd9823729bde83743fd13668aaf08a" dependencies = [ "hash-db", "log", @@ -7795,9 +7873,9 @@ checksum = "12f8ee986414b0a9ad741776762f4083cd3a5128449b982a3919c4df36874834" [[package]] name = "sp-storage" -version = "22.0.0" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3b70ca340e41cde9d2e069d354508a6e37a6573d66f7cc38f11549002f64ec" +checksum = "e5e5c9fb0016b49765f89d23e4869ee616e1317de8f75b59babb721ccc27d2af" dependencies = [ "impl-serde", "parity-scale-codec", @@ -7821,9 +7899,9 @@ dependencies = [ [[package]] name = "sp-trie" -version = "41.1.1" +version = "44.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e34c2336d82297f453340adb1188ec0592abcd862df1f7027994b8e1e5fc139" +checksum = "1f4d4aa0fec3c80f98762cae84ad7dda64c16dcd37f1b97c34c9d3402023119f" dependencies = [ "ahash", "foldhash 0.1.5", @@ -7847,15 +7925,16 @@ dependencies = [ [[package]] name = "sp-version" -version = "42.0.0" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633ea19da3ec057d449af667099072daa4e99900984f304b96f4c2ee15aeecc7" +checksum = "45a409b2e4cf147fc24ef158b3c6659738e7dca70838350309a3dc0cd59a81bc" dependencies = [ "impl-serde", "parity-scale-codec", "parity-wasm", "scale-info", "serde", + "sp-core", "sp-crypto-hashing-proc-macro", "sp-runtime", "sp-std", @@ -7876,11 +7955,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sp-virtualization" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd5b06a4cc35605887e9681cda701554d159e1cb757ec97b12a4c496fc96ed2" +dependencies = [ + "log", + "num_enum", + "parity-scale-codec", + "polkavm", + "sp-runtime-interface", + "sp-std", + "sp-wasm-interface", + "strum", +] + [[package]] name = "sp-wasm-interface" -version = "24.0.0" +version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd177d0658f3df0492f28bd39d665133a7868db5aa66c8642c949b6265430719" +checksum = "7d29d5c802dbb12ce5efe240f1284842d285249b87530bb7729ddae2647c6b18" dependencies = [ "anyhow", "impl-trait-for-tuples", @@ -7891,9 +7986,9 @@ dependencies = [ [[package]] name = "sp-weights" -version = "33.2.0" +version = "34.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c34d353fdc6469da8fae9248ffc1f34faaf04bec8cabc43fd77681dcbc8517" +checksum = "364f93051b3b243c6221e51965e2bef465605f1de169a2ebd126fba2576dc3d9" dependencies = [ "bounded-collections", "parity-scale-codec", @@ -7959,11 +8054,33 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "substrate-bip39" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca58ffd742f693dc13d69bdbb2e642ae239e0053f6aab3b104252892f856700a" +checksum = "d93affb0135879b1b67cbcf6370a256e1772f9eaaece3899ec20966d67ad0492" dependencies = [ "hmac 0.12.1", "pbkdf2", @@ -8785,9 +8902,9 @@ dependencies = [ [[package]] name = "trie-db" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c0670ab45a6b7002c7df369fee950a27cf29ae0474343fd3a15aa15f691e7a6" +checksum = "a7795f2df2ef744e4ffb2125f09325e60a21d305cc3ecece0adeef03f7a9e560" dependencies = [ "hash-db", "log", @@ -9082,9 +9199,9 @@ dependencies = [ [[package]] name = "w3f-pcs" -version = "0.0.2" +version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe7a8d5c914b69392ab3b267f679a2e546fe29afaddce47981772ac71bd02e1" +checksum = "3ea1046a1deb6d26c34ba2d1f1bab4222d695d126502ee765f80b021753cb674" dependencies = [ "ark-ec 0.5.0", "ark-ff 0.5.0", @@ -9096,9 +9213,9 @@ dependencies = [ [[package]] name = "w3f-plonk-common" -version = "0.0.2" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aca389e494fe08c5c108b512e2328309036ee1c0bc7bdfdb743fef54d448c8c" +checksum = "30408cda37b81bd7257319942584c794c5784d00d749757bc664656749a1472a" dependencies = [ "ark-ec 0.5.0", "ark-ff 0.5.0", @@ -9112,9 +9229,9 @@ dependencies = [ [[package]] name = "w3f-ring-proof" -version = "0.0.2" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a639379402ad51504575dbd258740383291ac8147d3b15859bdf1ea48c677de" +checksum = "0cbfc4cb881a934e6f33c25927bf955d0cb18e52b94528bbc5fa28dddedb4cd1" dependencies = [ "ark-ec 0.5.0", "ark-ff 0.5.0", @@ -9230,12 +9347,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.235.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bc393c395cb621367ff02d854179882b9a351b4e0c93d1397e6090b53a5c2a" +checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" dependencies = [ "leb128fmt", - "wasmparser 0.235.0", + "wasmparser 0.236.1", ] [[package]] @@ -9336,9 +9453,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.235.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "161296c618fa2d63f6ed5fffd1112937e803cb9ec71b32b01a76321555660917" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", @@ -9361,35 +9478,37 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.235.0" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75aa8e9076de6b9544e6dab4badada518cca0bf4966d35b131bbd057aed8fa0a" +checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.235.0", + "wasmparser 0.236.1", ] [[package]] name = "wasmtime" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe976922a16af3b0d67172c473d1fd4f1aa5d0af9c8ba6538c741f3af686f4" +checksum = "507d213104e83a7519d91af444a8b19c04281f2eef162d448ee7a894ac1c827d" dependencies = [ - "addr2line 0.24.2", + "addr2line", "anyhow", "bitflags 2.11.0", "bumpalo", "cc", "cfg-if", - "gimli 0.31.1", + "fxprof-processed-profile", + "gimli", "hashbrown 0.15.5", "indexmap", + "ittapi", "libc", "log", "mach2", "memfd", - "object 0.36.7", + "object", "once_cell", "postcard", "pulley-interpreter", @@ -9397,62 +9516,64 @@ dependencies = [ "rustix", "serde", "serde_derive", + "serde_json", "smallvec", "target-lexicon", - "wasmparser 0.235.0", + "wasmparser 0.236.1", "wasmtime-environ", "wasmtime-internal-asm-macros", "wasmtime-internal-cache", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", "wasmtime-internal-jit-icache-coherence", "wasmtime-internal-math", "wasmtime-internal-slab", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", "wasmtime-internal-winch", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "wasmtime-environ" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44b6264a78d806924abbc76bbc75eac24976bc83bdfb938e5074ae551242436f" +checksum = "9784b325c3b85562ac6d7f81c8348c42af1f137d98dd4fc6631860e4e68bb655" dependencies = [ "anyhow", "cpp_demangle", "cranelift-bitset", "cranelift-entity", - "gimli 0.31.1", + "gimli", "indexmap", "log", - "object 0.36.7", + "object", "postcard", "rustc-demangle", "serde", "serde_derive", "smallvec", "target-lexicon", - "wasm-encoder 0.235.0", - "wasmparser 0.235.0", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", "wasmprinter", ] [[package]] name = "wasmtime-internal-asm-macros" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6775a9b516559716e5710e95a8014ca0adcc81e5bf4d3ad7899d89ae40094d1a" +checksum = "bcaa9336cd5ba934ba734dfdfe35f5245c3c74b4e34f9af9e114fad892d81b3d" dependencies = [ "cfg-if", ] [[package]] name = "wasmtime-internal-cache" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e33ad4bd120f3b1c77d6d0dcdce0de8239555495befcda89393a40ba5e324" +checksum = "e297746bc001fd919f8f9bb3d28ed1a01fb1ff10a5ccd9720e649b5807bf2b68" dependencies = [ "anyhow", "base64 0.22.1", @@ -9464,15 +9585,15 @@ dependencies = [ "serde_derive", "sha2 0.10.9", "toml 0.8.23", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "zstd 0.13.3", ] [[package]] name = "wasmtime-internal-cranelift" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec9ad7565e6a8de7cb95484e230ff689db74a4a085219e0da0cbd637a29c01c" +checksum = "e7d938ae501275f44e7e5532ae4bb720542b429357014d33842e128c46fb9b54" dependencies = [ "anyhow", "cfg-if", @@ -9481,15 +9602,15 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli 0.31.1", + "gimli", "itertools 0.14.0", "log", - "object 0.36.7", + "object", "pulley-interpreter", "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.235.0", + "wasmparser 0.236.1", "wasmtime-environ", "wasmtime-internal-math", "wasmtime-internal-versioned-export-macros", @@ -9497,9 +9618,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b636ff8b220ebaf29dfe3b23770e4b2bad317b9683e3bf7345e162387385b39" +checksum = "c1443b0914ff848ee7920e0f232368168e2819b739c54f3c352f0559b6164343" dependencies = [ "anyhow", "cc", @@ -9508,54 +9629,66 @@ dependencies = [ "rustix", "wasmtime-internal-asm-macros", "wasmtime-internal-versioned-export-macros", - "windows-sys 0.59.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861d6f2a1652e95ca10b02552934b3bd460d7416b285fe10d7ca8c0a2b90dc3e" +dependencies = [ + "cc", + "object", + "rustix", + "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4417e06b7f80baff87d9770852c757a39b8d7f11d78b2620ca992b8725f16f50" +checksum = "b1caeb3140c46319fecf09d93dc38a373eb535fd478e401a9fb2ac2da30fe5f6" dependencies = [ "anyhow", "cfg-if", "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "wasmtime-internal-math" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7710d5c4ecdaa772927fd11e5dc30a9a62d1fc8fe933e11ad5576ad596ab6612" +checksum = "7c631615929951a4076aae64da7d6cad88668d292f19672606392c24ae9c5a00" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ab22fabe1eed27ab01fd47cd89deacf43ad222ed7fd169ba6f4dd1fbddc53b" +checksum = "7b28104d57b5bdb5d8facb3a8418463ec6c2cb40bb4adf9833b727ebf6a254eb" [[package]] name = "wasmtime-internal-unwinder" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "307708f302f5dcf19c1bbbfb3d9f2cbc837dd18088a7988747b043a46ba38ecc" +checksum = "0dd89f2db7377869aeaf66b71f56def8df54b9482e4f4e5533ccec2505f5c691" dependencies = [ "anyhow", "cfg-if", "cranelift-codegen", "log", - "object 0.36.7", + "object", ] [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "342b0466f92b7217a4de9e114175fedee1907028567d2548bcd42f71a8b5b016" +checksum = "a9cdb9c2e3965ee15629d067203cb800e9822664d04335dadc6fe1788d4fc335" dependencies = [ "proc-macro2", "quote", @@ -9564,16 +9697,16 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2012e7384c25b91aab2f1b6a1e1cbab9d0f199bbea06cc873597a3f047f05730" +checksum = "53f693c8db710f20b927bcee025acd345acf599d055b63f122613d52f5553a5f" dependencies = [ "anyhow", "cranelift-codegen", - "gimli 0.31.1", - "object 0.36.7", + "gimli", + "object", "target-lexicon", - "wasmparser 0.235.0", + "wasmparser 0.236.1", "wasmtime-environ", "wasmtime-internal-cranelift", "winch-codegen", @@ -9662,19 +9795,19 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "35.0.0" +version = "36.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839a334ef7c62d8368dbd427e767a6fbb1ba08cc12ecce19cbb666c10613b585" +checksum = "4332c8656af179fb8fc3ae5114c738c29399ee97b638d431725201c17f99294e" dependencies = [ "anyhow", "cranelift-assembler-x64", "cranelift-codegen", - "gimli 0.31.1", + "gimli", "regalloc2", "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.235.0", + "wasmparser 0.236.1", "wasmtime-environ", "wasmtime-internal-cranelift", "wasmtime-internal-math", @@ -9819,6 +9952,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -9852,13 +9994,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -9880,6 +10039,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -9892,6 +10057,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -9904,12 +10075,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -9922,6 +10105,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -9934,6 +10123,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -9946,6 +10141,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -9958,6 +10159,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -10321,9 +10528,9 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zombienet-configuration" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e5067eaad40f8c3b230a930911f58bb97a3163cc884fd4a5e0e771b29364f54" +checksum = "b2c269216bae9a4628882f272d00d5e44be68091e7b468e75dc70fe7f3e838e4" dependencies = [ "anyhow", "lazy_static", @@ -10342,9 +10549,9 @@ dependencies = [ [[package]] name = "zombienet-orchestrator" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6a37c3e697c3261dc417ac952d508ddf431127684acd104d968d710d932847f" +checksum = "dd5fc4ca778d2bf804f6ea1983f248957452f55fbf06bbb47f31a2bfbbac516b" dependencies = [ "anyhow", "array-bytes", @@ -10380,9 +10587,9 @@ dependencies = [ [[package]] name = "zombienet-prom-metrics-parser" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6b356bab187eda90677147bea9d10f888764aa8c3b8f5ed9d1cbb97a2a88884" +checksum = "aa851f64b120ee4f5fc13f453f18bb3eb984225e21a91a480f713ecf23c409a0" dependencies = [ "pest", "pest_derive", @@ -10391,9 +10598,9 @@ dependencies = [ [[package]] name = "zombienet-provider" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ab643816ac3e436ddbfad115680b8fbc205ad9a6a082d5d3e3048ea4466e91" +checksum = "85a96858350524976ca391e88d637c51a1698de26a2a9de815b79ab99e53911f" dependencies = [ "anyhow", "async-trait", @@ -10423,15 +10630,23 @@ dependencies = [ [[package]] name = "zombienet-sdk" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e96f53151093a724f08622e1af75961e040da938333c34b4c8ace2465e50a4" +checksum = "fac89f216adc651f1bc3c064b53b2a907efee05acb1a6b272dce3d1419a9d9bb" dependencies = [ + "anyhow", "async-trait", + "chrono", + "flate2", "futures", + "hex", "lazy_static", + "serde", + "serde_json", + "sha2 0.10.9", "subxt", "subxt-signer", + "tar", "tokio", "zombienet-configuration", "zombienet-orchestrator", @@ -10441,9 +10656,9 @@ dependencies = [ [[package]] name = "zombienet-support" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee54081f7f967970c7c3829bad176fea8b40668c839f277fe79605572cb264a" +checksum = "e00dc9084a78b16d2c3d0bd1c79a34998466ce7f522187ec1c7880b250af4011" dependencies = [ "anyhow", "async-trait", diff --git a/e2e-tests/Cargo.toml b/e2e-tests/Cargo.toml index dac7769d61..3b57064719 100644 --- a/e2e-tests/Cargo.toml +++ b/e2e-tests/Cargo.toml @@ -8,14 +8,12 @@ publish = false anyhow = "1.0.81" ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] } env_logger = "0.11.2" -flate2 = "1.0" hex = { version = "0.4.3", default-features = false } log = "0.4.22" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.132" sha2 = { version = "0.10", default-features = false } smoldot = { path = "../lib", default-features = false } -tar = "0.4" tempfile = "3.8.1" tokio = { version = "1.45", features = ["rt-multi-thread", "macros", "process", "time", "fs", "io-util"] } -zombienet-sdk = "0.4" +zombienet-sdk = "0.4.13" diff --git a/e2e-tests/src/bulletin.rs b/e2e-tests/src/bulletin.rs index a0057210af..d37dd26eaa 100644 --- a/e2e-tests/src/bulletin.rs +++ b/e2e-tests/src/bulletin.rs @@ -15,7 +15,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use smoldot::libp2p::cid::{Cid, CidPrefix, MultihashType}; @@ -180,31 +179,3 @@ fn write_leb128(out: &mut Vec, mut value: u64) { out.push(byte | 0x80); } } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManifestPayload { - pub label: String, - pub cid: String, - pub sha256: String, - pub size: u64, - pub on_partial: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArchiveChecksums { - pub relay_sha256: String, - pub bulletin_full_sha256: String, - pub bulletin_partial_sha256: String, -} - -/// Manifest emitted alongside the snapshots by the generator. Bumping -/// `schema_version` is a breaking change. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BulletinManifest { - pub schema_version: u32, - pub snapshot_height: u64, - pub bulletin_release_tag: String, - pub polkadot_release_tag: String, - pub payloads: Vec, - pub archives: ArchiveChecksums, -} diff --git a/e2e-tests/src/harness.rs b/e2e-tests/src/harness.rs index 47e9d6f68a..e8a5658446 100644 --- a/e2e-tests/src/harness.rs +++ b/e2e-tests/src/harness.rs @@ -22,25 +22,30 @@ use std::path::{Path, PathBuf}; -use anyhow::{anyhow, Result}; -use zombienet_sdk::{LocalFileSystem, Network, NetworkConfigBuilder}; +use anyhow::{anyhow, bail, Context, Result}; +use zombienet_sdk::{LocalFileSystem, Network, NetworkConfig, NetworkConfigBuilder}; use crate::bulletin; -/// GCS URLs for the snapshots produced by `bulletin_generate_snapshot`. -pub const DB_SNAPSHOT_RELAY: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/relay-2026-05-25.tgz"; -pub const DB_SNAPSHOT_BULLETIN_FULL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-full-2026-05-25.tgz"; -pub const DB_SNAPSHOT_BULLETIN_PARTIAL: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bulletin-partial-2026-05-25.tgz"; - -/// Bundle of snapshot URLs passed to [`spawn_with_snapshots`]. Borrowed — -/// the caller owns the strings. -pub struct SnapshotUrls<'a> { - pub relay: &'a str, - pub bulletin_full: &'a str, - pub bulletin_partial: &'a str, +/// GCS URL of the snapshot bundle produced by `bulletin_generate_snapshot` +/// (a single `bundle.tar.gz` packed by the zombienet-sdk `BundleBuilder`). +pub const DB_SNAPSHOT_BUNDLE: &str = + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bundle-2026-05-25.tar.gz"; + +/// SHA256 of the published bundle. Empty means not yet pinned — in that case +/// the resolver requires [`BUNDLE_OVERRIDE_ENV`] to point at a local bundle. +pub const DB_SNAPSHOT_BUNDLE_SHA256: &str = ""; + +/// Point this at a locally-generated `bundle.tar.gz` (e.g. `./tmp/snapshots/ +/// bundle.tar.gz` produced by `./g`) to skip the download and run against it. +pub const BUNDLE_OVERRIDE_ENV: &str = "DB_SNAPSHOT_BUNDLE_OVERRIDE"; + +/// Per-node DB archives unpacked from the bundle, ready to hand to +/// `with_db_snapshot`. Owned local paths under the network base dir. +pub struct BulletinSnapshots { + pub relay: PathBuf, + pub bulletin_full: PathBuf, + pub bulletin_partial: PathBuf, } /// Path to the bulletin chain spec shipped with the `smoldot-e2e-tests` crate. @@ -48,10 +53,65 @@ pub fn bulletin_chain_spec() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("chain-specs/bulletin-westend-local-spec.json") } -/// Returns the value of `env_var` if set, or `default` otherwise. Useful for -/// pointing snapshot URLs at a locally-staged `.tgz` while iterating. -pub fn get_snapshot_url(default: &str, env_var: &str) -> String { - std::env::var(env_var).unwrap_or_else(|_| default.to_string()) +/// Resolves the snapshot bundle (local override or download + SHA256-verify), +/// unpacks it under `{base_dir}/bulletin-snapshots/`, and returns the inner +/// per-node archive paths. +/// +/// Set [`BUNDLE_OVERRIDE_ENV`] to a local `bundle.tar.gz` to iterate without a +/// download. Otherwise the bundle is fetched from [`DB_SNAPSHOT_BUNDLE`] into +/// `~/.cache/smoldot-e2e/bulletin/` and verified against +/// [`DB_SNAPSHOT_BUNDLE_SHA256`]. +pub fn resolve_bundle(base_dir: &Path) -> Result { + let bundle_path = if let Ok(p) = std::env::var(BUNDLE_OVERRIDE_ENV) { + let p = PathBuf::from(p); + if !p.is_file() { + bail!("{BUNDLE_OVERRIDE_ENV}: {} is not a file", p.display()); + } + log::info!("bulletin snapshot: using local override {}", p.display()); + p + } else { + if DB_SNAPSHOT_BUNDLE_SHA256.is_empty() { + return Err(anyhow!( + "DB_SNAPSHOT_BUNDLE_SHA256 not pinned (placeholder); set \ + {BUNDLE_OVERRIDE_ENV} to a local bundle.tar.gz" + )); + } + let cached = bundle_cache_path(DB_SNAPSHOT_BUNDLE_SHA256)?; + if !cached.is_file() { + log::info!("bulletin snapshot: downloading {DB_SNAPSHOT_BUNDLE}"); + crate::snapshot::download(DB_SNAPSHOT_BUNDLE, &cached)?; + } + crate::snapshot::verify_sha256(&cached, DB_SNAPSHOT_BUNDLE_SHA256)?; + cached + }; + + // Unpack fresh each run so stale inner archives can't leak across runs. + let extract_dir = base_dir.join("bulletin-snapshots"); + let _ = std::fs::remove_dir_all(&extract_dir); + zombienet_sdk::snapshot::untar_bundle(&bundle_path, &extract_dir) + .with_context(|| format!("untar bundle {}", bundle_path.display()))?; + + let snaps = BulletinSnapshots { + relay: extract_dir.join("relay.tgz"), + bulletin_full: extract_dir.join("bulletin-full.tgz"), + bulletin_partial: extract_dir.join("bulletin-partial.tgz"), + }; + for p in [&snaps.relay, &snaps.bulletin_full, &snaps.bulletin_partial] { + if !p.is_file() { + bail!("bundle is missing expected archive {}", p.display()); + } + } + Ok(snaps) +} + +fn bundle_cache_path(sha256: &str) -> Result { + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .ok_or_else(|| anyhow!("neither XDG_CACHE_HOME nor HOME is set"))?; + let dir = base.join("smoldot-e2e").join("bulletin"); + std::fs::create_dir_all(&dir)?; + Ok(dir.join(format!("bundle-{sha256}.tar.gz"))) } /// Emit a copy-pasteable shell command equivalent to what `run_js_test` @@ -77,17 +137,22 @@ fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -/// Spawns a zombienet network running a westend relay + the bulletin -/// parachain, restoring the supplied DB snapshots on the relay and on each -/// of the two collators. `extra_para_args` are appended verbatim to the -/// parachain's default arg list — used by `bulletin_batch` to crank up log -/// verbosity on the collator side. -pub async fn spawn_with_snapshots( +/// Builds the bulletin network config (westend relay + bulletin parachain, +/// para id 2487). +/// +/// - `snaps == None`: fresh from genesis, to *generate* the snapshots +/// (`bulletin_generate_snapshot`). +/// - `snaps == Some`: restore those snapshots to *run the tests* — relay on +/// both validators, `bulletin-full` on collator-1, `bulletin-partial` on +/// collator-2. +/// +/// `extra_para_args` are appended to the parachain's default args. +pub fn bulletin_network_config( base_dir: &Path, chain_spec: &Path, - snaps: SnapshotUrls<'_>, + snaps: Option<&BulletinSnapshots>, extra_para_args: &[&str], -) -> Result> { +) -> Result { let chain_spec_str = chain_spec .to_str() .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? @@ -96,36 +161,27 @@ pub async fn spawn_with_snapshots( .to_str() .ok_or_else(|| anyhow!("non-utf8 base dir"))? .to_string(); - let relay = snaps.relay.to_string(); - let bulletin_full = snaps.bulletin_full.to_string(); - let bulletin_partial = snaps.bulletin_partial.to_string(); + let relay = snaps.map(|s| s.relay.clone()); + let bulletin_full = snaps.map(|s| s.bulletin_full.clone()); + let bulletin_partial = snaps.map(|s| s.bulletin_partial.clone()); let extra_para_args: Vec = extra_para_args.iter().map(|s| s.to_string()).collect(); - let cfg = NetworkConfigBuilder::new() - .with_relaychain(|rc| { + NetworkConfigBuilder::new() + .with_relaychain(move |rc| { rc.with_chain(bulletin::RELAY_CHAIN) .with_default_command(bulletin::RELAY_BINARY) .with_validator(|n| { n.with_name("alice") .bootnode(true) - .with_db_snapshot(relay.as_str()) + .with_optional_db_snapshot(relay.clone()) }) .with_validator(|n| { n.with_name("bob") .bootnode(true) - .with_db_snapshot(relay.as_str()) + .with_optional_db_snapshot(relay.clone()) }) }) - .with_parachain(|p| { - // Skip the embedded relay client and proxy relay-chain queries - // through alice/bob's RPC. Zombienet expands the - // `{{ZOMBIE::ws_uri}}` templates at spawn time. This - // sidesteps the relay-side libp2p discovery quirks we hit with - // the embedded relay (see polkadot-sdk's - // `full_node_warp_sync/common.rs` for the same pattern on - // collators "four" / "five", and - // `bulletin_generate_snapshot::spawn_network` for the original - // investigation). + .with_parachain(move |p| { let mut args = vec!["--ipfs-server".into()]; for arg in &extra_para_args { args.push(arg.as_str().into()); @@ -141,20 +197,31 @@ pub async fn spawn_with_snapshots( .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_full.as_str()) + .with_optional_db_snapshot(bulletin_full.clone()) }) .with_collator(|c| { c.with_name("collator-2") .validator(true) .bootnode(true) .with_command(bulletin::PARA_BINARY) - .with_db_snapshot(bulletin_partial.as_str()) + .with_optional_db_snapshot(bulletin_partial.clone()) }) }) - .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) + .with_global_settings(move |g| g.with_base_dir(base_dir_str.as_str())) .build() - .map_err(|e| anyhow!("network config errors: {e:?}"))?; + .map_err(|e| anyhow!("network config errors: {e:?}")) +} +/// Spawns the bulletin network restoring the supplied DB snapshots, detaches +/// it, and waits until it is up. Thin wrapper over [`bulletin_network_config`] +/// with `Some(snaps)`. +pub async fn spawn_with_snapshots( + base_dir: &Path, + chain_spec: &Path, + snaps: &BulletinSnapshots, + extra_para_args: &[&str], +) -> Result> { + let cfg = bulletin_network_config(base_dir, chain_spec, Some(snaps), extra_para_args)?; let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); let network = spawn_fn(cfg).await?; network.detach().await; diff --git a/e2e-tests/src/snapshot.rs b/e2e-tests/src/snapshot.rs index 87c1513fdf..924def01e8 100644 --- a/e2e-tests/src/snapshot.rs +++ b/e2e-tests/src/snapshot.rs @@ -148,7 +148,7 @@ fn cache_dir() -> Result { Ok(dir) } -fn download(url: &str, dst: &std::path::Path) -> Result<(), anyhow::Error> { +pub(crate) fn download(url: &str, dst: &std::path::Path) -> Result<(), anyhow::Error> { let tmp = dst.with_extension("partial"); let status = std::process::Command::new("curl") .arg("-fL") @@ -182,7 +182,7 @@ fn extract_tarball(tarball: &std::path::Path, dst: &std::path::Path) -> Result<( Ok(()) } -fn verify_sha256(path: &std::path::Path, expected: &str) -> Result<(), anyhow::Error> { +pub(crate) fn verify_sha256(path: &std::path::Path, expected: &str) -> Result<(), anyhow::Error> { let output = std::process::Command::new("sha256sum").arg(path).output()?; if !output.status.success() { return Err(anyhow!( diff --git a/e2e-tests/tests/bulletin_batch.rs b/e2e-tests/tests/bulletin_batch.rs index df931ce6f5..6226873e41 100644 --- a/e2e-tests/tests/bulletin_batch.rs +++ b/e2e-tests/tests/bulletin_batch.rs @@ -20,9 +20,8 @@ use serde::Serialize; use smoldot_e2e_tests::{ bulletin, ensure_js_deps_installed, ensure_smoldot_built, harness::{ - bulletin_chain_spec, chain_spec_paths, get_snapshot_url, print_dev_mode_invocation, - spawn_with_snapshots, SnapshotUrls, DB_SNAPSHOT_BULLETIN_FULL, - DB_SNAPSHOT_BULLETIN_PARTIAL, DB_SNAPSHOT_RELAY, + bulletin_chain_spec, chain_spec_paths, print_dev_mode_invocation, resolve_bundle, + spawn_with_snapshots, }, resolve_base_dir, run_js_test, }; @@ -51,24 +50,12 @@ async fn bulletin_batch() -> Result<()> { let chain_spec = bulletin_chain_spec(); let base_dir = resolve_base_dir()?; - let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); - let bulletin_full = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_FULL, - "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", - ); - let bulletin_partial = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_PARTIAL, - "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", - ); + let snaps = resolve_bundle(&base_dir)?; let network = spawn_with_snapshots( &base_dir, &chain_spec, - SnapshotUrls { - relay: &relay, - bulletin_full: &bulletin_full, - bulletin_partial: &bulletin_partial, - }, + &snaps, &["-lsub-libp2p::bitswap=trace", "-lsync=debug"], ) .await?; diff --git a/e2e-tests/tests/bulletin_fetch.rs b/e2e-tests/tests/bulletin_fetch.rs index e9ed15e570..979d39b459 100644 --- a/e2e-tests/tests/bulletin_fetch.rs +++ b/e2e-tests/tests/bulletin_fetch.rs @@ -20,9 +20,8 @@ use serde::Serialize; use smoldot_e2e_tests::{ bulletin, ensure_js_deps_installed, ensure_smoldot_built, harness::{ - bulletin_chain_spec, chain_spec_paths, get_snapshot_url, print_dev_mode_invocation, - spawn_with_snapshots, SnapshotUrls, DB_SNAPSHOT_BULLETIN_FULL, - DB_SNAPSHOT_BULLETIN_PARTIAL, DB_SNAPSHOT_RELAY, + bulletin_chain_spec, chain_spec_paths, print_dev_mode_invocation, resolve_bundle, + spawn_with_snapshots, }, resolve_base_dir, run_js_test, }; @@ -46,27 +45,9 @@ async fn bulletin_fetch() -> Result<()> { let chain_spec = bulletin_chain_spec(); let base_dir = resolve_base_dir()?; - let relay = get_snapshot_url(DB_SNAPSHOT_RELAY, "DB_SNAPSHOT_RELAY_OVERRIDE"); - let bulletin_full = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_FULL, - "DB_SNAPSHOT_BULLETIN_FULL_OVERRIDE", - ); - let bulletin_partial = get_snapshot_url( - DB_SNAPSHOT_BULLETIN_PARTIAL, - "DB_SNAPSHOT_BULLETIN_PARTIAL_OVERRIDE", - ); + let snaps = resolve_bundle(&base_dir)?; - let network = spawn_with_snapshots( - &base_dir, - &chain_spec, - SnapshotUrls { - relay: &relay, - bulletin_full: &bulletin_full, - bulletin_partial: &bulletin_partial, - }, - &[], - ) - .await?; + let network = spawn_with_snapshots(&base_dir, &chain_spec, &snaps, &[]).await?; let (relay_spec, bulletin_spec) = chain_spec_paths(&network)?; diff --git a/e2e-tests/tests/bulletin_generate_snapshot.rs b/e2e-tests/tests/bulletin_generate_snapshot.rs index 4e58a825eb..356344d688 100644 --- a/e2e-tests/tests/bulletin_generate_snapshot.rs +++ b/e2e-tests/tests/bulletin_generate_snapshot.rs @@ -23,10 +23,12 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use log::info; use smoldot_e2e_tests::{ - bulletin::{self, ArchiveChecksums, BulletinManifest, ManifestPayload, Payload}, + bulletin::{self, Payload}, + harness::bulletin_network_config, resolve_base_dir, }; use zombienet_sdk::{ + snapshot::BundleBuilder, subxt::{ config::{ substrate::SubstrateConfig, transaction_extensions, Config, @@ -36,7 +38,7 @@ use zombienet_sdk::{ OnlineClient, }, subxt_signer::sr25519::{dev, Keypair}, - LocalFileSystem, Network, NetworkConfigBuilder, + LocalFileSystem, Network, }; const SPAWN_TIMEOUT_SECS: u64 = 300; @@ -125,17 +127,27 @@ impl SnapshotOpts { } } -/// Manual generator for the bulletin-chain DB snapshots used by the bitswap +/// Generator for the bulletin-chain DB snapshots used by the bitswap /// zombienet tests. /// /// Flow: /// 1. Spawn westend-local relay and bulletin parachain (para id 2487). /// 2. Authorise //Alice, then submit `transactionStorage::store` for -/// every entry in `bulletin::payloads()`. +/// every entry in `bulletin::payloads()`, snapshotting the partial +/// collator DB after the first `PARTIAL_FORK_INDEX` payloads. /// 3. Wait until the parachain reaches `BULLETIN_SNAPSHOT_TARGET_HEIGHT`. -/// 4. Tar/gzip the relay and bulletin DBs and write a `manifest.json`. +/// 4. Snapshot the relay + full collator DBs. +/// 5. Pack relay + full + partial archives into a single `bundle.tar.gz` +/// via the zombienet-sdk `BundleBuilder` (manifest embedded). /// -/// Outputs land under `${BULLETIN_SNAPSHOT_OUT_DIR:-e2e-tests/target/snapshots}/`. +/// The per-node tarring / pause-resume / checksumming is done by the SDK +/// (`NetworkNode::snapshot_db`, `Network::pause`/`resume`, +/// `snapshot::BundleBuilder`); this test only orchestrates payload +/// injection and the snapshot points. +/// +/// Outputs land under `${BULLETIN_SNAPSHOT_OUT_DIR:-e2e-tests/target/snapshots}/`: +/// the loose `relay.tgz` / `bulletin-full.tgz` / `bulletin-partial.tgz` +/// plus the bundled `bundle.tar.gz`. #[tokio::test(flavor = "multi_thread")] #[ignore = "produces large DB snapshots and must be run manually"] async fn bulletin_generate_snapshot() -> Result<()> { @@ -150,8 +162,8 @@ async fn bulletin_generate_snapshot() -> Result<()> { let api = connect_subxt(collator.ws_uri()).await?; info!("authorising //Alice"); - let alice = dev::alice(); - authorize_account(&api, &alice, &alice).await?; + let alice_signer = dev::alice(); + authorize_account(&api, &alice_signer, &alice_signer).await?; let payloads = bulletin::payloads(); let (phase_1, phase_2) = payloads.split_at(bulletin::PARTIAL_FORK_INDEX); @@ -161,25 +173,27 @@ async fn bulletin_generate_snapshot() -> Result<()> { phase_2.len() ); - let mut emitted_cids = Vec::new(); for payload in phase_1 { - let cid_str = submit_store(&api, &alice, payload).await?; - emitted_cids.push((payload.label, cid_str)); + submit_store(&api, &alice_signer, payload).await?; } - let base_dir = PathBuf::from( - network - .base_dir() - .ok_or_else(|| anyhow!("network has no base_dir"))?, + // Partial snapshot: collator-1's DB after only the pre-fork payloads. + // No `relay-data/` ends up in the archive — collators run with + // `--relay-chain-rpc-urls`, so the embedded relay client loads nothing + // from disk anyway, and `snapshot_db` only includes `relay-data/` when + // it exists. + info!( + "snapshotting partial bulletin DB after {} payloads", + phase_1.len() ); - let staging_dir = base_dir.join("partial-staging"); - - info!("forking bulletin DB after {} payloads", phase_1.len()); - fork_collator_db(&network, &base_dir, &staging_dir).await?; + network.pause().await?; + let partial = collator + .snapshot_db(opts.out_dir.join("bulletin-partial.tgz")) + .await?; + network.resume().await?; for payload in phase_2 { - let cid_str = submit_store(&api, &alice, payload).await?; - emitted_cids.push((payload.label, cid_str)); + submit_store(&api, &alice_signer, payload).await?; } info!("waiting for parachain height >= {}", opts.target_height); @@ -191,200 +205,59 @@ async fn bulletin_generate_snapshot() -> Result<()> { ) .await?; - // The full snapshot (relay + bulletin-with-all-payloads) is taken via - // the same pause/copy/resume primitive so the on-disk RocksDB state is - // consistent. Calling `network.destroy()` instead would trigger - // zombienet's crash watcher, which `process::exit(1)`s before we - // finish tarring. - let final_staging = base_dir.join("final-staging"); + // Full snapshot: relay (alice) + collator-1 with every payload. info!("snapshotting full state"); - snapshot_full_state(&network, &base_dir, &final_staging).await?; - - info!("packing snapshots"); - let relay_archive = pack_node_dirs( - &final_staging.join("relay").join("data"), - None, - &opts.out_dir.join("relay.tgz"), - )?; - let bulletin_full_archive = pack_node_dirs( - &final_staging.join("bulletin").join("data"), - Some(&final_staging.join("bulletin").join("relay-data")), - &opts.out_dir.join("bulletin-full.tgz"), - )?; - // No `relay-data/` on the partial — collators use - // `--relay-chain-rpc-urls`, so the embedded relay client doesn't load - // anything from disk anyway. - let bulletin_partial_archive = pack_node_dirs( - &staging_dir.join("data"), - None, - &opts.out_dir.join("bulletin-partial.tgz"), - )?; - - info!("writing manifest.json"); - let manifest = build_manifest( - &opts, - &emitted_cids, - &payloads, - &relay_archive, - &bulletin_full_archive, - &bulletin_partial_archive, - )?; - let manifest_path = opts.out_dir.join("manifest.json"); - std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) - .with_context(|| format!("writing {}", manifest_path.display()))?; - - info!("snapshots written to {}", opts.out_dir.display()); - Ok(()) -} - -/// Pauses both collators (SIGSTOP), copies collator-1's `data/` into -/// `staging`, then resumes the collators (SIGCONT). The pause window is -/// the only consistent point at which we can fork RocksDB without -/// risking a torn snapshot. -/// -/// `relay-data/` is intentionally NOT copied: collators run with -/// `--relay-chain-rpc-urls`, so the embedded relay client doesn't load -/// anything from disk. -async fn fork_collator_db( - network: &Network, - base_dir: &Path, - staging: &Path, -) -> Result<()> { - let collator1 = network.get_node("collator-1")?; - let collator2 = network.get_node("collator-2")?; - - collator1.pause().await?; - collator2.pause().await?; - - let copy_result: Result<()> = (|| { - let src = base_dir.join("collator-1"); - std::fs::create_dir_all(staging) - .with_context(|| format!("creating {}", staging.display()))?; - copy_dir_all(&src.join("data"), &staging.join("data"))?; - Ok(()) - })(); - - collator1.resume().await?; - collator2.resume().await?; - copy_result -} + network.pause().await?; + let relay = network + .get_node("alice")? + .snapshot_db(opts.out_dir.join("relay.tgz")) + .await?; + let full = collator + .snapshot_db(opts.out_dir.join("bulletin-full.tgz")) + .await?; + network.resume().await?; -/// Pauses every node, copies the relay (alice) and bulletin (collator-1) -/// directories into `staging/{relay,bulletin}/`, and resumes. The pause -/// window is shorter than the zombienet crash-watcher's poll interval so -/// it doesn't fire `process::exit(1)` on us. -async fn snapshot_full_state( - network: &Network, - base_dir: &Path, - staging: &Path, -) -> Result<()> { - let alice = network.get_node("alice")?; - let bob = network.get_node("bob")?; - let collator1 = network.get_node("collator-1")?; - let collator2 = network.get_node("collator-2")?; - - alice.pause().await?; - bob.pause().await?; - collator1.pause().await?; - collator2.pause().await?; - - let copy_result: Result<()> = (|| { - let relay_dst = staging.join("relay"); - std::fs::create_dir_all(&relay_dst) - .with_context(|| format!("creating {}", relay_dst.display()))?; - copy_dir_all( - &base_dir.join("alice").join("data"), - &relay_dst.join("data"), - )?; - - let bulletin_dst = staging.join("bulletin"); - std::fs::create_dir_all(&bulletin_dst) - .with_context(|| format!("creating {}", bulletin_dst.display()))?; - let collator_src = base_dir.join("collator-1"); - copy_dir_all(&collator_src.join("data"), &bulletin_dst.join("data"))?; - let collator_relay = collator_src.join("relay-data"); - if collator_relay.is_dir() { - copy_dir_all(&collator_relay, &bulletin_dst.join("relay-data"))?; - } - Ok(()) - })(); - - alice.resume().await?; - bob.resume().await?; - collator1.resume().await?; - collator2.resume().await?; - copy_result -} + info!("packing bundle.tar.gz"); + let payload_meta: Vec = payloads + .iter() + .map(|p| { + serde_json::json!({ + "label": p.label, + "cid": p.predicted_cid(), + "sha256": p.sha256_hex(), + "size": p.size(), + "on_partial": p.on_partial, + }) + }) + .collect(); + + let bundle = BundleBuilder::new() + .add(relay) + .add(full) + .add(partial) + .user_data(serde_json::json!({ + "snapshot_height": opts.target_height, + "partial_fork_index": bulletin::PARTIAL_FORK_INDEX, + "bulletin_release_tag": std::env::var("BULLETIN_RELEASE_TAG") + .unwrap_or_else(|_| "dev".into()), + "polkadot_release_tag": std::env::var("POLKADOT_RELEASE_TAG") + .unwrap_or_else(|_| "polkadot-stable2603".into()), + "payloads": payload_meta, + })) + .build(opts.out_dir.join("bundle.tar.gz"))?; -fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> { - std::fs::create_dir_all(dst).with_context(|| format!("creating {}", dst.display()))?; - for entry in std::fs::read_dir(src).with_context(|| format!("reading {}", src.display()))? { - let entry = entry?; - let dst_path = dst.join(entry.file_name()); - if entry.file_type()?.is_dir() { - copy_dir_all(&entry.path(), &dst_path)?; - } else { - std::fs::copy(entry.path(), &dst_path).with_context(|| { - format!( - "copying {} -> {}", - entry.path().display(), - dst_path.display() - ) - })?; - } - } + info!( + "snapshot bundle written to {} (sha256={}, {} bytes)", + bundle.path.display(), + bundle.sha256, + bundle.size + ); Ok(()) } async fn spawn_network(chain_spec: &Path) -> Result> { - let chain_spec_str = chain_spec - .to_str() - .ok_or_else(|| anyhow!("non-utf8 chain spec path"))? - .to_string(); - // Honour `ZOMBIENET_SDK_BASE_DIR` (set by `./g`) so working dirs and - // chain-spec outputs land under the project-local `./tmp/g-run/` - // instead of `/tmp/zombienet-/`. let base_dir = resolve_base_dir()?; - let base_dir_str = base_dir - .to_str() - .ok_or_else(|| anyhow!("non-utf8 base dir"))? - .to_string(); - - let config = NetworkConfigBuilder::new() - .with_relaychain(|rc| { - rc.with_chain(bulletin::RELAY_CHAIN) - .with_default_command(bulletin::RELAY_BINARY) - .with_validator(|node| node.with_name("alice")) - .with_validator(|node| node.with_name("bob")) - }) - .with_parachain(|p| { - p.with_id(bulletin::PARA_ID) - .with_chain_spec_path(chain_spec_str.as_str()) - .cumulus_based(true) - // `--ipfs-server` exposes bitswap so the eventual CI test can - // dial against the snapshot. `--relay-chain-rpc-urls` skips - // the embedded relay client and proxies relay queries to - // alice's RPC, sidestepping the relay-side libp2p discovery - // quirks that otherwise leave collators unable to reach the - // validators (and the parachain unable to finalise). - .with_default_args(vec![ - "--ipfs-server".into(), - ("--relay-chain-rpc-urls", "{{ZOMBIE:alice:ws_uri}}").into(), - ]) - .with_collator(|c| { - c.with_name("collator-1") - .validator(true) - .with_command(bulletin::PARA_BINARY) - }) - .with_collator(|c| { - c.with_name("collator-2") - .validator(true) - .with_command(bulletin::PARA_BINARY) - }) - }) - .with_global_settings(|g| g.with_base_dir(base_dir_str.as_str())) - .build() - .map_err(|e| anyhow!("network config errors: {e:?}"))?; + let config = bulletin_network_config(&base_dir, chain_spec, None, &[])?; let spawn_fn = zombienet_sdk::environment::get_spawn_fn(); let network = spawn_fn(config).await?; @@ -456,7 +329,7 @@ async fn authorize_account( } /// Submits `transactionStorage::store(data)` and waits for the `Stored` -/// event. Returns the predicted CID for the manifest. +/// event. Returns the predicted CID. async fn submit_store( api: &OnlineClient, signer: &Keypair, @@ -499,101 +372,3 @@ async fn submit_store( } bail!("no TransactionStorage::Stored event for {}", payload.label); } - -/// Tar/gzips `data` (and optionally `relay_data`) into `archive_path` and -/// returns the hex-encoded SHA-256 of the archive. Top-level entries are -/// `data/` and `relay-data/` so zombienet-sdk's auto-extract drops the -/// contents at the node's expected paths. -fn pack_node_dirs(data: &Path, relay_data: Option<&Path>, archive_path: &Path) -> Result { - use sha2::{Digest as _, Sha256}; - - if !data.is_dir() { - bail!("data dir not found: {}", data.display()); - } - - let f = std::fs::File::create(archive_path) - .with_context(|| format!("creating {}", archive_path.display()))?; - let gz = flate2::write::GzEncoder::new(f, flate2::Compression::default()); - let mut tar = tar::Builder::new(gz); - append_dir_skip_identity(&mut tar, "data", data)?; - - if let Some(rd) = relay_data { - if rd.is_dir() { - append_dir_skip_identity(&mut tar, "relay-data", rd)?; - } - } - - tar.finish()?; - drop(tar); - - let bytes = std::fs::read(archive_path)?; - Ok(hex::encode(Sha256::digest(&bytes))) -} - -/// Recursively adds `src` to the archive under `prefix`, skipping any -/// directory whose name is `keystore` or `network`. We only need db. -fn append_dir_skip_identity( - tar: &mut tar::Builder, - prefix: &str, - src: &Path, -) -> Result<()> { - for entry in std::fs::read_dir(src).with_context(|| format!("reading {}", src.display()))? { - let entry = entry?; - let name = entry.file_name(); - let archive_path = format!("{prefix}/{}", name.to_string_lossy()); - let file_type = entry.file_type()?; - if file_type.is_dir() { - if name == "keystore" || name == "network" { - continue; - } - append_dir_skip_identity(tar, &archive_path, &entry.path())?; - } else { - tar.append_path_with_name(entry.path(), &archive_path) - .with_context(|| { - format!("appending {} as {archive_path}", entry.path().display()) - })?; - } - } - Ok(()) -} - -fn build_manifest( - opts: &SnapshotOpts, - emitted: &[(&'static str, String)], - payloads: &[Payload], - relay_sha256: &str, - bulletin_full_sha256: &str, - bulletin_partial_sha256: &str, -) -> Result { - let manifest_payloads = emitted - .iter() - .map(|(label, cid)| { - let p = payloads - .iter() - .find(|p| p.label == *label) - .ok_or_else(|| anyhow!("emitted CID for unknown payload {label}"))?; - Ok::<_, anyhow::Error>(ManifestPayload { - label: label.to_string(), - cid: cid.clone(), - sha256: p.sha256_hex(), - size: p.size(), - on_partial: p.on_partial, - }) - }) - .collect::>>()?; - - Ok(BulletinManifest { - schema_version: 1, - snapshot_height: opts.target_height, - bulletin_release_tag: std::env::var("BULLETIN_RELEASE_TAG") - .unwrap_or_else(|_| "dev".into()), - polkadot_release_tag: std::env::var("POLKADOT_RELEASE_TAG") - .unwrap_or_else(|_| "polkadot-stable2603".into()), - payloads: manifest_payloads, - archives: ArchiveChecksums { - relay_sha256: relay_sha256.to_string(), - bulletin_full_sha256: bulletin_full_sha256.to_string(), - bulletin_partial_sha256: bulletin_partial_sha256.to_string(), - }, - }) -} From d739edfcdf9e10d3cfb1b211ab918aac7739a6b4 Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Fri, 29 May 2026 20:06:56 +0200 Subject: [PATCH 14/15] with_spawn_concurrency removed from zombienet tests --- e2e-tests/src/harness.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/e2e-tests/src/harness.rs b/e2e-tests/src/harness.rs index ad071c00f3..e8a5658446 100644 --- a/e2e-tests/src/harness.rs +++ b/e2e-tests/src/harness.rs @@ -207,13 +207,7 @@ pub fn bulletin_network_config( .with_optional_db_snapshot(bulletin_partial.clone()) }) }) - .with_global_settings(move |g| { - // `with_spawn_concurrency(1)` is an upstream workaround for a - // spawn-time race in zombienet-sdk; see - // . - g.with_base_dir(base_dir_str.as_str()) - .with_spawn_concurrency(1) - }) + .with_global_settings(move |g| g.with_base_dir(base_dir_str.as_str())) .build() .map_err(|e| anyhow!("network config errors: {e:?}")) } From 47d579ca777f531022322d788b30f37f9127c4db Mon Sep 17 00:00:00 2001 From: Michal Kucharczyk <1728078+michalkucharczyk@users.noreply.github.com> Date: Fri, 29 May 2026 20:25:15 +0200 Subject: [PATCH 15/15] snapshot bundle updated --- e2e-tests/src/harness.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e-tests/src/harness.rs b/e2e-tests/src/harness.rs index e8a5658446..04bb379234 100644 --- a/e2e-tests/src/harness.rs +++ b/e2e-tests/src/harness.rs @@ -30,11 +30,12 @@ use crate::bulletin; /// GCS URL of the snapshot bundle produced by `bulletin_generate_snapshot` /// (a single `bundle.tar.gz` packed by the zombienet-sdk `BundleBuilder`). pub const DB_SNAPSHOT_BUNDLE: &str = - "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bundle-2026-05-25.tar.gz"; + "https://storage.googleapis.com/zombienet-db-snaps/smoldot/bulletin_fetch/bundle-2026-05-29.tar.gz"; /// SHA256 of the published bundle. Empty means not yet pinned — in that case /// the resolver requires [`BUNDLE_OVERRIDE_ENV`] to point at a local bundle. -pub const DB_SNAPSHOT_BUNDLE_SHA256: &str = ""; +pub const DB_SNAPSHOT_BUNDLE_SHA256: &str = + "658acad23c4e4e44e088dfb135d5691de5f079b0f48a6d5597b99007ce60ee25"; /// Point this at a locally-generated `bundle.tar.gz` (e.g. `./tmp/snapshots/ /// bundle.tar.gz` produced by `./g`) to skip the download and run against it.