Skip to content

feat(electrum): add ElectrumClient.populate_anchor_cache - #1151

Open
ethicnology wants to merge 1 commit into
bitcoindevkit:masterfrom
ethicnology:feat/electrum-anchor-cache
Open

ethicnology wants to merge 1 commit into
bitcoindevkit:masterfrom
ethicnology:feat/electrum-anchor-cache

Conversation

@ethicnology

@ethicnology ethicnology commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

Exposes BdkElectrumClient::populate_anchor_cache on ElectrumClient, taking a list of TxAnchor records (a txid and one of its ConfirmationBlockTime anchors). The anchors come from Wallet.transactions(): each confirmed CanonicalTx whose chain position is not transitive carries its own anchor.

This complements #1150 (populate_tx_cache). With the transaction cache alone, a scan on a fresh client still requests one Merkle proof per known confirmed transaction (one batch_transaction_get_merkle per keychain on a full scan, one per sync); with the anchor cache, it only requests a proof for a transaction whose confirmation is new. Both together let a wallet create a fresh client per sync, prefilled from its own data, instead of keeping one client alive across reorgs.

Request counts on a loopback Electrum server for the same wallet, second scan after one new block (each get_merkle entry is one proof in the batch):

Scenario Prefill transaction.get transaction.get_merkle
no change none 2 1
no change txs 1 1
no change txs + anchors 1 0
outgoing spend txs 2 2
outgoing spend txs + anchors 2 1
mempool then confirmed txs 2 2
mempool then confirmed txs + anchors 2 1

In a Flutter wallet using both methods through bdk-dart, with a new client per sync prefilled with the wallet's transactions, the parents of their inputs and their anchors, a sync of a 275-transaction wallet went from 59–82 s to about 2 s over Wi-Fi and LTE, and from about 225 s to 5–6 s over Tor (Android).

Notes to the reviewers

populate_anchor_cache in bdk_electrum takes (Txid, impl IntoIterator<Item = ConfirmationBlockTime>) pairs; uniffi cannot expose tuples or generics, so the TxAnchor record flattens one pair to one anchor, following the naming and wrapping ADRs. A caller with several anchors for one txid passes several records.

The cache is keyed by (txid, block hash) and only consulted for the block hash the server reports at that height, so a stale anchor after a reorg is never used: the proof is fetched and validated as usual (batch_fetch_anchors). Callers should only pass direct anchors (ChainPosition::Confirmed { transitively: None, .. }): a transitive anchor is a descendant's block, not this transaction's.

The change applies with or without #1150 (different hunks of the same file). No binding-level test, as for fetch_tx and the other thin ElectrumClient wrappers, since the client connects in its constructor; I can add one against a loopback server if wanted.

Documentation

Changelog

I don't have permission to add labels; this should be changelog: added.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo fmt and cargo clippy before committing
  • I've added exactly one changelog:* label
  • I've linked the relevant upstream docs or specs above

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Expose bdk_electrum's populate_anchor_cache so a caller can seed a new ElectrumClient with the confirmation anchors it already holds, as TxAnchor records built from Wallet.transactions. A sync or full scan then requests a Merkle proof only for a transaction whose confirmation is new, instead of one per known confirmed transaction. Together with populate_tx_cache this makes a fresh client per sync cheap, without keeping one alive across reorgs.
@ethicnology

ethicnology commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Hey @thunderbiscuit, I've made a sample app to test different sync configurations related to #1150 and #1151 ranging from 2x factor and up to 70x factor after the first sync on 10 rounds.

Here are the results on a Pixel 5 (GrapheneOS) under LTE and Wi-Fi:

LTE results Wi-Fi results

@thunderbiscuit

Copy link
Copy Markdown
Member

This is super cool thanks for the detailed examples @ethicnology. I'll dig into this one tomorrow. As is it probably solves a lot of the issues that #1012 eventually solves, but from a completely different angle. I think work on 1012 is still valuable because we need flexibility in what we sync, but if this cuts your sync times by 70x... well I'm very happy with that kind of improvement let's say!

@thunderbiscuit thunderbiscuit left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me, I have just a few nits.

I think your comment "Callers should only pass direct anchors (ChainPosition::Confirmed { transitively: None, .. })" should be added to the API docs, just because adding those anchors doesn't do anything but that's not obvious if you don't know. One design decision we could do is to pass the whole wallet to the method instead, and the method could pull the anchors directly, but after looking into it I think keeping the two separate is easier, at the cost of the new type TxAnchor, which I'm fine with.

My last comment is that it'd be good to have at least an example somewhere of how to build these TxAnchors. It's not obvious at first glance how to do it. Maybe a Rust test or just a little codeblock in the API docs?

Comment thread bdk-ffi/src/types.rs
#[derive(Clone, uniffi::Record)]
pub struct TxAnchor {
/// The id of the anchored transaction.
pub txid: Arc<crate::bitcoin::Txid>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This here can be simplified:

pub txid: Arc<Txid>,

Comment thread bdk-ffi/src/electrum.rs
self.0.populate_anchor_cache(anchors.into_iter().map(|a| {
(
a.txid.0,
std::iter::once(bdk_wallet::chain::ConfirmationBlockTime::from(a.anchor)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd like to stick with our standard approach of typealiasing the Rust types in the import statements, to keep the code easier to parse.

use bdk_wallet::chain::ConfirmationBlockTime as BdkConfirmationBlockTime;

(
    a.txid.0,
    std::iter::once(BdkConfirmationBlockTime::from(a.anchor)),
)
...

Comment thread bdk-ffi/src/types.rs
/// A transaction id with one of its confirmation anchors, used to prefill an Electrum client's
/// anchor cache (see `ElectrumClient::populate_anchor_cache`).
#[derive(Clone, uniffi::Record)]
pub struct TxAnchor {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you add a line on the API docs explaining that this is used as a replacement for the tuple on the Rust side (Txid, impl IntoIterator<Item = ConfirmationBlockTime>)? Sometimes it's good to remember why we build our custom types.

This branch has not been deployed

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

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants