From b4343702e7da3f3d634559b5cbdb12dbe419c94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 18 Sep 2026 09:36:19 +0000 Subject: [PATCH 1/2] fix(electrum): refresh header cache with headers fetched for the chain update `block_header_cache` was only written by `batch_fetch_anchors`, so after a reorg it kept serving the pre-reorg header for that height. The anchor cache is keyed by `(txid, hash)` from that header, so a tx re-mined at the same height hit the anchor cache with the stale hash, skipped proof validation, and was never re-anchored to the replacement block. `fetch_tip_and_latest_blocks` now writes the headers it fetches (the chain suffix and the agreement-walk checkpoints) into the header cache. --- crates/electrum/src/bdk_electrum_client.rs | 29 ++++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/electrum/src/bdk_electrum_client.rs b/crates/electrum/src/bdk_electrum_client.rs index a7c943150a..d80525ba3b 100644 --- a/crates/electrum/src/bdk_electrum_client.rs +++ b/crates/electrum/src/bdk_electrum_client.rs @@ -132,7 +132,11 @@ impl BdkElectrumClient { let start_time = request.start_time(); let tip_and_latest_blocks = match request.chain_tip() { - Some(chain_tip) => Some(fetch_tip_and_latest_blocks(&self.inner, chain_tip)?), + Some(chain_tip) => Some(fetch_tip_and_latest_blocks( + &self.inner, + chain_tip, + &mut self.block_header_cache.lock().unwrap(), + )?), None => None, }; @@ -217,7 +221,11 @@ impl BdkElectrumClient { let start_time = request.start_time(); let tip_and_latest_blocks = match request.chain_tip() { - Some(chain_tip) => Some(fetch_tip_and_latest_blocks(&self.inner, chain_tip)?), + Some(chain_tip) => Some(fetch_tip_and_latest_blocks( + &self.inner, + chain_tip, + &mut self.block_header_cache.lock().unwrap(), + )?), None => None, }; @@ -652,6 +660,7 @@ impl BdkElectrumClient { fn fetch_tip_and_latest_blocks( client: &impl ElectrumApi, prev_tip: CheckPoint, + block_header_cache: &mut HashMap, ) -> Result<(CheckPoint, BTreeMap), Error> { let HeaderNotification { height, .. } = client.block_headers_subscribe()?; let new_tip_height = height as u32; @@ -666,12 +675,14 @@ fn fetch_tip_and_latest_blocks( // to construct our checkpoint update. let mut new_blocks = { let start_height = new_tip_height.saturating_sub(CHAIN_SUFFIX_LENGTH - 1); - let hashes = client + let headers = client .block_headers(start_height as _, CHAIN_SUFFIX_LENGTH as _)? - .headers - .into_iter() - .map(|h| h.block_hash()); - (start_height..).zip(hashes).collect::>() + .headers; + block_header_cache.extend((start_height..).zip(headers.iter().copied())); + + (start_height..) + .zip(headers.into_iter().map(|h| h.block_hash())) + .collect::>() }; // Find the "point of agreement" (if any). @@ -686,7 +697,9 @@ fn fetch_tip_and_latest_blocks( new_tip_height >= cp_block.height, "already checked that electrum's tip cannot be smaller" ); - let hash = client.block_header(cp_block.height as _)?.block_hash(); + let header = client.block_header(cp_block.height as _)?; + block_header_cache.insert(cp_block.height, header); + let hash = header.block_hash(); new_blocks.insert(cp_block.height, hash); hash } From 6c5e9771814fe07fe13b1a401d06b7cc7b420cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 18 Sep 2026 09:36:19 +0000 Subject: [PATCH 2/2] test(electrum): cover tx re-mined at the same height after a reorg Checks the tx is re-anchored to the replacement block both when the reorged height is still in the synced chain suffix and when 20 blocks are mined before the next sync. Co-Authored-By: Claude Opus 5 --- crates/electrum/tests/test_electrum.rs | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index d0632db422..b00bac989d 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -689,6 +689,78 @@ fn test_sync() -> anyhow::Result<()> { Ok(()) } +/// Ensure that a tx re-mined at the same height after a reorg is anchored to the replacement block. +/// +/// The header cache must not keep serving the pre-reorg header for that height, otherwise the +/// anchor cache is hit with the stale block hash and the tx is never re-anchored. This is checked +/// both when the reorged height is still within the synced chain suffix, and when enough blocks +/// have been mined before the next sync that it is not. +#[test] +fn test_sync_reorg_remined_at_same_height() -> anyhow::Result<()> { + const SEND_AMOUNT: Amount = Amount::from_sat(10_000); + + for blocks_after_reorg in [0, 20] { + let env = TestEnv::new()?; + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + let client = BdkElectrumClient::new(electrum_client); + + let spk_to_track = ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()); + let addr_to_track = + Address::from_script(&spk_to_track, bdk_chain::bitcoin::Network::Regtest)?; + + let (mut recv_chain, _) = LocalChain::from_genesis(env.genesis_hash()?); + let mut recv_graph = IndexedTxGraph::::new({ + let mut recv_index = SpkTxOutIndex::default(); + recv_index.insert_spk((), spk_to_track.clone()); + recv_index + }); + + env.mine_blocks(101, None)?; + let txid = env.send(&addr_to_track, SEND_AMOUNT)?; + env.mine_blocks(1, None)?; + env.wait_until_electrum_sees_block(Duration::from_secs(6))?; + let _ = sync_with_electrum( + &client, + [spk_to_track.clone()], + &mut recv_chain, + &mut recv_graph, + )?; + + // Replace the confirming block. The tx returns to the mempool and is re-mined at the same + // height in the replacement block. + let height = env.bitcoind.client.get_block_count()?.into_model().0; + env.reorg(1)?; + env.mine_blocks(blocks_after_reorg, None)?; + env.wait_until_electrum_sees_block(Duration::from_secs(6))?; + let new_hash = env.get_block_hash(height)?; + let _ = sync_with_electrum( + &client, + [spk_to_track.clone()], + &mut recv_chain, + &mut recv_graph, + )?; + + assert!( + recv_graph + .graph() + .all_anchors() + .get(&txid) + .is_some_and(|anchors| anchors.iter().any(|a| a.block_id.hash == new_hash)), + "blocks_after_reorg={blocks_after_reorg}: tx must be anchored to the replacement block", + ); + assert_eq!( + get_balance(&recv_chain, &recv_graph)?, + Balance { + confirmed: SEND_AMOUNT, + ..Balance::default() + }, + "blocks_after_reorg={blocks_after_reorg}: balance must be correct", + ); + } + + Ok(()) +} + /// Ensure that confirmed txs that are reorged become unconfirmed. /// /// 1. Mine 101 blocks.