From e35a8629a86f631945dee4736ab241634ef12032 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 29 Sep 2025 16:00:47 -0500 Subject: [PATCH 1/3] fix(esplora): propagate thread join failures --- crates/esplora/src/blocking_ext.rs | 40 +++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index 5f8ab531c..0a8751b58 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -316,11 +316,17 @@ fn fetch_txs_with_keychain_spks .collect::>>>(); if handles.is_empty() { + if last_index.is_none() { + return Err(Box::new(esplora_client::Error::InvalidResponse)); + } break; } for handle in handles { - let (index, txs, evicted) = handle.join().expect("thread must not panic")?; + let handle_result = handle + .join() + .map_err(|_| Box::new(esplora_client::Error::InvalidResponse))?; + let (index, txs, evicted) = handle_result?; last_index = Some(index); if !txs.is_empty() { last_active_index = Some(index); @@ -417,7 +423,10 @@ fn fetch_txs_with_txids>( } for handle in handles { - let (txid, tx_info) = handle.join().expect("thread must not panic")?; + let handle_result = handle + .join() + .map_err(|_| Box::new(esplora_client::Error::InvalidResponse))?; + let (txid, tx_info) = handle_result?; if let Some(tx_info) = tx_info { if inserted_txs.insert(txid) { update.txs.push(tx_info.to_tx().into()); @@ -478,7 +487,10 @@ fn fetch_txs_with_outpoints>( } for handle in handles { - if let Some(op_status) = handle.join().expect("thread must not panic")? { + let handle_result = handle + .join() + .map_err(|_| Box::new(esplora_client::Error::InvalidResponse))?; + if let Some(op_status) = handle_result? { let spend_txid = match op_status.txid { Some(txid) => txid, None => continue, @@ -511,7 +523,7 @@ fn fetch_txs_with_outpoints>( #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod test { - use crate::blocking_ext::{chain_update, fetch_latest_blocks}; + use crate::blocking_ext::{chain_update, fetch_latest_blocks, Error}; use bdk_chain::bitcoin; use bdk_chain::bitcoin::hashes::Hash; use bdk_chain::bitcoin::Txid; @@ -529,6 +541,26 @@ mod test { }}; } + #[test] + fn thread_join_panic_maps_to_error() { + let handle = std::thread::spawn(|| -> Result<(), Error> { + panic!("expected panic for test coverage"); + }); + + let res = (|| -> Result<(), Error> { + let handle_result = handle + .join() + .map_err(|_| Box::new(esplora_client::Error::InvalidResponse))?; + handle_result?; + Ok(()) + })(); + + assert!(matches!( + *res.unwrap_err(), + esplora_client::Error::InvalidResponse + )); + } + macro_rules! local_chain { [ $(($height:expr, $block_hash:expr)), * ] => {{ #[allow(unused_mut)] From 7d6759138fe07a3742031658fb6778c534c03e1c Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 29 Sep 2025 16:02:06 -0500 Subject: [PATCH 2/3] fix(esplora): error if keychain iteration yields no indices --- crates/esplora/src/async_ext.rs | 15 ++++++++++++++- crates/esplora/src/blocking_ext.rs | 12 +++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index c9cb17c1e..4e2f7010b 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -348,6 +348,9 @@ where .collect::>(); if handles.is_empty() { + if last_index.is_none() { + return Err(Box::new(esplora_client::Error::InvalidResponse)); + } break; } @@ -368,7 +371,8 @@ where .extend(evicted.into_iter().map(|txid| (txid, start_time))); } - let last_index = last_index.expect("Must be set since handles wasn't empty."); + let last_index = + last_index.ok_or_else(|| Box::new(esplora_client::Error::InvalidResponse))?; let gap_limit_reached = if let Some(i) = last_active_index { last_index >= i.saturating_add(stop_gap as u32) } else { @@ -571,6 +575,15 @@ mod test { }}; } + #[test] + fn ensure_last_index_none_returns_error() { + let last_index: Option = None; + let err = last_index + .ok_or_else(|| Box::new(esplora_client::Error::InvalidResponse)) + .unwrap_err(); + assert!(matches!(*err, esplora_client::Error::InvalidResponse)); + } + // Test that `chain_update` fails due to wrong network. #[tokio::test] async fn test_chain_update_wrong_network_error() -> anyhow::Result<()> { diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index 0a8751b58..cf24bad7a 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -343,7 +343,8 @@ fn fetch_txs_with_keychain_spks .extend(evicted.into_iter().map(|txid| (txid, start_time))); } - let last_index = last_index.expect("Must be set since handles wasn't empty."); + let last_index = + last_index.ok_or_else(|| Box::new(esplora_client::Error::InvalidResponse))?; let gap_limit_reached = if let Some(i) = last_active_index { last_index >= i.saturating_add(stop_gap as u32) } else { @@ -561,6 +562,15 @@ mod test { )); } + #[test] + fn ensure_last_index_none_returns_error() { + let last_index: Option = None; + let err = last_index + .ok_or_else(|| Box::new(esplora_client::Error::InvalidResponse)) + .unwrap_err(); + assert!(matches!(*err, esplora_client::Error::InvalidResponse)); + } + macro_rules! local_chain { [ $(($height:expr, $block_hash:expr)), * ] => {{ #[allow(unused_mut)] From 481f92da9d804756f6aea1349be083cc15ada922 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 22 Sep 2025 17:00:38 -0500 Subject: [PATCH 3/3] fix(esplora): replace .expect() with error handling in chain_update --- crates/esplora/src/async_ext.rs | 2 +- crates/esplora/src/blocking_ext.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index 4e2f7010b..bb95863da 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -264,7 +264,7 @@ async fn chain_update( tip = tip .extend(conflicts.into_iter().rev().map(|b| (b.height, b.hash))) - .expect("evicted are in order"); + .map_err(|_| Box::new(esplora_client::Error::InvalidResponse))?; for (anchor, _txid) in anchors { let height = anchor.block_id.height; diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index cf24bad7a..f58a5acbc 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -249,7 +249,7 @@ fn chain_update( tip = tip .extend(conflicts.into_iter().rev().map(|b| (b.height, b.hash))) - .expect("evicted are in order"); + .map_err(|_| Box::new(esplora_client::Error::InvalidResponse))?; for (anchor, _) in anchors { let height = anchor.block_id.height;