bitcoindevkit / bitcoindevkit/bdk

Electrum: `batch_fetch_anchors` keeps returning the pre-reorg anchor once header and anchor caches are populated

Open
#2,310 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Rust
Stars
1.1k
Forks
483
Avg merge
20d 3h
Merged PRs (30d)
3

Description

**Describe the bug**

`batch_fetch_anchors` (`crates/electrum/src/bdk_electrum_client.rs`) derives each height's block hash from `block_header_cache` and, on an `anchor_cache` hit for `(txid, hash)`, returns the cached anchor without fetching a header or validating a merkle proof. The header cache is only refreshed when a proof fails to validate, which never happens on the cached path. So once both caches hold the pre-reorg state for a `(txid, height)`, a reorg that re-includes the transaction in a different block at the same height goes unnoticed: every subsequent call returns the anchor with the replaced block's hash, for the lifetime of the `BdkElectrumClient`.

`fetch_tip_and_latest_blocks` does pick up the new hash for the `LocalChain`, so the sync result has a chain with the new hash at that height and an anchor pointing at the old one. Canonicalization then treats the transaction as unconfirmed even though it is confirmed, and later syncs with the same client do not correct it. The existing `test_batch_fetch_anchors_reorg_uses_new_hash` only covers the case where the anchor cache is empty.

This issue was found by AI.

**To Reproduce**

Add the following to the `test` module in `crates/electrum/src/bdk_electrum_client.rs` (next to `test_batch_fetch_anchors_reorg_uses_new_hash`) and run `cargo test -p bdk_electrum --lib batch_fetch_anchors_after_reorg_with_populated_caches`:

```rust
#[cfg(feature = "default")]
#[test]
fn batch_fetch_anchors_after_reorg_with_populated_caches() -> anyhow::Result<()> {
let env = TestEnv::new()?;
let client = electrum_client::Client::new(env.electrsd.electrum_url.as_str()).unwrap();
let electrum_client = BdkElectrumClient::new(client);

env.mine_blocks(101, None)?;
let addr = env
.rpc_client()
.get_new_address(None, None)?
.address()?
.assume_checked();
let txid = env.send(&addr, Amount::from_sat(50_000))?;
env.mine_blocks(1, None)?;
env.wait_until_electrum_sees_block(Duration::from_secs(6))?;
let height = env.rpc_client().get_block_count()?.into_model().0 as usize;

// First call populates both the header cache and the anchor cache.
let pre_reorg = electrum_client.batch_fetch_anchors(&[(txid, height)])?;
let stale_hash = pre_reorg[0].1.block_id.hash;

// Replace the confirming block; the tx is re-included at the same height.
env.reorg(1)?;
env.wait_until_electrum_sees_block(Duration::from_secs(6))?;
let new_hash = electrum_client.inner.block_header(height)?.block_hash();
assert_ne!(new_hash, stale_hash);

let anchors = electrum_client.batch_fetch_anchors(&[(txid, height)])?;
assert_eq!(anchors[0].1.block_id.hash, new_hash);
Ok(())
}
```

Current output:

```
assertion `left == right` failed
left: 5cb52f60b4f1ef75606e44e9dc8356fd6ca043eb887a613d6c498f1095930652
right: 34b9db58142dc754829f9094e68c48a5b1c911f5d1ea84a531758fa906789605
```

**Expected behavior**

After a reorg, anchors returned by `batch_fetch_anchors` should reflect the block currently at that height rather than a previously cached one.

Contributor guide

Open the contributing guide

Research direction

Start in crates/electrum/src/bdk_electrum_client.rs at batch_fetch_anchors and the nearby test_batch_fetch_anchors_reorg_uses_new_hash. Run cargo test -p bdk_electrum --lib batch_fetch_anchors_after_reorg_with_populated_caches using the provided reproduction. Done means the populated-cache reorg test passes and the returned anchor uses the block hash currently at that height.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
blockchain
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.