bitcoindevkit / bitcoindevkit/bdk
Electrum: `batch_fetch_anchors` keeps returning the pre-reorg anchor once header and anchor caches are populated
- 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
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