• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bitcoindevkit / bdk / 5472795873

pending completion
5472795873

Pull #1002

github

web-flow
Merge 7223adb9f into 81c761339
Pull Request #1002: Implement linked-list `LocalChain` and add rpc-chain module/example

872 of 872 new or added lines in 9 files covered. (100.0%)

7097 of 9255 relevant lines covered (76.68%)

5516.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/electrum/src/electrum_ext.rs
1
use bdk_chain::{
2
    bitcoin::{hashes::hex::FromHex, OutPoint, Script, Transaction, Txid},
3
    keychain::LocalUpdate,
4
    local_chain::CheckPoint,
5
    tx_graph::{self, TxGraph},
6
    Anchor, BlockId, ConfirmationHeightAnchor, ConfirmationTimeAnchor,
7
};
8
use electrum_client::{Client, ElectrumApi, Error, HeaderNotification};
9
use std::{
10
    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
11
    fmt::Debug,
12
};
13

14
/// We assume that a block of this depth and deeper cannot be reorged.
15
const ASSUME_FINAL_DEPTH: u32 = 8;
16

17
/// Represents an update fetched from an Electrum server, but excludes full transactions.
18
///
19
/// To provide a complete update to [`TxGraph`], you'll need to call [`Self::missing_full_txs`] to
20
/// determine the full transactions missing from [`TxGraph`]. Then call [`Self::finalize`] to fetch
21
/// the full transactions from Electrum and finalize the update.
22
#[derive(Debug, Clone)]
×
23
pub struct ElectrumUpdate<K, A> {
24
    /// Map of [`Txid`]s to associated [`Anchor`]s.
25
    pub graph_update: HashMap<Txid, BTreeSet<A>>,
26
    /// The latest chain tip, as seen by the Electrum server.
27
    pub chain_update: CheckPoint,
28
    /// Last-used index update for [`KeychainTxOutIndex`](bdk_chain::keychain::KeychainTxOutIndex).
29
    pub keychain_update: BTreeMap<K, u32>,
30
}
31

32
impl<K, A: Anchor> ElectrumUpdate<K, A> {
33
    fn new(cp: CheckPoint) -> Self {
×
34
        Self {
×
35
            graph_update: HashMap::new(),
×
36
            chain_update: cp,
×
37
            keychain_update: BTreeMap::new(),
×
38
        }
×
39
    }
×
40

41
    /// Determine the full transactions that are missing from `graph`.
42
    ///
43
    /// Refer to [`ElectrumUpdate`].
44
    pub fn missing_full_txs<A2>(&self, graph: &TxGraph<A2>) -> Vec<Txid> {
×
45
        self.graph_update
×
46
            .keys()
×
47
            .filter(move |&&txid| graph.as_ref().get_tx(txid).is_none())
×
48
            .cloned()
×
49
            .collect()
×
50
    }
×
51

52
    /// Finalizes update with `missing` txids to fetch from `client`.
53
    ///
54
    /// Refer to [`ElectrumUpdate`].
55
    pub fn finalize(
×
56
        self,
×
57
        client: &Client,
×
58
        seen_at: Option<u64>,
×
59
        missing: Vec<Txid>,
×
60
    ) -> Result<LocalUpdate<K, A>, Error> {
×
61
        let new_txs = client.batch_transaction_get(&missing)?;
×
62
        let mut graph_update = TxGraph::<A>::new(new_txs);
×
63
        for (txid, anchors) in self.graph_update {
×
64
            if let Some(seen_at) = seen_at {
×
65
                let _ = graph_update.insert_seen_at(txid, seen_at);
×
66
            }
×
67
            for anchor in anchors {
×
68
                let _ = graph_update.insert_anchor(txid, anchor);
×
69
            }
×
70
        }
71
        Ok(LocalUpdate {
×
72
            keychain: self.keychain_update,
×
73
            graph: graph_update,
×
74
            tip: self.chain_update,
×
75
        })
×
76
    }
×
77
}
78

79
impl<K> ElectrumUpdate<K, ConfirmationHeightAnchor> {
80
    /// Finalizes the [`ElectrumUpdate`] with `new_txs` and anchors of type
81
    /// [`ConfirmationTimeAnchor`].
82
    ///
83
    /// **Note:** The confirmation time might not be precisely correct if there has been a reorg.
84
    /// Electrum's API intends that we use the merkle proof API, we should change `bdk_electrum` to
85
    /// use it.
86
    pub fn finalize_as_confirmation_time(
×
87
        self,
×
88
        client: &Client,
×
89
        seen_at: Option<u64>,
×
90
        missing: Vec<Txid>,
×
91
    ) -> Result<LocalUpdate<K, ConfirmationTimeAnchor>, Error> {
×
92
        let update = self.finalize(client, seen_at, missing)?;
×
93
        // client.batch_transaction_get(txid)
94

95
        let relevant_heights = {
×
96
            let mut visited_heights = HashSet::new();
×
97
            update
×
98
                .graph
×
99
                .all_anchors()
×
100
                .iter()
×
101
                .map(|(a, _)| a.confirmation_height_upper_bound())
×
102
                .filter(move |&h| visited_heights.insert(h))
×
103
                .collect::<Vec<_>>()
×
104
        };
105

106
        let height_to_time = relevant_heights
×
107
            .clone()
×
108
            .into_iter()
×
109
            .zip(
×
110
                client
×
111
                    .batch_block_header(relevant_heights)?
×
112
                    .into_iter()
×
113
                    .map(|bh| bh.time as u64),
×
114
            )
×
115
            .collect::<HashMap<u32, u64>>();
×
116

×
117
        let graph_additions = {
×
118
            let old_additions = TxGraph::default().determine_additions(&update.graph);
×
119
            tx_graph::Additions {
×
120
                txs: old_additions.txs,
×
121
                txouts: old_additions.txouts,
×
122
                last_seen: old_additions.last_seen,
×
123
                anchors: old_additions
×
124
                    .anchors
×
125
                    .into_iter()
×
126
                    .map(|(height_anchor, txid)| {
×
127
                        let confirmation_height = height_anchor.confirmation_height;
×
128
                        let confirmation_time = height_to_time[&confirmation_height];
×
129
                        let time_anchor = ConfirmationTimeAnchor {
×
130
                            anchor_block: height_anchor.anchor_block,
×
131
                            confirmation_height,
×
132
                            confirmation_time,
×
133
                        };
×
134
                        (time_anchor, txid)
×
135
                    })
×
136
                    .collect(),
×
137
            }
×
138
        };
×
139

×
140
        Ok(LocalUpdate {
×
141
            keychain: update.keychain,
×
142
            graph: {
×
143
                let mut graph = TxGraph::default();
×
144
                graph.apply_additions(graph_additions);
×
145
                graph
×
146
            },
×
147
            tip: update.tip,
×
148
        })
×
149
    }
×
150
}
151

152
/// Trait to extend [`Client`] functionality.
153
pub trait ElectrumExt<A> {
154
    /// Scan the blockchain (via electrum) for the data specified and returns a [`ElectrumUpdate`].
155
    ///
156
    /// - `prev_tip`: the most recent blockchain tip present locally
157
    /// - `keychain_spks`: keychains that we want to scan transactions for
158
    /// - `txids`: transactions for which we want updated [`Anchor`]s
159
    /// - `outpoints`: transactions associated with these outpoints (residing, spending) that we
160
    ///     want to included in the update
161
    ///
162
    /// The scan for each keychain stops after a gap of `stop_gap` script pubkeys with no associated
163
    /// transactions. `batch_size` specifies the max number of script pubkeys to request for in a
164
    /// single batch request.
165
    fn scan<K: Ord + Clone>(
166
        &self,
167
        prev_tip: Option<CheckPoint>,
168
        keychain_spks: BTreeMap<K, impl IntoIterator<Item = (u32, Script)>>,
169
        txids: impl IntoIterator<Item = Txid>,
170
        outpoints: impl IntoIterator<Item = OutPoint>,
171
        stop_gap: usize,
172
        batch_size: usize,
173
    ) -> Result<ElectrumUpdate<K, A>, Error>;
174

175
    /// Convenience method to call [`scan`] without requiring a keychain.
176
    ///
177
    /// [`scan`]: ElectrumExt::scan
178
    fn scan_without_keychain(
×
179
        &self,
×
180
        prev_tip: Option<CheckPoint>,
×
181
        misc_spks: impl IntoIterator<Item = Script>,
×
182
        txids: impl IntoIterator<Item = Txid>,
×
183
        outpoints: impl IntoIterator<Item = OutPoint>,
×
184
        batch_size: usize,
×
185
    ) -> Result<ElectrumUpdate<(), A>, Error> {
×
186
        let spk_iter = misc_spks
×
187
            .into_iter()
×
188
            .enumerate()
×
189
            .map(|(i, spk)| (i as u32, spk));
×
190

×
191
        self.scan(
×
192
            prev_tip,
×
193
            [((), spk_iter)].into(),
×
194
            txids,
×
195
            outpoints,
×
196
            usize::MAX,
×
197
            batch_size,
×
198
        )
×
199
    }
×
200
}
201

202
impl ElectrumExt<ConfirmationHeightAnchor> for Client {
203
    fn scan<K: Ord + Clone>(
×
204
        &self,
×
205
        prev_tip: Option<CheckPoint>,
×
206
        keychain_spks: BTreeMap<K, impl IntoIterator<Item = (u32, Script)>>,
×
207
        txids: impl IntoIterator<Item = Txid>,
×
208
        outpoints: impl IntoIterator<Item = OutPoint>,
×
209
        stop_gap: usize,
×
210
        batch_size: usize,
×
211
    ) -> Result<ElectrumUpdate<K, ConfirmationHeightAnchor>, Error> {
×
212
        let mut request_spks = keychain_spks
×
213
            .into_iter()
×
214
            .map(|(k, s)| (k, s.into_iter()))
×
215
            .collect::<BTreeMap<K, _>>();
×
216
        let mut scanned_spks = BTreeMap::<(K, u32), (Script, bool)>::new();
×
217

×
218
        let txids = txids.into_iter().collect::<Vec<_>>();
×
219
        let outpoints = outpoints.into_iter().collect::<Vec<_>>();
×
220

221
        let update = loop {
×
222
            let (tip, _) = construct_update_tip(self, prev_tip.clone())?;
×
223
            let mut update = ElectrumUpdate::<K, ConfirmationHeightAnchor>::new(tip.clone());
×
224
            let cps = update
×
225
                .chain_update
×
226
                .iter()
×
227
                .take(10)
×
228
                .map(|cp| (cp.height(), cp))
×
229
                .collect::<BTreeMap<u32, CheckPoint>>();
×
230

×
231
            if !request_spks.is_empty() {
×
232
                if !scanned_spks.is_empty() {
×
233
                    scanned_spks.append(&mut populate_with_spks(
×
234
                        self,
×
235
                        &cps,
×
236
                        &mut update,
×
237
                        &mut scanned_spks
×
238
                            .iter()
×
239
                            .map(|(i, (spk, _))| (i.clone(), spk.clone())),
×
240
                        stop_gap,
×
241
                        batch_size,
×
242
                    )?);
×
243
                }
×
244
                for (keychain, keychain_spks) in &mut request_spks {
×
245
                    scanned_spks.extend(
246
                        populate_with_spks(
×
247
                            self,
×
248
                            &cps,
×
249
                            &mut update,
×
250
                            keychain_spks,
×
251
                            stop_gap,
×
252
                            batch_size,
×
253
                        )?
×
254
                        .into_iter()
×
255
                        .map(|(spk_i, spk)| ((keychain.clone(), spk_i), spk)),
×
256
                    );
257
                }
258
            }
×
259

260
            populate_with_txids(self, &cps, &mut update, &mut txids.iter().cloned())?;
×
261

262
            let _txs =
×
263
                populate_with_outpoints(self, &cps, &mut update, &mut outpoints.iter().cloned())?;
×
264

265
            // check for reorgs during scan process
266
            let server_blockhash = self.block_header(tip.height() as usize)?.block_hash();
×
267
            if tip.hash() != server_blockhash {
×
268
                continue; // reorg
×
269
            }
×
270

×
271
            update.keychain_update = request_spks
×
272
                .into_keys()
×
273
                .filter_map(|k| {
×
274
                    scanned_spks
×
275
                        .range((k.clone(), u32::MIN)..=(k.clone(), u32::MAX))
×
276
                        .rev()
×
277
                        .find(|(_, (_, active))| *active)
×
278
                        .map(|((_, i), _)| (k, *i))
×
279
                })
×
280
                .collect::<BTreeMap<_, _>>();
×
281
            break update;
×
282
        };
×
283

×
284
        Ok(update)
×
285
    }
×
286
}
287

288
/// Return a [`CheckPoint`] of the latest tip, that connects with `prev_tip`.
289
fn construct_update_tip(
×
290
    client: &Client,
×
291
    prev_tip: Option<CheckPoint>,
×
292
) -> Result<(CheckPoint, Option<u32>), Error> {
×
293
    let HeaderNotification { height, .. } = client.block_headers_subscribe()?;
×
294
    let new_tip_height = height as u32;
×
295

296
    // If electrum returns a tip height that is lower than our previous tip, then checkpoints do
297
    // not need updating. We just return the previous tip and use that as the point of agreement.
298
    if let Some(prev_tip) = prev_tip.as_ref() {
×
299
        if new_tip_height < prev_tip.height() {
×
300
            return Ok((prev_tip.clone(), Some(prev_tip.height())));
×
301
        }
×
302
    }
×
303

304
    // Atomically fetch the latest `ASSUME_FINAL_DEPTH` count of blocks from Electrum. We use this
305
    // to construct our checkpoint update.
306
    let mut new_blocks = {
×
307
        let start_height = new_tip_height.saturating_sub(ASSUME_FINAL_DEPTH);
×
308
        let hashes = client
×
309
            .block_headers(start_height as _, ASSUME_FINAL_DEPTH as _)?
×
310
            .headers
311
            .into_iter()
×
312
            .map(|h| h.block_hash());
×
313
        (start_height..).zip(hashes).collect::<BTreeMap<u32, _>>()
×
314
    };
315

316
    // Find the "point of agreement" (if any).
317
    let agreement_cp = {
×
318
        let mut agreement_cp = Option::<CheckPoint>::None;
×
319
        for cp in prev_tip.iter().flat_map(CheckPoint::iter) {
×
320
            let cp_block = cp.block_id();
×
321
            let hash = match new_blocks.get(&cp_block.height) {
×
322
                Some(&hash) => hash,
×
323
                None => {
324
                    assert!(
×
325
                        new_tip_height >= cp_block.height,
×
326
                        "already checked that electrum's tip cannot be smaller"
×
327
                    );
328
                    let hash = client.block_header(cp_block.height as _)?.block_hash();
×
329
                    new_blocks.insert(cp_block.height, hash);
×
330
                    hash
×
331
                }
332
            };
333
            if hash == cp_block.hash {
×
334
                agreement_cp = Some(cp);
×
335
                break;
×
336
            }
×
337
        }
338
        agreement_cp
×
339
    };
×
340

×
341
    let agreement_height = agreement_cp.as_ref().map(CheckPoint::height);
×
342

×
343
    let new_tip = new_blocks
×
344
        .into_iter()
×
345
        // Prune `new_blocks` to only include blocks that are actually new.
×
346
        .filter(|(height, _)| Some(*height) > agreement_height)
×
347
        .map(|(height, hash)| BlockId { height, hash })
×
348
        .fold(agreement_cp, |prev_cp, block| {
×
349
            Some(match prev_cp {
×
350
                Some(cp) => cp.extend(block).expect("must extend checkpoint"),
×
351
                None => CheckPoint::new(block),
×
352
            })
353
        })
×
354
        .expect("must have at least one checkpoint");
×
355

×
356
    Ok((new_tip, agreement_height))
×
357
}
×
358

359
/// A [tx status] comprises of a concatenation of `tx_hash:height:`s. We transform a single one of
360
/// these concatenations into a [`ConfirmationHeightAnchor`] if possible.
361
///
362
/// We use the lowest possible checkpoint as the anchor block (from `cps`). If an anchor block
363
/// cannot be found, or the transaction is unconfirmed, [`None`] is returned.
364
///
365
/// [tx status](https://electrumx-spesmilo.readthedocs.io/en/latest/protocol-basics.html#status)
366
fn determine_tx_anchor(
×
367
    cps: &BTreeMap<u32, CheckPoint>,
×
368
    raw_height: i32,
×
369
    txid: Txid,
×
370
) -> Option<ConfirmationHeightAnchor> {
×
371
    // The electrum API has a weird quirk where an unconfirmed transaction is presented with a
×
372
    // height of 0. To avoid invalid representation in our data structures, we manually set
×
373
    // transactions residing in the genesis block to have height 0, then interpret a height of 0 as
×
374
    // unconfirmed for all other transactions.
×
375
    if txid
×
376
        == Txid::from_hex("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")
×
377
            .expect("must deserialize genesis coinbase txid")
×
378
    {
379
        let anchor_block = cps.values().next()?.block_id();
×
380
        return Some(ConfirmationHeightAnchor {
×
381
            anchor_block,
×
382
            confirmation_height: 0,
×
383
        });
×
384
    }
×
385
    match raw_height {
×
386
        h if h <= 0 => {
×
387
            debug_assert!(h == 0 || h == -1, "unexpected height ({}) from electrum", h);
×
388
            None
×
389
        }
390
        h => {
×
391
            let h = h as u32;
×
392
            let anchor_block = cps.range(h..).next().map(|(_, cp)| cp.block_id())?;
×
393
            if h > anchor_block.height {
×
394
                None
×
395
            } else {
396
                Some(ConfirmationHeightAnchor {
×
397
                    anchor_block,
×
398
                    confirmation_height: h,
×
399
                })
×
400
            }
401
        }
402
    }
403
}
×
404

405
fn populate_with_outpoints<K>(
×
406
    client: &Client,
×
407
    cps: &BTreeMap<u32, CheckPoint>,
×
408
    update: &mut ElectrumUpdate<K, ConfirmationHeightAnchor>,
×
409
    outpoints: &mut impl Iterator<Item = OutPoint>,
×
410
) -> Result<HashMap<Txid, Transaction>, Error> {
×
411
    let mut full_txs = HashMap::new();
×
412
    for outpoint in outpoints {
×
413
        let txid = outpoint.txid;
×
414
        let tx = client.transaction_get(&txid)?;
×
415
        debug_assert_eq!(tx.txid(), txid);
×
416
        let txout = match tx.output.get(outpoint.vout as usize) {
×
417
            Some(txout) => txout,
×
418
            None => continue,
×
419
        };
420
        // attempt to find the following transactions (alongside their chain positions), and
421
        // add to our sparsechain `update`:
422
        let mut has_residing = false; // tx in which the outpoint resides
×
423
        let mut has_spending = false; // tx that spends the outpoint
×
424
        for res in client.script_get_history(&txout.script_pubkey)? {
×
425
            if has_residing && has_spending {
×
426
                break;
×
427
            }
×
428

×
429
            if res.tx_hash == txid {
×
430
                if has_residing {
×
431
                    continue;
×
432
                }
×
433
                has_residing = true;
×
434
                full_txs.insert(res.tx_hash, tx.clone());
×
435
            } else {
436
                if has_spending {
×
437
                    continue;
×
438
                }
×
439
                let res_tx = match full_txs.get(&res.tx_hash) {
×
440
                    Some(tx) => tx,
×
441
                    None => {
442
                        let res_tx = client.transaction_get(&res.tx_hash)?;
×
443
                        full_txs.insert(res.tx_hash, res_tx);
×
444
                        full_txs.get(&res.tx_hash).expect("just inserted")
×
445
                    }
446
                };
447
                has_spending = res_tx
×
448
                    .input
×
449
                    .iter()
×
450
                    .any(|txin| txin.previous_output == outpoint);
×
451
                if !has_spending {
×
452
                    continue;
×
453
                }
×
454
            };
455

456
            let anchor = determine_tx_anchor(cps, res.height, res.tx_hash);
×
457

×
458
            let tx_entry = update.graph_update.entry(res.tx_hash).or_default();
×
459
            if let Some(anchor) = anchor {
×
460
                tx_entry.insert(anchor);
×
461
            }
×
462
        }
463
    }
464
    Ok(full_txs)
×
465
}
×
466

467
fn populate_with_txids<K>(
×
468
    client: &Client,
×
469
    cps: &BTreeMap<u32, CheckPoint>,
×
470
    update: &mut ElectrumUpdate<K, ConfirmationHeightAnchor>,
×
471
    txids: &mut impl Iterator<Item = Txid>,
×
472
) -> Result<(), Error> {
×
473
    for txid in txids {
×
474
        let tx = match client.transaction_get(&txid) {
×
475
            Ok(tx) => tx,
×
476
            Err(electrum_client::Error::Protocol(_)) => continue,
×
477
            Err(other_err) => return Err(other_err),
×
478
        };
479

480
        let spk = tx
×
481
            .output
×
482
            .get(0)
×
483
            .map(|txo| &txo.script_pubkey)
×
484
            .expect("tx must have an output");
×
485

486
        let anchor = match client
×
487
            .script_get_history(spk)?
×
488
            .into_iter()
×
489
            .find(|r| r.tx_hash == txid)
×
490
        {
491
            Some(r) => determine_tx_anchor(cps, r.height, txid),
×
492
            None => continue,
×
493
        };
494

495
        let tx_entry = update.graph_update.entry(txid).or_default();
×
496
        if let Some(anchor) = anchor {
×
497
            tx_entry.insert(anchor);
×
498
        }
×
499
    }
500
    Ok(())
×
501
}
×
502

503
fn populate_with_spks<K, I: Ord + Clone>(
×
504
    client: &Client,
×
505
    cps: &BTreeMap<u32, CheckPoint>,
×
506
    update: &mut ElectrumUpdate<K, ConfirmationHeightAnchor>,
×
507
    spks: &mut impl Iterator<Item = (I, Script)>,
×
508
    stop_gap: usize,
×
509
    batch_size: usize,
×
510
) -> Result<BTreeMap<I, (Script, bool)>, Error> {
×
511
    let mut unused_spk_count = 0_usize;
×
512
    let mut scanned_spks = BTreeMap::new();
×
513

514
    loop {
×
515
        let spks = (0..batch_size)
×
516
            .map_while(|_| spks.next())
×
517
            .collect::<Vec<_>>();
×
518
        if spks.is_empty() {
×
519
            return Ok(scanned_spks);
×
520
        }
×
521

522
        let spk_histories = client.batch_script_get_history(spks.iter().map(|(_, s)| s))?;
×
523

524
        for ((spk_index, spk), spk_history) in spks.into_iter().zip(spk_histories) {
×
525
            if spk_history.is_empty() {
×
526
                scanned_spks.insert(spk_index, (spk, false));
×
527
                unused_spk_count += 1;
×
528
                if unused_spk_count > stop_gap {
×
529
                    return Ok(scanned_spks);
×
530
                }
×
531
                continue;
×
532
            } else {
×
533
                scanned_spks.insert(spk_index, (spk, true));
×
534
                unused_spk_count = 0;
×
535
            }
×
536

537
            for tx in spk_history {
×
538
                let tx_entry = update.graph_update.entry(tx.tx_hash).or_default();
×
539
                if let Some(anchor) = determine_tx_anchor(cps, tx.height, tx.tx_hash) {
×
540
                    tx_entry.insert(anchor);
×
541
                }
×
542
            }
543
        }
544
    }
545
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc