Skip to content

Commit f0faff6

Browse files
committed
Merge #1839: Introduce evicted-at/last-evicted timestamps
0a20724 feat(examples): Update example crates to use `expected_spk_txids` (志宇) 1f8fc17 feat(core)!: Remove redundant `SyncRequest` methods (志宇) f42d5a8 feat(esplora): Handle spks with expected txids (志宇) 3ab4994 feat(electrum): Handle spks with expected txids (志宇) 64e4100 feat(chain): Add `TxGraph` methods that handle expected spk txids (志宇) b38569f feat(core): Add expected txids to `SyncRequest` spks (Wei Chen) a2dfcb9 feat(chain)!: Change `TxGraph` to understand evicted-at & last-evicted (志宇) ae0336b feat(core): Add `TxUpdate::evicted_ats` (志宇) Pull request description: Partially Fixes #1740. Replaces #1765. Replaces #1811. ### Description This PR allows the receiving structures (`bdk_chain`, `bdk_wallet`) to detect and evict incoming transactions that are double spent (cancelled). We add a new field to `TxUpdate` (`TxUpdate::evicted_ats`), which in turn, updates the `last_evicted` timestamps that are tracked/persisted by `TxGraph`. This is similar to how `TxUpdate::seen_ats` update the `last_seen` timestamp in `TxGraph`. Transactions with a `last_evicted` timestamp higher than their `last_seen` timestamp are considered evicted. `SpkWithExpectedTxids` is introduced in `SpkClient` to track expected `Txid`s for each `spk`. During a sync, if any `Txid`s from `SpkWithExpectedTxids` are not in the current history of an `spk` obtained from the chain source, those `Txid`s are considered missing. Support for `SpkWithExpectedTxids` has been added to both `bdk_electrum` and `bdk_esplora` chain source crates. The canonicalization algorithm is updated to disregard transactions with a `last_evicted` timestamp greater than or equal to their `last_seen` timestamp, except in cases where transitivity rules apply. ### Notes to the reviewers This PR does not fix #1740 for block-by-block chain source (such as `bdk_bitcoind_rpc`). This work is done in a separate PR (#1857). ### Changelog notice * Add `TxUpdate::evicted_ats` which tracks transactions that have been replaced and are no longer present in mempool. * Change `TxGraph` to track `last_evicted` timestamps. This is when a transaction is last marked as missing from the mempool. * The canonicalization algorithm now disregards transactions with a `last_evicted` timestamp greater than or equal to it's `last_seen` timestamp, except when a canonical descendant exists due to rules of transitivity. * Add `SpkWithExpectedTxids` in `spk_client` which keeps track of expected `Txid`s for each `spk`. * Change `bdk_electrum` and `bdk_esplora` to understand `SpkWithExpectedTxids`. * Add `SyncRequestBuilder::expected_txids_of_spk` method which adds an association between `txid`s and `spk`s. ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `cargo fmt` and `cargo clippy` before committing #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature #### Bugfixes: * [x] This pull request breaks the existing API * [x] I've added tests to reproduce the issue which are now passing * [x] I'm linking the issue being fixed by this PR ACKs for top commit: LLFourn: ACK 0a20724 Tree-SHA512: 29ef964e4597aa9f4cf02e9997b0d17cb91ec2f0f1187b0e9ade3709636b873c2a7cbe803facbc4686a3050a2abeb3e9cc40f9308f8cded9c9353734dcc5755b
2 parents 9af0fd4 + 0a20724 commit f0faff6

File tree

19 files changed

+919
-100
lines changed

19 files changed

+919
-100
lines changed

.github/workflows/cont_integration.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ jobs:
9696
- name: Check esplora
9797
working-directory: ./crates/esplora
9898
# TODO "--target thumbv6m-none-eabi" should work but currently does not
99-
run: cargo check --no-default-features --features miniscript/no-std,bdk_chain/hashbrown
99+
run: cargo check --no-default-features --features bdk_chain/hashbrown
100100

101101
check-wasm:
102102
needs: prepare
@@ -128,7 +128,7 @@ jobs:
128128
run: cargo check --target wasm32-unknown-unknown --no-default-features --features miniscript/no-std,bdk_chain/hashbrown
129129
- name: Check esplora
130130
working-directory: ./crates/esplora
131-
run: cargo check --target wasm32-unknown-unknown --no-default-features --features miniscript/no-std,bdk_chain/hashbrown,async
131+
run: cargo check --target wasm32-unknown-unknown --no-default-features --features bdk_core/hashbrown,async
132132

133133
fmt:
134134
needs: prepare

crates/chain/src/indexed_tx_graph.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
//! Contains the [`IndexedTxGraph`] and associated types. Refer to the
22
//! [`IndexedTxGraph`] documentation for more.
3-
use core::fmt::Debug;
3+
use core::{
4+
convert::Infallible,
5+
fmt::{self, Debug},
6+
ops::RangeBounds,
7+
};
48

59
use alloc::{sync::Arc, vec::Vec};
6-
use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid};
10+
use bitcoin::{Block, OutPoint, ScriptBuf, Transaction, TxOut, Txid};
711

812
use crate::{
13+
spk_txout::SpkTxOutIndex,
914
tx_graph::{self, TxGraph},
10-
Anchor, BlockId, Indexer, Merge, TxPosInBlock,
15+
Anchor, BlockId, ChainOracle, Indexer, Merge, TxPosInBlock,
1116
};
1217

1318
/// The [`IndexedTxGraph`] combines a [`TxGraph`] and an [`Indexer`] implementation.
@@ -127,6 +132,19 @@ where
127132
self.graph.insert_seen_at(txid, seen_at).into()
128133
}
129134

135+
/// Inserts the given `evicted_at` for `txid`.
136+
///
137+
/// The `evicted_at` timestamp represents the last known time when the transaction was observed
138+
/// to be missing from the mempool. If `txid` was previously recorded with an earlier
139+
/// `evicted_at` value, it is updated only if the new value is greater.
140+
pub fn insert_evicted_at(&mut self, txid: Txid, evicted_at: u64) -> ChangeSet<A, I::ChangeSet> {
141+
let tx_graph = self.graph.insert_evicted_at(txid, evicted_at);
142+
ChangeSet {
143+
tx_graph,
144+
..Default::default()
145+
}
146+
}
147+
130148
/// Batch insert transactions, filtering out those that are irrelevant.
131149
///
132150
/// Relevancy is determined by the [`Indexer::is_tx_relevant`] implementation of `I`. Irrelevant
@@ -301,6 +319,58 @@ where
301319
}
302320
}
303321

322+
impl<A, X> IndexedTxGraph<A, X>
323+
where
324+
A: Anchor,
325+
{
326+
/// List txids that are expected to exist under the given spks.
327+
///
328+
/// This is used to fill [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids).
329+
///
330+
/// The spk index range can be contrained with `range`.
331+
///
332+
/// # Error
333+
///
334+
/// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the
335+
/// returned item.
336+
///
337+
/// If the [`ChainOracle`] is infallible,
338+
/// [`list_expected_spk_txids`](Self::list_expected_spk_txids) can be used instead.
339+
pub fn try_list_expected_spk_txids<'a, C, I>(
340+
&'a self,
341+
chain: &'a C,
342+
chain_tip: BlockId,
343+
spk_index_range: impl RangeBounds<I> + 'a,
344+
) -> impl Iterator<Item = Result<(ScriptBuf, Txid), C::Error>> + 'a
345+
where
346+
C: ChainOracle,
347+
X: AsRef<SpkTxOutIndex<I>> + 'a,
348+
I: fmt::Debug + Clone + Ord + 'a,
349+
{
350+
self.graph
351+
.try_list_expected_spk_txids(chain, chain_tip, &self.index, spk_index_range)
352+
}
353+
354+
/// List txids that are expected to exist under the given spks.
355+
///
356+
/// This is the infallible version of
357+
/// [`try_list_expected_spk_txids`](Self::try_list_expected_spk_txids).
358+
pub fn list_expected_spk_txids<'a, C, I>(
359+
&'a self,
360+
chain: &'a C,
361+
chain_tip: BlockId,
362+
spk_index_range: impl RangeBounds<I> + 'a,
363+
) -> impl Iterator<Item = (ScriptBuf, Txid)> + 'a
364+
where
365+
C: ChainOracle<Error = Infallible>,
366+
X: AsRef<SpkTxOutIndex<I>> + 'a,
367+
I: fmt::Debug + Clone + Ord + 'a,
368+
{
369+
self.try_list_expected_spk_txids(chain, chain_tip, spk_index_range)
370+
.map(|r| r.expect("infallible"))
371+
}
372+
}
373+
304374
impl<A, I> AsRef<TxGraph<A>> for IndexedTxGraph<A, I> {
305375
fn as_ref(&self) -> &TxGraph<A> {
306376
&self.graph

crates/chain/src/indexer/keychain_txout.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ impl<K> Default for KeychainTxOutIndex<K> {
136136
}
137137
}
138138

139+
impl<K> AsRef<SpkTxOutIndex<(K, u32)>> for KeychainTxOutIndex<K> {
140+
fn as_ref(&self) -> &SpkTxOutIndex<(K, u32)> {
141+
&self.inner
142+
}
143+
}
144+
139145
impl<K: Clone + Ord + Debug> Indexer for KeychainTxOutIndex<K> {
140146
type ChangeSet = ChangeSet;
141147

@@ -200,6 +206,11 @@ impl<K> KeychainTxOutIndex<K> {
200206
lookahead,
201207
}
202208
}
209+
210+
/// Get a reference to the internal [`SpkTxOutIndex`].
211+
pub fn inner(&self) -> &SpkTxOutIndex<(K, u32)> {
212+
&self.inner
213+
}
203214
}
204215

205216
/// Methods that are *re-exposed* from the internal [`SpkTxOutIndex`].

crates/chain/src/indexer/spk_txout.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ impl<I> Default for SpkTxOutIndex<I> {
5454
}
5555
}
5656

57+
impl<I> AsRef<SpkTxOutIndex<I>> for SpkTxOutIndex<I> {
58+
fn as_ref(&self) -> &SpkTxOutIndex<I> {
59+
self
60+
}
61+
}
62+
5763
impl<I: Clone + Ord + core::fmt::Debug> Indexer for SpkTxOutIndex<I> {
5864
type ChangeSet = ();
5965

@@ -334,4 +340,24 @@ impl<I: Clone + Ord + core::fmt::Debug> SpkTxOutIndex<I> {
334340
.any(|output| self.spk_indices.contains_key(&output.script_pubkey));
335341
input_matches || output_matches
336342
}
343+
344+
/// Find relevant script pubkeys associated with a transaction for tracking and validation.
345+
///
346+
/// Returns a set of script pubkeys from [`SpkTxOutIndex`] that are relevant to the outputs and
347+
/// previous outputs of a given transaction. Inputs are only considered relevant if the parent
348+
/// transactions have been scanned.
349+
pub fn relevant_spks_of_tx(&self, tx: &Transaction) -> BTreeSet<(I, ScriptBuf)> {
350+
let spks_from_inputs = tx.input.iter().filter_map(|txin| {
351+
self.txouts
352+
.get(&txin.previous_output)
353+
.cloned()
354+
.map(|(i, prev_txo)| (i, prev_txo.script_pubkey))
355+
});
356+
let spks_from_outputs = tx
357+
.output
358+
.iter()
359+
.filter_map(|txout| self.spk_indices.get_key_value(&txout.script_pubkey))
360+
.map(|(spk, i)| (i.clone(), spk.clone()));
361+
spks_from_inputs.chain(spks_from_outputs).collect()
362+
}
337363
}

crates/chain/src/rusqlite_impl.rs

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,20 @@ impl tx_graph::ChangeSet<ConfirmationBlockTime> {
264264
format!("{add_confirmation_time_column}; {extract_confirmation_time_from_anchor_column}; {drop_anchor_column}")
265265
}
266266

267+
/// Get v2 of sqlite [tx_graph::ChangeSet] schema
268+
pub fn schema_v2() -> String {
269+
format!(
270+
"ALTER TABLE {} ADD COLUMN last_evicted INTEGER",
271+
Self::TXS_TABLE_NAME,
272+
)
273+
}
274+
267275
/// Initialize sqlite tables.
268276
pub fn init_sqlite_tables(db_tx: &rusqlite::Transaction) -> rusqlite::Result<()> {
269277
migrate_schema(
270278
db_tx,
271279
Self::SCHEMA_NAME,
272-
&[&Self::schema_v0(), &Self::schema_v1()],
280+
&[&Self::schema_v0(), &Self::schema_v1(), &Self::schema_v2()],
273281
)
274282
}
275283

@@ -280,24 +288,28 @@ impl tx_graph::ChangeSet<ConfirmationBlockTime> {
280288
let mut changeset = Self::default();
281289

282290
let mut statement = db_tx.prepare(&format!(
283-
"SELECT txid, raw_tx, last_seen FROM {}",
291+
"SELECT txid, raw_tx, last_seen, last_evicted FROM {}",
284292
Self::TXS_TABLE_NAME,
285293
))?;
286294
let row_iter = statement.query_map([], |row| {
287295
Ok((
288296
row.get::<_, Impl<bitcoin::Txid>>("txid")?,
289297
row.get::<_, Option<Impl<bitcoin::Transaction>>>("raw_tx")?,
290298
row.get::<_, Option<u64>>("last_seen")?,
299+
row.get::<_, Option<u64>>("last_evicted")?,
291300
))
292301
})?;
293302
for row in row_iter {
294-
let (Impl(txid), tx, last_seen) = row?;
303+
let (Impl(txid), tx, last_seen, last_evicted) = row?;
295304
if let Some(Impl(tx)) = tx {
296305
changeset.txs.insert(Arc::new(tx));
297306
}
298307
if let Some(last_seen) = last_seen {
299308
changeset.last_seen.insert(txid, last_seen);
300309
}
310+
if let Some(last_evicted) = last_evicted {
311+
changeset.last_evicted.insert(txid, last_evicted);
312+
}
301313
}
302314

303315
let mut statement = db_tx.prepare(&format!(
@@ -377,6 +389,19 @@ impl tx_graph::ChangeSet<ConfirmationBlockTime> {
377389
})?;
378390
}
379391

392+
let mut statement = db_tx
393+
.prepare_cached(&format!(
394+
"INSERT INTO {}(txid, last_evicted) VALUES(:txid, :last_evicted) ON CONFLICT(txid) DO UPDATE SET last_evicted=:last_evicted",
395+
Self::TXS_TABLE_NAME,
396+
))?;
397+
for (&txid, &last_evicted) in &self.last_evicted {
398+
let checked_time = last_evicted.to_sql()?;
399+
statement.execute(named_params! {
400+
":txid": Impl(txid),
401+
":last_evicted": Some(checked_time),
402+
})?;
403+
}
404+
380405
let mut statement = db_tx.prepare_cached(&format!(
381406
"REPLACE INTO {}(txid, vout, value, script) VALUES(:txid, :vout, :value, :script)",
382407
Self::TXOUTS_TABLE_NAME,
@@ -628,7 +653,7 @@ mod test {
628653
}
629654

630655
#[test]
631-
fn v0_to_v1_schema_migration_is_backward_compatible() -> anyhow::Result<()> {
656+
fn v0_to_v2_schema_migration_is_backward_compatible() -> anyhow::Result<()> {
632657
type ChangeSet = tx_graph::ChangeSet<ConfirmationBlockTime>;
633658
let mut conn = rusqlite::Connection::open_in_memory()?;
634659

@@ -697,13 +722,17 @@ mod test {
697722
}
698723
}
699724

700-
// Apply v1 sqlite schema to tables with data
725+
// Apply v1 & v2 sqlite schema to tables with data
701726
{
702727
let db_tx = conn.transaction()?;
703728
migrate_schema(
704729
&db_tx,
705730
ChangeSet::SCHEMA_NAME,
706-
&[&ChangeSet::schema_v0(), &ChangeSet::schema_v1()],
731+
&[
732+
&ChangeSet::schema_v0(),
733+
&ChangeSet::schema_v1(),
734+
&ChangeSet::schema_v2(),
735+
],
707736
)?;
708737
db_tx.commit()?;
709738
}
@@ -718,4 +747,43 @@ mod test {
718747

719748
Ok(())
720749
}
750+
751+
#[test]
752+
fn can_persist_last_evicted() -> anyhow::Result<()> {
753+
use bitcoin::hashes::Hash;
754+
755+
type ChangeSet = tx_graph::ChangeSet<ConfirmationBlockTime>;
756+
let mut conn = rusqlite::Connection::open_in_memory()?;
757+
758+
// Init tables
759+
{
760+
let db_tx = conn.transaction()?;
761+
ChangeSet::init_sqlite_tables(&db_tx)?;
762+
db_tx.commit()?;
763+
}
764+
765+
let txid = bitcoin::Txid::all_zeros();
766+
let last_evicted = 100;
767+
768+
// Persist `last_evicted`
769+
{
770+
let changeset = ChangeSet {
771+
last_evicted: [(txid, last_evicted)].into(),
772+
..Default::default()
773+
};
774+
let db_tx = conn.transaction()?;
775+
changeset.persist_to_sqlite(&db_tx)?;
776+
db_tx.commit()?;
777+
}
778+
779+
// Load from sqlite should succeed
780+
{
781+
let db_tx = conn.transaction()?;
782+
let changeset = ChangeSet::from_sqlite(&db_tx)?;
783+
db_tx.commit()?;
784+
assert_eq!(changeset.last_evicted.get(&txid), Some(&last_evicted));
785+
}
786+
787+
Ok(())
788+
}
721789
}

0 commit comments

Comments
 (0)