diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml
index c287b88c6..cd45ff29e 100644
--- a/.github/workflows/cont_integration.yml
+++ b/.github/workflows/cont_integration.yml
@@ -96,7 +96,7 @@ jobs:
- name: Check esplora
working-directory: ./crates/esplora
# TODO "--target thumbv6m-none-eabi" should work but currently does not
- run: cargo check --no-default-features --features miniscript/no-std,bdk_chain/hashbrown
+ run: cargo check --no-default-features --features bdk_chain/hashbrown
check-wasm:
needs: prepare
@@ -128,7 +128,7 @@ jobs:
run: cargo check --target wasm32-unknown-unknown --no-default-features --features miniscript/no-std,bdk_chain/hashbrown
- name: Check esplora
working-directory: ./crates/esplora
- run: cargo check --target wasm32-unknown-unknown --no-default-features --features miniscript/no-std,bdk_chain/hashbrown,async
+ run: cargo check --target wasm32-unknown-unknown --no-default-features --features bdk_core/hashbrown,async
fmt:
needs: prepare
diff --git a/crates/chain/src/indexed_tx_graph.rs b/crates/chain/src/indexed_tx_graph.rs
index 45ed92aee..bcd6ac3fc 100644
--- a/crates/chain/src/indexed_tx_graph.rs
+++ b/crates/chain/src/indexed_tx_graph.rs
@@ -1,13 +1,18 @@
//! Contains the [`IndexedTxGraph`] and associated types. Refer to the
//! [`IndexedTxGraph`] documentation for more.
-use core::fmt::Debug;
+use core::{
+ convert::Infallible,
+ fmt::{self, Debug},
+ ops::RangeBounds,
+};
use alloc::{sync::Arc, vec::Vec};
-use bitcoin::{Block, OutPoint, Transaction, TxOut, Txid};
+use bitcoin::{Block, OutPoint, ScriptBuf, Transaction, TxOut, Txid};
use crate::{
+ spk_txout::SpkTxOutIndex,
tx_graph::{self, TxGraph},
- Anchor, BlockId, Indexer, Merge, TxPosInBlock,
+ Anchor, BlockId, ChainOracle, Indexer, Merge, TxPosInBlock,
};
/// The [`IndexedTxGraph`] combines a [`TxGraph`] and an [`Indexer`] implementation.
@@ -127,6 +132,19 @@ where
self.graph.insert_seen_at(txid, seen_at).into()
}
+ /// Inserts the given `evicted_at` for `txid`.
+ ///
+ /// The `evicted_at` timestamp represents the last known time when the transaction was observed
+ /// to be missing from the mempool. If `txid` was previously recorded with an earlier
+ /// `evicted_at` value, it is updated only if the new value is greater.
+ pub fn insert_evicted_at(&mut self, txid: Txid, evicted_at: u64) -> ChangeSet {
+ let tx_graph = self.graph.insert_evicted_at(txid, evicted_at);
+ ChangeSet {
+ tx_graph,
+ ..Default::default()
+ }
+ }
+
/// Batch insert transactions, filtering out those that are irrelevant.
///
/// Relevancy is determined by the [`Indexer::is_tx_relevant`] implementation of `I`. Irrelevant
@@ -301,6 +319,58 @@ where
}
}
+impl IndexedTxGraph
+where
+ A: Anchor,
+{
+ /// List txids that are expected to exist under the given spks.
+ ///
+ /// This is used to fill [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids).
+ ///
+ /// The spk index range can be contrained with `range`.
+ ///
+ /// # Error
+ ///
+ /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the
+ /// returned item.
+ ///
+ /// If the [`ChainOracle`] is infallible,
+ /// [`list_expected_spk_txids`](Self::list_expected_spk_txids) can be used instead.
+ pub fn try_list_expected_spk_txids<'a, C, I>(
+ &'a self,
+ chain: &'a C,
+ chain_tip: BlockId,
+ spk_index_range: impl RangeBounds + 'a,
+ ) -> impl Iterator- > + 'a
+ where
+ C: ChainOracle,
+ X: AsRef> + 'a,
+ I: fmt::Debug + Clone + Ord + 'a,
+ {
+ self.graph
+ .try_list_expected_spk_txids(chain, chain_tip, &self.index, spk_index_range)
+ }
+
+ /// List txids that are expected to exist under the given spks.
+ ///
+ /// This is the infallible version of
+ /// [`try_list_expected_spk_txids`](Self::try_list_expected_spk_txids).
+ pub fn list_expected_spk_txids<'a, C, I>(
+ &'a self,
+ chain: &'a C,
+ chain_tip: BlockId,
+ spk_index_range: impl RangeBounds + 'a,
+ ) -> impl Iterator
- + 'a
+ where
+ C: ChainOracle,
+ X: AsRef> + 'a,
+ I: fmt::Debug + Clone + Ord + 'a,
+ {
+ self.try_list_expected_spk_txids(chain, chain_tip, spk_index_range)
+ .map(|r| r.expect("infallible"))
+ }
+}
+
impl AsRef> for IndexedTxGraph {
fn as_ref(&self) -> &TxGraph {
&self.graph
diff --git a/crates/chain/src/indexer/keychain_txout.rs b/crates/chain/src/indexer/keychain_txout.rs
index 4543027cc..bdea4b82d 100644
--- a/crates/chain/src/indexer/keychain_txout.rs
+++ b/crates/chain/src/indexer/keychain_txout.rs
@@ -136,6 +136,12 @@ impl Default for KeychainTxOutIndex {
}
}
+impl AsRef> for KeychainTxOutIndex {
+ fn as_ref(&self) -> &SpkTxOutIndex<(K, u32)> {
+ &self.inner
+ }
+}
+
impl Indexer for KeychainTxOutIndex {
type ChangeSet = ChangeSet;
@@ -200,6 +206,11 @@ impl KeychainTxOutIndex {
lookahead,
}
}
+
+ /// Get a reference to the internal [`SpkTxOutIndex`].
+ pub fn inner(&self) -> &SpkTxOutIndex<(K, u32)> {
+ &self.inner
+ }
}
/// Methods that are *re-exposed* from the internal [`SpkTxOutIndex`].
diff --git a/crates/chain/src/indexer/spk_txout.rs b/crates/chain/src/indexer/spk_txout.rs
index 286e5d2dc..6378dbb79 100644
--- a/crates/chain/src/indexer/spk_txout.rs
+++ b/crates/chain/src/indexer/spk_txout.rs
@@ -54,6 +54,12 @@ impl Default for SpkTxOutIndex {
}
}
+impl AsRef> for SpkTxOutIndex {
+ fn as_ref(&self) -> &SpkTxOutIndex {
+ self
+ }
+}
+
impl Indexer for SpkTxOutIndex {
type ChangeSet = ();
@@ -334,4 +340,24 @@ impl SpkTxOutIndex {
.any(|output| self.spk_indices.contains_key(&output.script_pubkey));
input_matches || output_matches
}
+
+ /// Find relevant script pubkeys associated with a transaction for tracking and validation.
+ ///
+ /// Returns a set of script pubkeys from [`SpkTxOutIndex`] that are relevant to the outputs and
+ /// previous outputs of a given transaction. Inputs are only considered relevant if the parent
+ /// transactions have been scanned.
+ pub fn relevant_spks_of_tx(&self, tx: &Transaction) -> BTreeSet<(I, ScriptBuf)> {
+ let spks_from_inputs = tx.input.iter().filter_map(|txin| {
+ self.txouts
+ .get(&txin.previous_output)
+ .cloned()
+ .map(|(i, prev_txo)| (i, prev_txo.script_pubkey))
+ });
+ let spks_from_outputs = tx
+ .output
+ .iter()
+ .filter_map(|txout| self.spk_indices.get_key_value(&txout.script_pubkey))
+ .map(|(spk, i)| (i.clone(), spk.clone()));
+ spks_from_inputs.chain(spks_from_outputs).collect()
+ }
}
diff --git a/crates/chain/src/rusqlite_impl.rs b/crates/chain/src/rusqlite_impl.rs
index 7b39f53c0..3bc105d0b 100644
--- a/crates/chain/src/rusqlite_impl.rs
+++ b/crates/chain/src/rusqlite_impl.rs
@@ -264,12 +264,20 @@ impl tx_graph::ChangeSet {
format!("{add_confirmation_time_column}; {extract_confirmation_time_from_anchor_column}; {drop_anchor_column}")
}
+ /// Get v2 of sqlite [tx_graph::ChangeSet] schema
+ pub fn schema_v2() -> String {
+ format!(
+ "ALTER TABLE {} ADD COLUMN last_evicted INTEGER",
+ Self::TXS_TABLE_NAME,
+ )
+ }
+
/// Initialize sqlite tables.
pub fn init_sqlite_tables(db_tx: &rusqlite::Transaction) -> rusqlite::Result<()> {
migrate_schema(
db_tx,
Self::SCHEMA_NAME,
- &[&Self::schema_v0(), &Self::schema_v1()],
+ &[&Self::schema_v0(), &Self::schema_v1(), &Self::schema_v2()],
)
}
@@ -280,7 +288,7 @@ impl tx_graph::ChangeSet {
let mut changeset = Self::default();
let mut statement = db_tx.prepare(&format!(
- "SELECT txid, raw_tx, last_seen FROM {}",
+ "SELECT txid, raw_tx, last_seen, last_evicted FROM {}",
Self::TXS_TABLE_NAME,
))?;
let row_iter = statement.query_map([], |row| {
@@ -288,16 +296,20 @@ impl tx_graph::ChangeSet {
row.get::<_, Impl>("txid")?,
row.get::<_, Option>>("raw_tx")?,
row.get::<_, Option>("last_seen")?,
+ row.get::<_, Option>("last_evicted")?,
))
})?;
for row in row_iter {
- let (Impl(txid), tx, last_seen) = row?;
+ let (Impl(txid), tx, last_seen, last_evicted) = row?;
if let Some(Impl(tx)) = tx {
changeset.txs.insert(Arc::new(tx));
}
if let Some(last_seen) = last_seen {
changeset.last_seen.insert(txid, last_seen);
}
+ if let Some(last_evicted) = last_evicted {
+ changeset.last_evicted.insert(txid, last_evicted);
+ }
}
let mut statement = db_tx.prepare(&format!(
@@ -377,6 +389,19 @@ impl tx_graph::ChangeSet {
})?;
}
+ let mut statement = db_tx
+ .prepare_cached(&format!(
+ "INSERT INTO {}(txid, last_evicted) VALUES(:txid, :last_evicted) ON CONFLICT(txid) DO UPDATE SET last_evicted=:last_evicted",
+ Self::TXS_TABLE_NAME,
+ ))?;
+ for (&txid, &last_evicted) in &self.last_evicted {
+ let checked_time = last_evicted.to_sql()?;
+ statement.execute(named_params! {
+ ":txid": Impl(txid),
+ ":last_evicted": Some(checked_time),
+ })?;
+ }
+
let mut statement = db_tx.prepare_cached(&format!(
"REPLACE INTO {}(txid, vout, value, script) VALUES(:txid, :vout, :value, :script)",
Self::TXOUTS_TABLE_NAME,
@@ -628,7 +653,7 @@ mod test {
}
#[test]
- fn v0_to_v1_schema_migration_is_backward_compatible() -> anyhow::Result<()> {
+ fn v0_to_v2_schema_migration_is_backward_compatible() -> anyhow::Result<()> {
type ChangeSet = tx_graph::ChangeSet;
let mut conn = rusqlite::Connection::open_in_memory()?;
@@ -697,13 +722,17 @@ mod test {
}
}
- // Apply v1 sqlite schema to tables with data
+ // Apply v1 & v2 sqlite schema to tables with data
{
let db_tx = conn.transaction()?;
migrate_schema(
&db_tx,
ChangeSet::SCHEMA_NAME,
- &[&ChangeSet::schema_v0(), &ChangeSet::schema_v1()],
+ &[
+ &ChangeSet::schema_v0(),
+ &ChangeSet::schema_v1(),
+ &ChangeSet::schema_v2(),
+ ],
)?;
db_tx.commit()?;
}
@@ -718,4 +747,43 @@ mod test {
Ok(())
}
+
+ #[test]
+ fn can_persist_last_evicted() -> anyhow::Result<()> {
+ use bitcoin::hashes::Hash;
+
+ type ChangeSet = tx_graph::ChangeSet;
+ let mut conn = rusqlite::Connection::open_in_memory()?;
+
+ // Init tables
+ {
+ let db_tx = conn.transaction()?;
+ ChangeSet::init_sqlite_tables(&db_tx)?;
+ db_tx.commit()?;
+ }
+
+ let txid = bitcoin::Txid::all_zeros();
+ let last_evicted = 100;
+
+ // Persist `last_evicted`
+ {
+ let changeset = ChangeSet {
+ last_evicted: [(txid, last_evicted)].into(),
+ ..Default::default()
+ };
+ let db_tx = conn.transaction()?;
+ changeset.persist_to_sqlite(&db_tx)?;
+ db_tx.commit()?;
+ }
+
+ // Load from sqlite should succeed
+ {
+ let db_tx = conn.transaction()?;
+ let changeset = ChangeSet::from_sqlite(&db_tx)?;
+ db_tx.commit()?;
+ assert_eq!(changeset.last_evicted.get(&txid), Some(&last_evicted));
+ }
+
+ Ok(())
+ }
}
diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs
index d40ee49d3..4c2990351 100644
--- a/crates/chain/src/tx_graph.rs
+++ b/crates/chain/src/tx_graph.rs
@@ -16,23 +16,52 @@
//! documentation for more details), and the timestamp of the last time we saw the transaction as
//! unconfirmed.
//!
-//! Conflicting transactions are allowed to coexist within a [`TxGraph`]. This is useful for
-//! identifying and traversing conflicts and descendants of a given transaction. Some [`TxGraph`]
-//! methods only consider transactions that are "canonical" (i.e., in the best chain or in mempool).
-//! We decide which transactions are canonical based on the transaction's anchors and the
-//! `last_seen` (as unconfirmed) timestamp.
+//! # Canonicalization
//!
-//! The [`ChangeSet`] reports changes made to a [`TxGraph`]; it can be used to either save to
-//! persistent storage, or to be applied to another [`TxGraph`].
+//! Conflicting transactions are allowed to coexist within a [`TxGraph`]. A process called
+//! canonicalization is required to get a conflict-free view of transactions.
+//!
+//! * [`list_canonical_txs`](TxGraph::list_canonical_txs) lists canonical transactions.
+//! * [`filter_chain_txouts`](TxGraph::filter_chain_txouts) filters out canonical outputs from a
+//! list of outpoints.
+//! * [`filter_chain_unspents`](TxGraph::filter_chain_unspents) filters out canonical unspent
+//! outputs from a list of outpoints.
+//! * [`balance`](TxGraph::balance) gets the total sum of unspent outputs filtered from a list of
+//! outpoints.
+//! * [`canonical_iter`](TxGraph::canonical_iter) returns the [`CanonicalIter`] which contains all
+//! of the canonicalization logic.
+//!
+//! All these methods require a `chain` and `chain_tip` argument. The `chain` must be a
+//! [`ChainOracle`] implementation (such as [`LocalChain`](crate::local_chain::LocalChain)) which
+//! identifies which blocks exist under a given `chain_tip`.
//!
-//! Lastly, you can use [`TxAncestors`]/[`TxDescendants`] to traverse ancestors and descendants of
-//! a given transaction, respectively.
+//! The canonicalization algorithm uses the following associated data to determine which
+//! transactions have precedence over others:
+//!
+//! * [`Anchor`] - This bit of data represents that a transaction is anchored in a given block. If
+//! the transaction is anchored in chain of `chain_tip`, or is an ancestor of a transaction
+//! anchored in chain of `chain_tip`, then the transaction must be canonical.
+//! * `last_seen` - This is the timestamp of when a transaction is last-seen in the mempool. This
+//! value is updated by [`insert_seen_at`](TxGraph::insert_seen_at) and
+//! [`apply_update`](TxGraph::apply_update). Transactions that are seen later have higher
+//! priority than those that are seen earlier. `last_seen` values are transitive. This means
+//! that the actual `last_seen` value of a transaction is the max of all the `last_seen` values
+//! from it's descendants.
+//! * `last_evicted` - This is the timestamp of when a transaction last went missing from the
+//! mempool. If this value is equal to or higher than the transaction's `last_seen` value, then
+//! it will not be considered canonical.
+//!
+//! # Graph traversal
+//!
+//! You can use [`TxAncestors`]/[`TxDescendants`] to traverse ancestors and descendants of a given
+//! transaction, respectively.
//!
//! # Applying changes
//!
+//! The [`ChangeSet`] reports changes made to a [`TxGraph`]; it can be used to either save to
+//! persistent storage, or to be applied to another [`TxGraph`].
+//!
//! Methods that change the state of [`TxGraph`] will return [`ChangeSet`]s.
-//! [`ChangeSet`]s can be applied back to a [`TxGraph`] or be used to inform persistent storage
-//! of the changes to [`TxGraph`].
//!
//! # Generics
//!
@@ -91,6 +120,7 @@
//! [`insert_txout`]: TxGraph::insert_txout
use crate::collections::*;
+use crate::spk_txout::SpkTxOutIndex;
use crate::BlockId;
use crate::CanonicalIter;
use crate::CanonicalReason;
@@ -103,6 +133,7 @@ use bdk_core::ConfirmationBlockTime;
pub use bdk_core::TxUpdate;
use bitcoin::{Amount, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid};
use core::fmt::{self, Formatter};
+use core::ops::RangeBounds;
use core::{
convert::Infallible,
ops::{Deref, RangeInclusive},
@@ -122,6 +153,7 @@ impl From> for TxUpdate {
.flat_map(|(txid, anchors)| anchors.into_iter().map(move |a| (a, txid)))
.collect();
tx_update.seen_ats = graph.last_seen.into_iter().collect();
+ tx_update.evicted_ats = graph.last_evicted.into_iter().collect();
tx_update
}
}
@@ -145,6 +177,7 @@ pub struct TxGraph {
spends: BTreeMap>,
anchors: HashMap>,
last_seen: HashMap,
+ last_evicted: HashMap,
txs_by_highest_conf_heights: BTreeSet<(u32, Txid)>,
txs_by_last_seen: BTreeSet<(u64, Txid)>,
@@ -162,6 +195,7 @@ impl Default for TxGraph {
spends: Default::default(),
anchors: Default::default(),
last_seen: Default::default(),
+ last_evicted: Default::default(),
txs_by_highest_conf_heights: Default::default(),
txs_by_last_seen: Default::default(),
empty_outspends: Default::default(),
@@ -715,6 +749,34 @@ impl TxGraph {
changeset
}
+ /// Inserts the given `evicted_at` for `txid` into [`TxGraph`].
+ ///
+ /// The `evicted_at` timestamp represents the last known time when the transaction was observed
+ /// to be missing from the mempool. If `txid` was previously recorded with an earlier
+ /// `evicted_at` value, it is updated only if the new value is greater.
+ pub fn insert_evicted_at(&mut self, txid: Txid, evicted_at: u64) -> ChangeSet {
+ let is_changed = match self.last_evicted.entry(txid) {
+ hash_map::Entry::Occupied(mut e) => {
+ let last_evicted = e.get_mut();
+ let change = *last_evicted < evicted_at;
+ if change {
+ *last_evicted = evicted_at;
+ }
+ change
+ }
+ hash_map::Entry::Vacant(e) => {
+ e.insert(evicted_at);
+ true
+ }
+ };
+
+ let mut changeset = ChangeSet::::default();
+ if is_changed {
+ changeset.last_evicted.insert(txid, evicted_at);
+ }
+ changeset
+ }
+
/// Extends this graph with the given `update`.
///
/// The returned [`ChangeSet`] is the set difference between `update` and `self` (transactions that
@@ -733,6 +795,9 @@ impl TxGraph {
for (txid, seen_at) in update.seen_ats {
changeset.merge(self.insert_seen_at(txid, seen_at));
}
+ for (txid, evicted_at) in update.evicted_ats {
+ changeset.merge(self.insert_evicted_at(txid, evicted_at));
+ }
changeset
}
@@ -750,6 +815,7 @@ impl TxGraph {
.flat_map(|(txid, anchors)| anchors.iter().map(|a| (a.clone(), *txid)))
.collect(),
last_seen: self.last_seen.iter().map(|(&k, &v)| (k, v)).collect(),
+ last_evicted: self.last_evicted.iter().map(|(&k, &v)| (k, v)).collect(),
}
}
@@ -767,6 +833,9 @@ impl TxGraph {
for (txid, seen_at) in changeset.last_seen {
let _ = self.insert_seen_at(txid, seen_at);
}
+ for (txid, evicted_at) in changeset.last_evicted {
+ let _ = self.insert_evicted_at(txid, evicted_at);
+ }
}
}
@@ -937,9 +1006,17 @@ impl TxGraph {
/// List txids by descending last-seen order.
///
- /// Transactions without last-seens are excluded.
- pub fn txids_by_descending_last_seen(&self) -> impl ExactSizeIterator
- + '_ {
- self.txs_by_last_seen.iter().copied().rev()
+ /// Transactions without last-seens are excluded. Transactions with a last-evicted timestamp
+ /// equal or higher than it's last-seen timestamp are excluded.
+ pub fn txids_by_descending_last_seen(&self) -> impl Iterator
- + '_ {
+ self.txs_by_last_seen
+ .iter()
+ .copied()
+ .rev()
+ .filter(|(last_seen, txid)| match self.last_evicted.get(txid) {
+ Some(last_evicted) => last_evicted < last_seen,
+ None => true,
+ })
}
/// Returns a [`CanonicalIter`].
@@ -1078,6 +1155,67 @@ impl TxGraph {
self.try_balance(chain, chain_tip, outpoints, trust_predicate)
.expect("oracle is infallible")
}
+
+ /// List txids that are expected to exist under the given spks.
+ ///
+ /// This is used to fill [`SyncRequestBuilder::expected_spk_txids`](bdk_core::spk_client::SyncRequestBuilder::expected_spk_txids).
+ ///
+ /// The spk index range can be constrained with `range`.
+ ///
+ /// # Error
+ ///
+ /// If the [`ChainOracle`] implementation (`chain`) fails, an error will be returned with the
+ /// returned item.
+ ///
+ /// If the [`ChainOracle`] is infallible,
+ /// [`list_expected_spk_txids`](Self::list_expected_spk_txids) can be used instead.
+ pub fn try_list_expected_spk_txids<'a, C, I>(
+ &'a self,
+ chain: &'a C,
+ chain_tip: BlockId,
+ indexer: &'a impl AsRef>,
+ spk_index_range: impl RangeBounds + 'a,
+ ) -> impl Iterator
- > + 'a
+ where
+ C: ChainOracle,
+ I: fmt::Debug + Clone + Ord + 'a,
+ {
+ let indexer = indexer.as_ref();
+ self.try_list_canonical_txs(chain, chain_tip).flat_map(
+ move |res| -> Vec> {
+ let range = &spk_index_range;
+ let c_tx = match res {
+ Ok(c_tx) => c_tx,
+ Err(err) => return vec![Err(err)],
+ };
+ let relevant_spks = indexer.relevant_spks_of_tx(&c_tx.tx_node);
+ relevant_spks
+ .into_iter()
+ .filter(|(i, _)| range.contains(i))
+ .map(|(_, spk)| Ok((spk, c_tx.tx_node.txid)))
+ .collect()
+ },
+ )
+ }
+
+ /// List txids that are expected to exist under the given spks.
+ ///
+ /// This is the infallible version of
+ /// [`try_list_expected_spk_txids`](Self::try_list_expected_spk_txids).
+ pub fn list_expected_spk_txids<'a, C, I>(
+ &'a self,
+ chain: &'a C,
+ chain_tip: BlockId,
+ indexer: &'a impl AsRef>,
+ spk_index_range: impl RangeBounds + 'a,
+ ) -> impl Iterator
- + 'a
+ where
+ C: ChainOracle,
+ I: fmt::Debug + Clone + Ord + 'a,
+ {
+ self.try_list_expected_spk_txids(chain, chain_tip, indexer, spk_index_range)
+ .map(|r| r.expect("infallible"))
+ }
}
/// The [`ChangeSet`] represents changes to a [`TxGraph`].
@@ -1107,6 +1245,9 @@ pub struct ChangeSet {
pub anchors: BTreeSet<(A, Txid)>,
/// Added last-seen unix timestamps of transactions.
pub last_seen: BTreeMap,
+ /// Added timestamps of when a transaction is last evicted from the mempool.
+ #[cfg_attr(feature = "serde", serde(default))]
+ pub last_evicted: BTreeMap,
}
impl Default for ChangeSet {
@@ -1116,6 +1257,7 @@ impl Default for ChangeSet {
txouts: Default::default(),
anchors: Default::default(),
last_seen: Default::default(),
+ last_evicted: Default::default(),
}
}
}
@@ -1170,6 +1312,14 @@ impl Merge for ChangeSet {
.filter(|(txid, update_ls)| self.last_seen.get(txid) < Some(update_ls))
.collect::>(),
);
+ // last_evicted timestamps should only increase
+ self.last_evicted.extend(
+ other
+ .last_evicted
+ .into_iter()
+ .filter(|(txid, update_lm)| self.last_evicted.get(txid) < Some(update_lm))
+ .collect::>(),
+ );
}
fn is_empty(&self) -> bool {
@@ -1177,6 +1327,7 @@ impl Merge for ChangeSet {
&& self.txouts.is_empty()
&& self.anchors.is_empty()
&& self.last_seen.is_empty()
+ && self.last_evicted.is_empty()
}
}
@@ -1196,6 +1347,7 @@ impl ChangeSet {
self.anchors.into_iter().map(|(a, txid)| (f(a), txid)),
),
last_seen: self.last_seen,
+ last_evicted: self.last_evicted,
}
}
}
diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs
index eef5e2239..446147821 100644
--- a/crates/chain/tests/test_tx_graph.rs
+++ b/crates/chain/tests/test_tx_graph.rs
@@ -115,7 +115,8 @@ fn insert_txouts() {
txs: [Arc::new(update_tx.clone())].into(),
txouts: update_ops.clone().into(),
anchors: [(conf_anchor, update_tx.compute_txid()),].into(),
- last_seen: [(hash!("tx2"), 1000000)].into()
+ last_seen: [(hash!("tx2"), 1000000)].into(),
+ last_evicted: [].into(),
}
);
@@ -168,7 +169,8 @@ fn insert_txouts() {
txs: [Arc::new(update_tx.clone())].into(),
txouts: update_ops.into_iter().chain(original_ops).collect(),
anchors: [(conf_anchor, update_tx.compute_txid()),].into(),
- last_seen: [(hash!("tx2"), 1000000)].into()
+ last_seen: [(hash!("tx2"), 1000000)].into(),
+ last_evicted: [].into(),
}
);
}
diff --git a/crates/core/src/spk_client.rs b/crates/core/src/spk_client.rs
index dce3b7ae1..b6a8e0204 100644
--- a/crates/core/src/spk_client.rs
+++ b/crates/core/src/spk_client.rs
@@ -1,7 +1,7 @@
//! Helper types for spk-based blockchain clients.
use crate::{
alloc::{boxed::Box, collections::VecDeque, vec::Vec},
- collections::BTreeMap,
+ collections::{BTreeMap, HashMap, HashSet},
CheckPoint, ConfirmationBlockTime, Indexed,
};
use bitcoin::{OutPoint, Script, ScriptBuf, Txid};
@@ -86,6 +86,28 @@ impl SyncProgress {
}
}
+/// [`Script`] with expected [`Txid`] histories.
+#[derive(Debug, Clone)]
+pub struct SpkWithExpectedTxids {
+ /// Script pubkey.
+ pub spk: ScriptBuf,
+
+ /// [`Txid`]s that we expect to appear in the chain source's spk history response.
+ ///
+ /// Any transaction listed here that is missing from the spk history response should be
+ /// considered evicted from the mempool.
+ pub expected_txids: HashSet,
+}
+
+impl From for SpkWithExpectedTxids {
+ fn from(spk: ScriptBuf) -> Self {
+ Self {
+ spk,
+ expected_txids: HashSet::new(),
+ }
+ }
+}
+
/// Builds a [`SyncRequest`].
///
/// Construct with [`SyncRequest::builder`].
@@ -153,6 +175,20 @@ impl SyncRequestBuilder {
self
}
+ /// Add transactions that are expected to exist under the given spks.
+ ///
+ /// This is useful for detecting a malicious replacement of an incoming transaction.
+ pub fn expected_spk_txids(mut self, txs: impl IntoIterator
- ) -> Self {
+ for (spk, txid) in txs {
+ self.inner
+ .spk_expected_txids
+ .entry(spk)
+ .or_default()
+ .insert(txid);
+ }
+ self
+ }
+
/// Add [`Txid`]s that will be synced against.
pub fn txids(mut self, txids: impl IntoIterator
- ) -> Self {
self.inner.txids.extend(txids);
@@ -208,6 +244,7 @@ pub struct SyncRequest {
chain_tip: Option,
spks: VecDeque<(I, ScriptBuf)>,
spks_consumed: usize,
+ spk_expected_txids: HashMap>,
txids: VecDeque,
txids_consumed: usize,
outpoints: VecDeque,
@@ -237,6 +274,7 @@ impl SyncRequest {
chain_tip: None,
spks: VecDeque::new(),
spks_consumed: 0,
+ spk_expected_txids: HashMap::new(),
txids: VecDeque::new(),
txids_consumed: 0,
outpoints: VecDeque::new(),
@@ -282,14 +320,23 @@ impl SyncRequest {
self.chain_tip.clone()
}
- /// Advances the sync request and returns the next [`ScriptBuf`].
+ /// Advances the sync request and returns the next [`ScriptBuf`] with corresponding [`Txid`]
+ /// history.
///
/// Returns [`None`] when there are no more scripts remaining in the request.
- pub fn next_spk(&mut self) -> Option {
- let (i, spk) = self.spks.pop_front()?;
+ pub fn next_spk_with_expected_txids(&mut self) -> Option {
+ let (i, next_spk) = self.spks.pop_front()?;
self.spks_consumed += 1;
- self._call_inspect(SyncItem::Spk(i, spk.as_script()));
- Some(spk)
+ self._call_inspect(SyncItem::Spk(i, next_spk.as_script()));
+ let spk_history = self
+ .spk_expected_txids
+ .get(&next_spk)
+ .cloned()
+ .unwrap_or_default();
+ Some(SpkWithExpectedTxids {
+ spk: next_spk,
+ expected_txids: spk_history,
+ })
}
/// Advances the sync request and returns the next [`Txid`].
@@ -312,9 +359,11 @@ impl SyncRequest {
Some(outpoint)
}
- /// Iterate over [`ScriptBuf`]s contained in this request.
- pub fn iter_spks(&mut self) -> impl ExactSizeIterator
- + '_ {
- SyncIter::::new(self)
+ /// Iterate over [`ScriptBuf`]s with corresponding [`Txid`] histories contained in this request.
+ pub fn iter_spks_with_expected_txids(
+ &mut self,
+ ) -> impl ExactSizeIterator
- + '_ {
+ SyncIter::::new(self)
}
/// Iterate over [`Txid`]s contained in this request.
@@ -543,11 +592,11 @@ impl<'r, I, Item> SyncIter<'r, I, Item> {
impl<'r, I, Item> ExactSizeIterator for SyncIter<'r, I, Item> where SyncIter<'r, I, Item>: Iterator {}
-impl Iterator for SyncIter<'_, I, ScriptBuf> {
- type Item = ScriptBuf;
+impl Iterator for SyncIter<'_, I, SpkWithExpectedTxids> {
+ type Item = SpkWithExpectedTxids;
fn next(&mut self) -> Option {
- self.request.next_spk()
+ self.request.next_spk_with_expected_txids()
}
fn size_hint(&self) -> (usize, Option) {
diff --git a/crates/core/src/tx_update.rs b/crates/core/src/tx_update.rs
index 0b548313a..89a224fbd 100644
--- a/crates/core/src/tx_update.rs
+++ b/crates/core/src/tx_update.rs
@@ -44,6 +44,12 @@ pub struct TxUpdate {
/// [`SyncRequest::start_time`](crate::spk_client::SyncRequest::start_time) can be used to
/// provide the `seen_at` value.
pub seen_ats: HashSet<(Txid, u64)>,
+
+ /// When transactions were discovered to be missing (evicted) from the mempool.
+ ///
+ /// [`SyncRequest::start_time`](crate::spk_client::SyncRequest::start_time) can be used to
+ /// provide the `evicted_at` value.
+ pub evicted_ats: HashSet<(Txid, u64)>,
}
impl Default for TxUpdate {
@@ -53,6 +59,7 @@ impl Default for TxUpdate {
txouts: Default::default(),
anchors: Default::default(),
seen_ats: Default::default(),
+ evicted_ats: Default::default(),
}
}
}
@@ -72,6 +79,7 @@ impl TxUpdate {
.map(|(a, txid)| (map(a), txid))
.collect(),
seen_ats: self.seen_ats,
+ evicted_ats: self.evicted_ats,
}
}
@@ -81,5 +89,6 @@ impl TxUpdate {
self.txouts.extend(other.txouts);
self.anchors.extend(other.anchors);
self.seen_ats.extend(other.seen_ats);
+ self.evicted_ats.extend(other.evicted_ats);
}
}
diff --git a/crates/electrum/src/bdk_electrum_client.rs b/crates/electrum/src/bdk_electrum_client.rs
index 163854ad3..fb387bb39 100644
--- a/crates/electrum/src/bdk_electrum_client.rs
+++ b/crates/electrum/src/bdk_electrum_client.rs
@@ -1,14 +1,13 @@
use bdk_core::{
- bitcoin::{block::Header, BlockHash, OutPoint, ScriptBuf, Transaction, Txid},
- collections::{BTreeMap, HashMap},
- spk_client::{FullScanRequest, FullScanResponse, SyncRequest, SyncResponse},
+ bitcoin::{block::Header, BlockHash, OutPoint, Transaction, Txid},
+ collections::{BTreeMap, HashMap, HashSet},
+ spk_client::{
+ FullScanRequest, FullScanResponse, SpkWithExpectedTxids, SyncRequest, SyncResponse,
+ },
BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate,
};
use electrum_client::{ElectrumApi, Error, HeaderNotification};
-use std::{
- collections::HashSet,
- sync::{Arc, Mutex},
-};
+use std::sync::{Arc, Mutex};
/// We include a chain suffix of a certain length for the purpose of robustness.
const CHAIN_SUFFIX_LENGTH: u32 = 8;
@@ -138,7 +137,9 @@ impl BdkElectrumClient {
let mut tx_update = TxUpdate::::default();
let mut last_active_indices = BTreeMap::::default();
for keychain in request.keychains() {
- let spks = request.iter_spks(keychain.clone());
+ let spks = request
+ .iter_spks(keychain.clone())
+ .map(|(spk_i, spk)| (spk_i, SpkWithExpectedTxids::from(spk)));
if let Some(last_active_index) =
self.populate_with_spks(start_time, &mut tx_update, spks, stop_gap, batch_size)?
{
@@ -209,7 +210,7 @@ impl BdkElectrumClient {
start_time,
&mut tx_update,
request
- .iter_spks()
+ .iter_spks_with_expected_txids()
.enumerate()
.map(|(i, spk)| (i as u32, spk)),
usize::MAX,
@@ -247,7 +248,7 @@ impl BdkElectrumClient {
&self,
start_time: u64,
tx_update: &mut TxUpdate,
- mut spks: impl Iterator
- ,
+ mut spks_with_expected_txids: impl Iterator
- ,
stop_gap: usize,
batch_size: usize,
) -> Result