Skip to content

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

Description

@noahjoeris

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:

#[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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions