]> Untitled Git - bdk/commitdiff
fix(electrum): check batch response lengths before pairing
authorBrijesh-Thakkar <brijeshthakkariitian5126@gmail.com>
Wed, 30 Sep 2026 19:21:08 +0000 (00:51 +0530)
committer志宇 <hello@evanlinjin.me>
Sun, 18 Oct 2026 13:11:41 +0000 (00:11 +1100)
`BdkElectrumClient` paired every batch request with its response via
`zip` without checking that the response had one entry per request. A
short response (possible with any non-stock `ElectrumApi` implementation)
silently dropped trailing scripts, txids and anchors while `sync` still
returned `Ok`, and a short `batch_block_header` response left
`height_to_hash` incomplete so `height_to_hash[&h]` panicked.

Add `check_batch_len` and call it after each of the five batch calls
(`batch_script_get_history` x3, `batch_block_header`,
`batch_transaction_get_merkle`), returning `Error::Message` on any
length mismatch, in line with the other server-misbehaviour checks in
this crate.

Fixes #2305

crates/electrum/src/bdk_electrum_client.rs

index 090f1f5f4df10d6ed01df6092163f55e790e99b1..2612c261fe3660230dc487554c65cfce23c1185d 100644 (file)
@@ -313,6 +313,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
             let spk_histories = self
                 .inner
                 .batch_script_get_history(spks.iter().map(|(_, s)| s.spk.as_script()))?;
+            check_batch_len("batch_script_get_history", spks.len(), spk_histories.len())?;
 
             for ((spk_index, spk), spk_history) in spks.into_iter().zip(spk_histories) {
                 let beyond_revealed = last_revealed.is_none_or(|lr| spk_index > lr);
@@ -388,6 +389,11 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
         let histories = self
             .inner
             .batch_script_get_history(unique_spks.iter().map(|spk| spk.as_script()))?;
+        check_batch_len(
+            "batch_script_get_history",
+            unique_spks.len(),
+            histories.len(),
+        )?;
         let mut spk_map = HashMap::new();
         for (spk, history) in unique_spks.into_iter().zip(histories) {
             spk_map.insert(spk, history);
@@ -506,6 +512,11 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
         let spk_histories = self
             .inner
             .batch_script_get_history(scripts.iter().map(|spk| spk.as_script()))?;
+        check_batch_len(
+            "batch_script_get_history",
+            scripts.len(),
+            spk_histories.len(),
+        )?;
 
         for (tx, spk_history) in txs.into_iter().zip(spk_histories) {
             if let Some(res) = spk_history.into_iter().find(|res| res.tx_hash == tx.0) {
@@ -557,6 +568,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
 
             if !missing_heights.is_empty() {
                 let headers = self.inner.batch_block_header(missing_heights.clone())?;
+                check_batch_len("batch_block_header", missing_heights.len(), headers.len())?;
                 for (height, header) in missing_heights.into_iter().zip(headers) {
                     height_to_hash.insert(height, header.block_hash());
                     cache.insert(height, header);
@@ -580,6 +592,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
 
         // Fetch merkle proofs.
         let proofs = self.inner.batch_transaction_get_merkle(to_fetch.iter())?;
+        check_batch_len("batch_transaction_get_merkle", to_fetch.len(), proofs.len())?;
 
         // Validate each proof, retrying once for each stale header.
         for ((txid, height), proof) in to_fetch.into_iter().zip(proofs) {
@@ -658,6 +671,17 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
     }
 }
 
+/// Ensure that a batch response has exactly one entry per request, so that pairing them with
+/// `zip` or indexing can never silently drop entries or panic.
+fn check_batch_len(method: &str, expected: usize, actual: usize) -> Result<(), Error> {
+    if expected != actual {
+        return Err(Error::Message(format!(
+            "electrum server returned {actual} responses for {expected} {method} requests"
+        )));
+    }
+    Ok(())
+}
+
 /// Return a [`CheckPoint`] of the latest tip, that connects with `prev_tip`. The latest blocks are
 /// fetched to construct checkpoint updates with the proper [`BlockHash`] in case of re-org.
 fn fetch_tip_and_latest_blocks(