From db9ea4a6e51b58b12efcb7108bb04f32fc053be8 Mon Sep 17 00:00:00 2001 From: Sparrow Date: Tue, 10 Nov 2026 20:07:27 +0000 Subject: [PATCH] fix(electrum,esplora): retain only unobserved txids in evicted_ats When multiple SPKs in a sync or full_scan request expect a transaction, an SPK that does not find the transaction in its history populates evicted_ats with (txid, start_time). If another SPK in the same sync pass finds the transaction, seen_ats (or anchors) is also populated with the same timestamp. Because TxNode::is_evicted() treats last_evicted >= last_seen as evicted, emitting equal timestamps marks the transaction as evicted, causing it to be hidden from canonical_view and unconfirmed transaction lists. This prunes any transactions present in seen_ats or anchors from evicted_ats before returning the response in both bdk_electrum and bdk_esplora. Fixes bitcoindevkit/bdk#2271 --- crates/electrum/src/bdk_electrum_client.rs | 20 ++++++++ crates/electrum/tests/test_electrum.rs | 55 ++++++++++++++++++++++ crates/esplora/src/async_ext.rs | 20 ++++++++ crates/esplora/src/blocking_ext.rs | 20 ++++++++ 4 files changed, 115 insertions(+) diff --git a/crates/electrum/src/bdk_electrum_client.rs b/crates/electrum/src/bdk_electrum_client.rs index 70f181b2c..52911402f 100644 --- a/crates/electrum/src/bdk_electrum_client.rs +++ b/crates/electrum/src/bdk_electrum_client.rs @@ -173,6 +173,16 @@ impl BdkElectrumClient { } } + let observed: HashSet = tx_update + .seen_ats + .iter() + .map(|(txid, _)| *txid) + .chain(tx_update.anchors.iter().map(|(_, txid)| *txid)) + .collect(); + tx_update + .evicted_ats + .retain(|(txid, _)| !observed.contains(txid)); + let chain_update = match tip_and_latest_blocks { Some((chain_tip, latest_blocks)) => Some(chain_update( chain_tip, @@ -268,6 +278,16 @@ impl BdkElectrumClient { } } + let observed: HashSet = tx_update + .seen_ats + .iter() + .map(|(txid, _)| *txid) + .chain(tx_update.anchors.iter().map(|(_, txid)| *txid)) + .collect(); + tx_update + .evicted_ats + .retain(|(txid, _)| !observed.contains(txid)); + let chain_update = match tip_and_latest_blocks { Some((chain_tip, latest_blocks)) => Some(chain_update( chain_tip, diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index 514d4015d..e3ba76e91 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -1058,3 +1058,58 @@ fn test_zero_batch_size() -> anyhow::Result<()> { ); Ok(()) } + +/// A transaction seen under one script must not be marked evicted just because another script +/// expected it and didn't find it. +/// +/// See: https://github.com/bitcoindevkit/bdk/issues/2271 +#[test] +fn test_sync_does_not_evict_tx_seen_on_another_spk() -> anyhow::Result<()> { + let env = TestEnv::new()?; + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + let client = BdkElectrumClient::new(electrum_client); + + env.mine_blocks(101, None)?; + + let addrs = test_addresses(); + let spk_missing = addrs[0].script_pubkey(); + let spk_present = addrs[1].script_pubkey(); + + // Broadcast a tx that pays spk_present (and not spk_missing). + let txid = env.send(&addrs[1], Amount::from_sat(50_000))?; + env.wait_until_electrum_sees_txid(txid, Duration::from_secs(6))?; + + // Build a sync request where both scripts expect the transaction. + let request = SyncRequest::builder() + .spks([spk_missing.clone(), spk_present.clone()]) + .expected_spk_txids([(spk_missing, txid), (spk_present, txid)]) + .build(); + + let response = client.sync(request, BATCH_SIZE, false)?; + + // The transaction was seen on spk_present. + assert!( + response.tx_update.seen_ats.iter().any(|&(t, _)| t == txid), + "tx must be reported in seen_ats" + ); + + // It must NOT be reported as evicted due to spk_missing. + assert!( + !response + .tx_update + .evicted_ats + .iter() + .any(|&(t, _)| t == txid), + "tx seen in the same sync pass must not be marked evicted" + ); + + let mut graph = TxGraph::::default(); + let _ = graph.apply_update(response.tx_update); + assert_eq!( + graph.get_tx_node(txid).map(|node| node.is_evicted()), + Some(false), + "tx must not be considered evicted in graph" + ); + + Ok(()) +} diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index f08e8d2ee..86220415c 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -106,6 +106,16 @@ where } } + let observed: HashSet = tx_update + .seen_ats + .iter() + .map(|(txid, _)| *txid) + .chain(tx_update.anchors.iter().map(|(_, txid)| *txid)) + .collect(); + tx_update + .evicted_ats + .retain(|(txid, _)| !observed.contains(txid)); + let chain_update = match (chain_tip, latest_blocks) { (Some(chain_tip), Some(latest_blocks)) => { Some(chain_update(self, &latest_blocks, &chain_tip, &tx_update.anchors).await?) @@ -168,6 +178,16 @@ where .await?, ); + let observed: HashSet = tx_update + .seen_ats + .iter() + .map(|(txid, _)| *txid) + .chain(tx_update.anchors.iter().map(|(_, txid)| *txid)) + .collect(); + tx_update + .evicted_ats + .retain(|(txid, _)| !observed.contains(txid)); + let chain_update = match (chain_tip, latest_blocks) { (Some(chain_tip), Some(latest_blocks)) => { Some(chain_update(self, &latest_blocks, &chain_tip, &tx_update.anchors).await?) diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index 0062585cb..ea68a1266 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -95,6 +95,16 @@ impl EsploraExt for esplora_client::BlockingClient { } } + let observed: HashSet = tx_update + .seen_ats + .iter() + .map(|(txid, _)| *txid) + .chain(tx_update.anchors.iter().map(|(_, txid)| *txid)) + .collect(); + tx_update + .evicted_ats + .retain(|(txid, _)| !observed.contains(txid)); + let chain_update = match (chain_tip, latest_blocks) { (Some(chain_tip), Some(latest_blocks)) => Some(chain_update( self, @@ -151,6 +161,16 @@ impl EsploraExt for esplora_client::BlockingClient { parallel_requests, )?); + let observed: HashSet = tx_update + .seen_ats + .iter() + .map(|(txid, _)| *txid) + .chain(tx_update.anchors.iter().map(|(_, txid)| *txid)) + .collect(); + tx_update + .evicted_ats + .retain(|(txid, _)| !observed.contains(txid)); + let chain_update = match (chain_tip, latest_blocks) { (Some(chain_tip), Some(latest_blocks)) => Some(chain_update( self,