Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions crates/electrum/src/bdk_electrum_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
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,
};

Expand Down Expand Up @@ -217,7 +221,11 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
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,
};

Expand Down Expand Up @@ -652,6 +660,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
fn fetch_tip_and_latest_blocks(
client: &impl ElectrumApi,
prev_tip: CheckPoint<BlockHash>,
block_header_cache: &mut HashMap<u32, Header>,
) -> Result<(CheckPoint<BlockHash>, BTreeMap<u32, BlockHash>), Error> {
let HeaderNotification { height, .. } = client.block_headers_subscribe()?;
let new_tip_height = height as u32;
Expand All @@ -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::<BTreeMap<u32, _>>()
.headers;
block_header_cache.extend((start_height..).zip(headers.iter().copied()));

(start_height..)
.zip(headers.into_iter().map(|h| h.block_hash()))
.collect::<BTreeMap<u32, BlockHash>>()
};

// Find the "point of agreement" (if any).
Expand All @@ -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
}
Expand Down
72 changes: 72 additions & 0 deletions crates/electrum/tests/test_electrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ConfirmationBlockTime, _>::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.
Expand Down
Loading