Skip to content

fix(electrum): verify anchor heights against current chain in chain_update - #2318

Open
only1dreamgene wants to merge 1 commit into
bitcoindevkit:masterfrom
only1dreamgene:docs/electrum-chain-update-anchor-fallback
Open

only1dreamgene wants to merge 1 commit into
bitcoindevkit:masterfrom
only1dreamgene:docs/electrum-chain-update-anchor-fallback

Conversation

@only1dreamgene

@only1dreamgene only1dreamgene commented Sep 17, 2026

Copy link
Copy Markdown

Summary

Fixes #2312.

chain_update (crates/electrum/src/bdk_electrum_client.rs) inserted a checkpoint using an anchor's own block hash whenever its height fell outside latest_blocks (the last CHAIN_SUFFIX_LENGTH blocks plus point-of-agreement heights). That hash comes from a header fetched separately in batch_fetch_anchors, at a different point in the sync — a merkle proof against it only proves the tx is included in that header, not that the header is still on the chain the current tip represents. Around a reorg, a stale or inconsistent header could be inserted straight into the checkpoint chain and treated as confirmed.

  • chain_update now only inserts checkpoints backed by latest_blocks — it no longer falls back to trusting the anchor's own hash.
  • A new fill_missing_anchor_heights runs right before chain_update (in both sync and full_scan) and re-fetches fresh, uncached headers for any anchor height outside the reorg-risk window, merging them into latest_blocks.

This closes the staleness gap without regressing historical confirmations on a full scan/restore: every checkpoint now reflects the server's current view at commit time. A reorged-out anchor resolves to the current (differing) hash instead of being silently trusted, while anchors that are still valid keep their correct confirmed status.

Test plan

  • cargo test -p bdk_electrum --lib — all 5 tests pass, including a new integration test (fill_missing_anchor_heights_corrects_reorged_anchor) that performs a real reorg via TestEnv and verifies the checkpoint reflects the corrected chain, not the stale anchor.
  • cargo clippy -p bdk_electrum --lib --tests — clean.

…pdate

`chain_update` inserted a checkpoint using an anchor's own block hash
whenever its height fell outside `latest_blocks` (the ~8-block
reorg-risk window plus point-of-agreement heights). That hash was
fetched separately in `batch_fetch_anchors`, at a different point in
the sync, so a merkle proof against it only shows the tx is included
in *that* header -- not that the header is still on the chain the
current tip represents. Around a reorg, this let an inconsistent or
stale header be inserted straight into the checkpoint chain and
treated as confirmed.

Tighten `chain_update` to only insert checkpoints backed by
`latest_blocks`, and add `fill_missing_anchor_heights`, which
re-fetches fresh (uncached) headers for anchor heights outside that
window right before `chain_update` runs. This closes the staleness
gap without regressing historical confirmations on a full scan: every
checkpoint reflects the server's current view at commit time, so a
reorged-out anchor now resolves to the current (differing) hash
instead of being silently trusted.

Fixes bitcoindevkit#2312
@only1dreamgene
only1dreamgene force-pushed the docs/electrum-chain-update-anchor-fallback branch from acb6639 to 5a2772e Compare September 17, 2026 17:13
@only1dreamgene only1dreamgene changed the title docs(electrum): clarify chain_update anchor-hash fallback + regression test fix(electrum): verify anchor heights against current chain in chain_update Sep 17, 2026
@evanlinjin

Copy link
Copy Markdown
Member

Unfortunately, I think the premise of the issue is incorrect.

a merkle proof against it only proves the tx is included in that header, not that the header is still on the chain the current tip represents.

Yes, that is the point. If the merkle proof produces an anchor that doesn't point at a block in the update chain (assuming there are no other anchors for that tx), then the wallet won't see that transaction as "confirmed". If a reorg happens mid-sync (assuming that the reorg depth is not deeper than CHAIN_SUFFIX_LENGTH), the sync may be incomplete. However, it guarantees that the synced data is correct and consistent (i.e. transactions will not be anchored in the wrong blocks, the blocks of the checkpoint update actually connect to one another).

Around a reorg, a stale or inconsistent header could be inserted straight into the checkpoint chain and treated as confirmed.

CHAIN_SUFFIX_LENGTH exists to avoid this situation (assuming that reorgs aren't deeper than CHAIN_SUFFIX_LENGTH).

@only1dreamgene only1dreamgene left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and thanks for pushing back on this.

For an anchor height with no prior local checkpoint (the normal case for historical transactions on a full scan), the merkle-proof-verified anchor is the only source of truth available -- there's no independent chain to check it against. Re-querying the same server a second time doesn't protect against a dishonest server (it would just repeat the same answer), and against an honest server hit by a genuine reorg, it only narrows a race that's already inside the window CHAIN_SUFFIX_LENGTH is designed to tolerate. Left inline notes on the specific pieces below.

Since this reasoning applies equally to the original report: should #2312 be closed as not-a-bug, or is a docs-only comment on chain_update (explaining why the fallback is safe, no behavior change) still worth adding so this doesn't get flagged again? Happy to close this PR either way -- let me know which you'd prefer.

tx_update.anchors.iter().cloned(),
)?),
Some((chain_tip, mut latest_blocks)) => {
fill_missing_anchor_heights(

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor correction to the original issue's framing while I'm here: fetch_tip_and_latest_blocks runs before batch_fetch_anchors populates tx_update.anchors later in this function, so latest_blocks is actually the staler of the two reads by the time chain_update runs, not the anchor's hash as the issue assumed.

None => anchor.block_id.hash,
};
tip = tip.insert(height, hash);
if let Some(&hash) = latest_blocks.get(&height) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check doesn't need to be this strict. For anchor heights with no prior local checkpoint, the merkle-proof-verified anchor hash is the only available source of truth -- there's nothing independent to validate it against, so requiring latest_blocks coverage here doesn't add real protection, just complexity.

return Ok(());
}

let headers = client.batch_block_header(missing_heights.clone())?;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-queries the same server a second time. Against a dishonest server it adds nothing (it would just repeat the same fabricated answer); against an honest server hit by a genuine reorg, the race this narrows is already inside the window CHAIN_SUFFIX_LENGTH is meant to tolerate. Given the cost -- a batch_block_header call that can cover every historical anchor height on a full scan -- I don't think this function is worth keeping.

@milah-247

Copy link
Copy Markdown

Having read through the thread, I agree with @evanlinjin original objection: trusting the merkle-proof-verified anchor hash when there's no prior local checkpoint isn't a bug, it's the intended design. CHAIN_SUFFIX_LENGTH already bounds the reorg risk for that case, and there's no independent chain data available at that point to validate the anchor against anyway.

Given that, I don't think this should merge as-is:

The core premise (that chain_update unsafely trusts anchor hashes) doesn't hold up, and you've already conceded this yourself in the inline comments.
fill_missing_anchor_heights re-queries the same server it already got the anchor from. Against a dishonest server this adds nothing (it can just repeat the same answer); against an honest server it only narrows a race window that CHAIN_SUFFIX_LENGTH already tolerates.
The cost isn't trivial either , a batch_block_header call across every historical anchor height on a full scan is a real overhead for protection that isn't actually there.

So: changes requested, and honestly I'd lean toward closing this PR rather than revising it, since the fix doesn't have a sound justification left once the premise is removed.

On your open question : I'd vote for a docs-only comment on chain_update explaining why the anchor-hash fallback is safe (no local checkpoint → nothing to compare against → merkle proof is the trust anchor), rather than closing #2312 with no trace. That gives future readers (and future issue-filers hitting the same "looks like a bug" intuition) something to point to, without touching behavior.

Happy to re-review if you want to pivot this into that docs-only version.

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Electrum: chain_update inserts anchor block hashes that were not checked against the tip chain

3 participants