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

bitcoindevkit / bdk / 5984736767

26 Aug 2023 12:20PM CUT coverage: 78.856% (+0.2%) from 78.694%
5984736767

Pull #1084

github

web-flow
Merge 8bdb5a43d into 8f978f86b
Pull Request #1084: Enhance bdk chain structures

86 of 86 new or added lines in 5 files covered. (100.0%)

8022 of 10173 relevant lines covered (78.86%)

5091.55 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::{OutPoint, ScriptBuf, Transaction, Txid},
3
    local_chain::{self, CheckPoint},
4
    tx_graph::{self, TxGraph},
5
    Anchor, BlockId, ConfirmationHeightAnchor, ConfirmationTimeAnchor,
6
};
7
use electrum_client::{Client, ElectrumApi, Error, HeaderNotification};
8
use std::{
9
    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
10
    fmt::Debug,
11
    str::FromStr,
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
18
/// transactions.
19
///
20
/// To provide a complete update to [`TxGraph`], you'll need to call [`Self::missing_full_txs`] to
21
/// determine the full transactions missing from [`TxGraph`]. Then call [`Self::finalize`] to fetch
22
/// the full transactions from Electrum and finalize the update.
23
#[derive(Debug, Clone)]
×
24
pub struct ElectrumUpdate<K, A> {
25
    /// Map of [`Txid`]s to associated [`Anchor`]s.
26
    pub graph_update: HashMap<Txid, BTreeSet<A>>,
27
    /// The latest chain tip, as seen by the Electrum server.
28
    pub new_tip: local_chain::CheckPoint,
29
    /// Last-used index update for [`KeychainTxOutIndex`](bdk_chain::keychain::KeychainTxOutIndex).
30
    pub keychain_update: BTreeMap<K, u32>,
31
}
32

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

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

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

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

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

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

×
119
        let graph_changeset = {
×
120
            let old_changeset = TxGraph::default().apply_update(graph.clone());
×
121
            tx_graph::ChangeSet {
×
122
                txs: old_changeset.txs,
×
123
                txouts: old_changeset.txouts,
×
124
                last_seen: old_changeset.last_seen,
×
125
                anchors: old_changeset
×
126
                    .anchors
×
127
                    .into_iter()
×
128
                    .map(|(height_anchor, txid)| {
×
129
                        let confirmation_height = height_anchor.confirmation_height;
×
130
                        let confirmation_time = height_to_time[&confirmation_height];
×
131
                        let time_anchor = ConfirmationTimeAnchor {
×
132
                            anchor_block: height_anchor.anchor_block,
×
133
                            confirmation_height,
×
134
                            confirmation_time,
×
135
                        };
×
136
                        (time_anchor, txid)
×
137
                    })
×
138
                    .collect(),
×
139
            }
×
140
        };
×
141

×
142
        let mut update = TxGraph::default();
×
143
        update.apply_changeset(graph_changeset);
×
144

×
145
        Ok((update, keychain_update, update_tip))
×
146
    }
×
147
}
148

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

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

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

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

×
215
        let txids = txids.into_iter().collect::<Vec<_>>();
×
216
        let outpoints = outpoints.into_iter().collect::<Vec<_>>();
×
217

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

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

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

259
            let _txs =
×
260
                populate_with_outpoints(self, &cps, &mut update, &mut outpoints.iter().cloned())?;
×
261

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

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

×
281
        Ok(update)
×
282
    }
×
283
}
284

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

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

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

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

×
338
    let agreement_height = agreement_cp.as_ref().map(CheckPoint::height);
×
339

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

×
353
    Ok((new_tip, agreement_height))
×
354
}
×
355

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

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

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

453
            let anchor = determine_tx_anchor(cps, res.height, res.tx_hash);
×
454
            let tx_entry = update.graph_update.entry(res.tx_hash).or_default();
×
455
            if let Some(anchor) = anchor {
×
456
                tx_entry.insert(anchor);
×
457
            }
×
458
        }
459
    }
460
    Ok(full_txs)
×
461
}
×
462

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

476
        let spk = tx
×
477
            .output
×
478
            .get(0)
×
479
            .map(|txo| &txo.script_pubkey)
×
480
            .expect("tx must have an output");
×
481

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

491
        let tx_entry = update.graph_update.entry(txid).or_default();
×
492
        if let Some(anchor) = anchor {
×
493
            tx_entry.insert(anchor);
×
494
        }
×
495
    }
496
    Ok(())
×
497
}
×
498

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

510
    loop {
×
511
        let spks = (0..batch_size)
×
512
            .map_while(|_| spks.next())
×
513
            .collect::<Vec<_>>();
×
514
        if spks.is_empty() {
×
515
            return Ok(scanned_spks);
×
516
        }
×
517

518
        let spk_histories =
×
519
            client.batch_script_get_history(spks.iter().map(|(_, s)| s.as_script()))?;
×
520

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

534
            for tx in spk_history {
×
535
                let tx_entry = update.graph_update.entry(tx.tx_hash).or_default();
×
536
                if let Some(anchor) = determine_tx_anchor(cps, tx.height, tx.tx_hash) {
×
537
                    tx_entry.insert(anchor);
×
538
                }
×
539
            }
540
        }
541
    }
542
}
×
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