From: yahia008 Date: Fri, 9 Oct 2026 13:54:26 +0000 (+0100) Subject: fix(esplora): treat parallel_requests of 0 as 1 X-Git-Url: http://internal-gitweb-vhost/-script/src/example_cli/database/crate::database::Database?a=commitdiff_plain;h=e990ea78e9fd4dd6fbcb1577f43873d9234ab87a;p=bdk fix(esplora): treat parallel_requests of 0 as 1 A `parallel_requests` of 0 made `full_scan` and `sync` skip every request and return an empty `Ok`, because each fetch loop takes its next batch with `.take(parallel_requests)` and stops when the batch is empty. Clamp it to 1 in each fetch loop, for both the blocking and async clients, as `stop_gap` already is. Document this on `full_scan` and `sync`, and add tests covering the spk, txid and outpoint paths. Fixes #2291 --- diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index 093604d8..80fa0253 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -27,7 +27,8 @@ pub trait EsploraAsyncExt { /// `request` provides the data required to perform a script-pubkey-based full scan /// (see [`FullScanRequest`]). The full scan for each keychain (`K`) stops after a gap of /// `stop_gap` script pubkeys with no associated transactions. `parallel_requests` specifies - /// the maximum number of HTTP requests to make in parallel. + /// the maximum number of HTTP requests to make in parallel. A `parallel_requests` of 0 is + /// treated as 1. /// /// Refer to [crate-level docs](crate) for more. async fn full_scan> + Send>( @@ -41,7 +42,7 @@ pub trait EsploraAsyncExt { /// /// `request` provides the data required to perform a script-pubkey-based sync (see /// [`SyncRequest`]). `parallel_requests` specifies the maximum number of HTTP requests to make - /// in parallel. + /// in parallel. A `parallel_requests` of 0 is treated as 1. /// /// Refer to [crate-level docs](crate) for more. async fn sync> + Send>( @@ -322,6 +323,7 @@ where let mut consecutive_unused = 0usize; // Treat stop_gap = 0 as 1 while preserving original semantics for other values. let gap_limit = stop_gap.max(1); + let parallel_requests = parallel_requests.max(1); loop { let handles = keychain_spks @@ -449,6 +451,7 @@ where .filter(|txid| !inserted_txs.contains(txid)) .collect::>() .into_iter(); + let parallel_requests = parallel_requests.max(1); loop { let handles = txids .by_ref() @@ -494,6 +497,7 @@ where I::IntoIter: Send, S: Sleeper + Clone + Send + Sync, { + let parallel_requests = parallel_requests.max(1); let outpoints = outpoints.into_iter().collect::>(); let mut update = TxUpdate::::default(); diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index 5a768190..2bd9722e 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -24,7 +24,8 @@ pub trait EsploraExt { /// `request` provides the data required to perform a script-pubkey-based full scan /// (see [`FullScanRequest`]). The full scan for each keychain (`K`) stops after a gap of /// `stop_gap` script pubkeys with no associated transactions. `parallel_requests` specifies - /// the maximum number of HTTP requests to make in parallel. + /// the maximum number of HTTP requests to make in parallel. A `parallel_requests` of 0 is + /// treated as 1. /// /// Refer to [crate-level docs](crate) for more. fn full_scan>>( @@ -38,7 +39,7 @@ pub trait EsploraExt { /// /// `request` provides the data required to perform a script-pubkey-based sync (see /// [`SyncRequest`]). `parallel_requests` specifies the maximum number of HTTP requests to make - /// in parallel. + /// in parallel. A `parallel_requests` of 0 is treated as 1. /// /// Refer to [crate-level docs](crate) for more. fn sync>>( @@ -290,6 +291,7 @@ fn fetch_txs_with_keychain_spks let mut consecutive_unused = 0usize; // Treat stop_gap = 0 as 1 while preserving original semantics for other values. let gap_limit = stop_gap.max(1); + let parallel_requests = parallel_requests.max(1); loop { let handles = keychain_spks @@ -407,6 +409,7 @@ fn fetch_txs_with_txids>( .filter(|txid| !inserted_txs.contains(txid)) .collect::>() .into_iter(); + let parallel_requests = parallel_requests.max(1); loop { let handles = txids .by_ref() @@ -453,6 +456,7 @@ fn fetch_txs_with_outpoints>( outpoints: I, parallel_requests: usize, ) -> Result, Error> { + let parallel_requests = parallel_requests.max(1); let outpoints = outpoints.into_iter().collect::>(); let mut update = TxUpdate::::default(); diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index c3754300..a14a8397 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -1,4 +1,4 @@ -use bdk_chain::bitcoin::{Address, Amount}; +use bdk_chain::bitcoin::{hashes::Hash, Address, Amount, OutPoint, ScriptBuf, Txid}; use bdk_chain::local_chain::LocalChain; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::spk_txout::SpkTxOutIndex; @@ -435,3 +435,43 @@ async fn sync_terminates_when_server_page_does_not_advance() { "`sync` must reject a page that does not advance the cursor" ); } + +/// A `parallel_requests` of 0 must not make `full_scan` skip its scripts. +/// +/// The client points at a closed port, so a scan that actually requests something returns an +/// error. If no request is made, the scan returns an empty `Ok`. +#[tokio::test] +pub async fn test_full_scan_with_zero_parallel_requests() -> anyhow::Result<()> { + let client = Builder::new("http://127.0.0.1:1").build_async()?; + let request = FullScanRequest::builder().spks_for_keychain(0u32, [(0, ScriptBuf::new())]); + + let response = client.full_scan(request, 3, 0).await; + assert!( + response.is_err(), + "scan skipped its script and returned {response:?}" + ); + Ok(()) +} + +/// A `parallel_requests` of 0 must not make `sync` skip its spks, txids or outpoints (each goes +/// through a different fetch loop). +#[tokio::test] +pub async fn test_sync_with_zero_parallel_requests() -> anyhow::Result<()> { + let client = Builder::new("http://127.0.0.1:1").build_async()?; + let requests = [ + ("spks", SyncRequest::builder().spks([ScriptBuf::new()])), + ("txids", SyncRequest::builder().txids([Txid::all_zeros()])), + ( + "outpoints", + SyncRequest::builder().outpoints([OutPoint::new(Txid::all_zeros(), 0)]), + ), + ]; + for (kind, request) in requests { + let response = client.sync(request, 0).await; + assert!( + response.is_err(), + "sync skipped its {kind} and returned {response:?}" + ); + } + Ok(()) +} diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 128f82c3..1095f152 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -1,4 +1,4 @@ -use bdk_chain::bitcoin::{Address, Amount}; +use bdk_chain::bitcoin::{hashes::Hash, Address, Amount, OutPoint, ScriptBuf, Txid}; use bdk_chain::local_chain::LocalChain; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::spk_txout::SpkTxOutIndex; @@ -437,3 +437,41 @@ fn sync_terminates_when_server_page_does_not_advance() { "`sync` must reject a page that does not advance the cursor" ); } + +/// A `parallel_requests` of 0 must not make `full_scan` skip its scripts. +/// +/// The client points at a closed port, so a scan that actually requests something returns an +/// error. If no request is made, the scan returns an empty `Ok`. +#[test] +pub fn test_full_scan_with_zero_parallel_requests() { + let client = Builder::new("http://127.0.0.1:1").build_blocking(); + let request = FullScanRequest::builder().spks_for_keychain(0u32, [(0, ScriptBuf::new())]); + + let response = client.full_scan(request, 3, 0); + assert!( + response.is_err(), + "scan skipped its script and returned {response:?}" + ); +} + +/// A `parallel_requests` of 0 must not make `sync` skip its spks, txids or outpoints (each goes +/// through a different fetch loop). +#[test] +pub fn test_sync_with_zero_parallel_requests() { + let client = Builder::new("http://127.0.0.1:1").build_blocking(); + let requests = [ + ("spks", SyncRequest::builder().spks([ScriptBuf::new()])), + ("txids", SyncRequest::builder().txids([Txid::all_zeros()])), + ( + "outpoints", + SyncRequest::builder().outpoints([OutPoint::new(Txid::all_zeros(), 0)]), + ), + ]; + for (kind, request) in requests { + let response = client.sync(request, 0); + assert!( + response.is_err(), + "sync skipped its {kind} and returned {response:?}" + ); + } +}