Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/chain/src/indexer.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
//! [`Indexer`] provides utilities for indexing transaction data.

use alloc::vec::Vec;
use bitcoin::{OutPoint, Transaction, TxOut};

#[cfg(feature = "miniscript")]
pub mod keychain_txout;
pub mod spk_txout;

/// Type alias for a list of indexed transaction outputs.
///
/// Each element is a tuple of `(index, TxOut)` where index is the index of the input or output in
/// the original [`Transaction`].
pub type IndexedTxOuts = Vec<(usize, TxOut)>;

/// Utilities for indexing transaction data.
///
/// Types which implement this trait can be used to construct an [`IndexedTxGraph`].
Expand Down
17 changes: 16 additions & 1 deletion crates/chain/src/indexer/keychain_txout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::{
spk_client::{FullScanRequestBuilder, SyncRequestBuilder},
spk_iter::BIP32_MAX_INDEX,
spk_txout::SpkTxOutIndex,
DescriptorExt, DescriptorId, Indexed, Indexer, KeychainIndexed, SpkIterator,
DescriptorExt, DescriptorId, Indexed, IndexedTxOuts, Indexer, KeychainIndexed, SpkIterator,
};
use alloc::{borrow::ToOwned, vec::Vec};
use bitcoin::{
Expand Down Expand Up @@ -418,6 +418,21 @@ impl<K: Clone + Ord + Debug> KeychainTxOutIndex<K> {
.sent_and_received(tx, self.map_to_inner_bounds(range))
}

/// Returns the sent and received [`TxOut`]s for this `tx` relative to the script pubkeys
/// belonging to the keychains in `range`. A TxOut is *sent* when a script pubkey in the
/// `range` is on an input and *received* when it is on an output. For `sent` to be computed
/// correctly, the index must have already scanned the output being spent. Calculating
/// received just uses the [`Transaction`] outputs directly, so it will be correct even if
/// it has not been scanned.
pub fn sent_and_received_txouts(
&self,
tx: &Transaction,
range: impl RangeBounds<K>,
) -> (IndexedTxOuts, IndexedTxOuts) {
self.inner
.sent_and_received_txouts(tx, self.map_to_inner_bounds(range))
}

/// Computes the net value that this transaction gives to the script pubkeys in the index and
/// *takes* from the transaction outputs in the index. Shorthand for calling
/// [`sent_and_received`] and subtracting sent from received.
Expand Down
72 changes: 71 additions & 1 deletion crates/chain/src/indexer/spk_txout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ use core::ops::RangeBounds;

use crate::{
collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap},
Indexer,
IndexedTxOuts, Indexer,
};
use bitcoin::{Amount, OutPoint, Script, ScriptBuf, SignedAmount, Transaction, TxOut, Txid};

use alloc::vec::Vec;

/// An index storing [`TxOut`]s that have a script pubkey that matches those in a list.
///
/// The basic idea is that you insert script pubkeys you care about into the index with
Expand Down Expand Up @@ -318,6 +320,74 @@ impl<I: Clone + Ord + core::fmt::Debug> SpkTxOutIndex<I> {
(sent, received)
}

/// Collects the sent and received [`TxOut`]s for `tx` on the script pubkeys in `range`.
/// TxOuts are *sent* when a script pubkey in the `range` is on an input and *received* when
/// it is on an output. For `sent` to be computed correctly, the index must have already
/// scanned the output being spent. Calculating received just uses the [`Transaction`]
/// outputs directly, so it will be correct even if it has not been scanned.
///
/// Returns a tuple of (sent_txouts, received_txouts).
///
/// # Example
/// Shows the addresses of the TxOut sent from or received by a Transaction relevant to all spks
/// in this index.
///
/// ```rust
/// # use bdk_chain::spk_txout::SpkTxOutIndex;
/// # use bitcoin::{Address, Network, Transaction};
/// # use std::str::FromStr;
/// #
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let mut index = SpkTxOutIndex::<u32>::default();
///
/// // ... scan transactions to populate the index ...
/// # let tx = Transaction { version: bitcoin::transaction::Version::TWO, lock_time: bitcoin::locktime::absolute::LockTime::ZERO, input: vec![], output: vec![] };
///
/// // Get sent and received txouts for a transaction across all tracked addresses
/// let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx, ..);
///
/// // Display addresses and amounts
Copy link
Member Author

Choose a reason for hiding this comment

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

@tnull would this information be useful for LDKnode when handling the transactions in WalletEvents ?

Copy link
Contributor

@tnull tnull Dec 11, 2025

Choose a reason for hiding this comment

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

Ah, yes, that would be very helpful, we just recently got requests to be able to see which address was sent to: lightningdevkit/ldk-node#684

Do you happen to see a way to include the output's index in this API, too? (cf. lightningdevkit/ldk-node#717). Maybe the return type could be (Vec<(usize, TxOut)>, Vec<(usize, TxOut)>), or a similar struct representation?

Copy link
Member Author

@notmandatory notmandatory Dec 11, 2025

Choose a reason for hiding this comment

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

Good idea, since the input & output Vecs of Transaction are in the correct order I'm able to return the index info with the TxOuts. See 6ff9f26.

/// println!("Sent:");
/// for (i, txout) in sent_txouts {
/// let address = Address::from_script(&txout.script_pubkey, Network::Bitcoin)?;
/// println!("input {}: from {} - {} sats", i, address, txout.value.to_sat());
/// }
///
/// println!("Received:");
/// for (i, txout) in received_txouts {
/// let address = Address::from_script(&txout.script_pubkey, Network::Bitcoin)?;
/// println!("output {}: to {} + {} sats", i, address, txout.value.to_sat());
/// }
/// # Ok(())
/// # }
/// ```
pub fn sent_and_received_txouts(
&self,
tx: &Transaction,
range: impl RangeBounds<I>,
) -> (IndexedTxOuts, IndexedTxOuts) {
let mut sent = Vec::new();
let mut received = Vec::new();

for (i, txin) in tx.input.iter().enumerate() {
if let Some((index, txout)) = self.txout(txin.previous_output) {
if range.contains(index) {
sent.push((i, txout.clone()));
}
}
}

for (i, txout) in tx.output.iter().enumerate() {
if let Some(index) = self.index_of_spk(txout.script_pubkey.clone()) {
if range.contains(index) {
received.push((i, txout.clone()));
}
}
}

(sent, received)
}

/// Computes the net value transfer effect of `tx` on the script pubkeys in `range`. Shorthand
/// for calling [`sent_and_received`] and subtracting sent from received.
///
Expand Down
2 changes: 1 addition & 1 deletion crates/chain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub mod indexed_tx_graph;
pub use indexed_tx_graph::IndexedTxGraph;
pub mod indexer;
pub use indexer::spk_txout;
pub use indexer::Indexer;
pub use indexer::{IndexedTxOuts, Indexer};
pub mod local_chain;
mod tx_data_traits;
pub use tx_data_traits::*;
Expand Down
136 changes: 136 additions & 0 deletions crates/chain/tests/test_spk_txout_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,142 @@ fn spk_txout_sent_and_received() {
assert_eq!(index.net_value(&tx2, ..), SignedAmount::from_sat(8_000));
}

#[test]
fn spk_txout_sent_and_received_txouts() {
let spk1 = ScriptBuf::from_hex("001404f1e52ce2bab3423c6a8c63b7cd730d8f12542c").unwrap();
let spk2 = ScriptBuf::from_hex("00142b57404ae14f08c3a0c903feb2af7830605eb00f").unwrap();

let mut index = SpkTxOutIndex::default();
index.insert_spk(0, spk1.clone());
index.insert_spk(1, spk2.clone());

let tx1 = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: vec![],
output: vec![TxOut {
value: Amount::from_sat(42_000),
script_pubkey: spk1.clone(),
}],
};
let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx1, ..);
assert!(sent_txouts.is_empty());
assert_eq!(
received_txouts,
vec![(
0,
TxOut {
value: Amount::from_sat(42_000),
script_pubkey: spk1.clone(),
}
)]
);
let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx1, ..1);
assert!(sent_txouts.is_empty());
assert_eq!(
received_txouts,
vec![(
0,
TxOut {
value: Amount::from_sat(42_000),
script_pubkey: spk1.clone(),
}
)]
);
let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx1, 1..);
assert!(sent_txouts.is_empty() && received_txouts.is_empty());

index.index_tx(&tx1);

let tx2 = Transaction {
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint {
txid: tx1.compute_txid(),
vout: 0,
},
..Default::default()
}],
output: vec![
TxOut {
value: Amount::from_sat(20_000),
script_pubkey: spk2.clone(),
},
TxOut {
script_pubkey: spk1.clone(),
value: Amount::from_sat(30_000),
},
],
};

let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx2, ..);
assert_eq!(
sent_txouts,
vec![(
0,
TxOut {
value: Amount::from_sat(42_000),
script_pubkey: spk1.clone(),
}
)]
);
assert_eq!(
received_txouts,
vec![
(
0,
TxOut {
value: Amount::from_sat(20_000),
script_pubkey: spk2.clone(),
}
),
(
1,
TxOut {
value: Amount::from_sat(30_000),
script_pubkey: spk1.clone(),
}
)
]
);

let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx2, ..1);
assert_eq!(
sent_txouts,
vec![(
0,
TxOut {
value: Amount::from_sat(42_000),
script_pubkey: spk1.clone(),
}
)]
);
assert_eq!(
received_txouts,
vec![(
1,
TxOut {
value: Amount::from_sat(30_000),
script_pubkey: spk1.clone(),
}
)]
);

let (sent_txouts, received_txouts) = index.sent_and_received_txouts(&tx2, 1..);
assert!(sent_txouts.is_empty());
assert_eq!(
received_txouts,
vec![(
0,
TxOut {
value: Amount::from_sat(20_000),
script_pubkey: spk2.clone(),
}
)]
);
}

#[test]
fn mark_used() {
let spk1 = ScriptBuf::from_hex("001404f1e52ce2bab3423c6a8c63b7cd730d8f12542c").unwrap();
Expand Down
Loading