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
101 changes: 97 additions & 4 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1114,14 +1114,107 @@ impl Wallet {

/// Return the balance, separated into available, trusted-pending, untrusted-pending, and
/// immature values.
///
/// A pending UTXO is `trusted_pending` if every unconfirmed ancestor transaction
/// was created by this wallet.
/// Otherwise it is `untrusted_pending`.
pub fn balance(&self) -> Balance {
self.tx_graph.graph().balance(
let graph = self.tx_graph.graph();
let chain_tip = self.chain.tip().block_id();

// TODO: simplify with CanonicalView::txout() once released on bdk_chain
let canonical_txs: HashMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)> =
graph
.list_canonical_txs(&self.chain, chain_tip, CanonicalizationParams::default())
.map(|ctx| {
(
ctx.tx_node.txid,
(ctx.tx_node.tx.clone(), ctx.chain_position),
)
})
.collect();

let mut immature = Amount::ZERO;
let mut trusted_pending = Amount::ZERO;
let mut untrusted_pending = Amount::ZERO;
let mut confirmed = Amount::ZERO;

for (_spk_i, txout) in graph.filter_chain_unspents(
&self.chain,
self.chain.tip().block_id(),
chain_tip,
CanonicalizationParams::default(),
self.tx_graph.index.outpoints().iter().cloned(),
|&(k, _), _| k == KeychainKind::Internal,
)
) {
match &txout.chain_position {
ChainPosition::Confirmed { .. } => {
// TODO: Check with min_confirmation
if txout.is_confirmed_and_spendable(chain_tip.height) {
confirmed += txout.txout.value;
} else if !txout.is_mature(chain_tip.height) {
immature += txout.txout.value;
}
}

ChainPosition::Unconfirmed { .. } => {
let mut txids: Vec<Txid> = vec![txout.outpoint.txid];
let mut visited_tx: HashSet<Txid> = HashSet::new();
let mut trusted: bool = true;

'trust_check: while let Some(current_txid) = txids.pop() {
if !visited_tx.contains(&current_txid) {
let Some((tx, _)) = canonical_txs.get(&current_txid) else {
trusted = false;
break;
};

for input in tx.input.iter() {
if !&input.previous_output.is_null() {
let Some((parent_tx, parent_chain_pos)) =
canonical_txs.get(&input.previous_output.txid)
else {
trusted = false;
break 'trust_check;
};
// TODO: Check with min_confirmation
if !parent_chain_pos.is_confirmed() {
let vout = input.previous_output.vout as usize;
let is_ours = parent_tx
.output
.get(vout)
.map(|o| {
self.tx_graph
.index
.index_of_spk(o.script_pubkey.clone())
.is_some()
})
.unwrap_or(false);
if is_ours {
txids.push(input.previous_output.txid);
} else {
trusted = false;
break 'trust_check;
}
}
}
}
visited_tx.insert(current_txid);
}
}
if trusted {
trusted_pending += txout.txout.value;
} else {
untrusted_pending += txout.txout.value;
}
}
}
}

Balance {
immature,
trusted_pending,
untrusted_pending,
confirmed,
}
}

/// Add an external signer
Expand Down
257 changes: 257 additions & 0 deletions tests/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3007,3 +3007,260 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() {
"UTXOs should be ordered with required first, then selected"
);
}

#[test]
fn test_trusted_pending_balance_from_owned_outpoints() {
let (mut wallet, txid) = get_funded_wallet_wpkh();
let tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx.clone());

let balance = wallet.balance();

assert!(balance.trusted_pending > Amount::ZERO);
assert_eq!(balance.untrusted_pending, Amount::ZERO);
}

#[test]
fn test_untrusted_pending_balance_from_external_inputs() {
let (descriptor, change_descriptor) = get_test_wpkh_and_change_desc();
let mut wallet = Wallet::create(descriptor, change_descriptor)
.network(Network::Regtest)
.create_wallet_no_persist()
.expect("wallet");

let txid = Txid::from_raw_hash(Hash::all_zeros());

let tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx.clone());

let balance = wallet.balance();

assert!(balance.untrusted_pending > Amount::ZERO);
assert_eq!(balance.trusted_pending, Amount::ZERO);
}

#[test]
fn test_trusted_pending_transitive_chain() {
let (mut wallet, txid) = get_funded_wallet_wpkh();

let tx_a = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

let tx_a_txid = tx_a.compute_txid();
insert_tx(&mut wallet, tx_a);

let tx_b = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: tx_a_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};
insert_tx(&mut wallet, tx_b);

let balance = wallet.balance();

assert!(balance.trusted_pending > Amount::ZERO);
assert_eq!(balance.untrusted_pending, Amount::ZERO);
}

#[test]
fn test_pay_to_internal_from_not_trusted() {
let (mut wallet, _) = get_funded_wallet_wpkh();

// Build a tx whose input comes from an unknown (external) outpoint,
// but whose output goes to our change (internal) keychain address.
let external_txid = Txid::from_raw_hash(Hash::all_zeros());
let tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: external_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(500),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx);

let balance = wallet.balance();

// The output is ours but the input is not owned, so it must be untrusted_pending.
assert!(balance.untrusted_pending > Amount::ZERO);
assert_eq!(balance.trusted_pending, Amount::ZERO);
}

#[test]
fn test_trusted_pending_does_not_propagate_through_foreign_outputs() {
let (mut wallet, txid) = get_funded_wallet_wpkh();

let foreign_addr = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5")
.expect("valid address")
.require_network(Network::Regtest)
.unwrap();

let tx_a = Transaction {
input: vec![TxIn {
previous_output: OutPoint { txid, vout: 0 },
..Default::default()
}],
output: vec![
TxOut {
value: Amount::from_sat(25_000),
script_pubkey: foreign_addr.script_pubkey(),
},
TxOut {
value: Amount::from_sat(24_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
},
],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};
let tx_a_txid = tx_a.compute_txid();
insert_tx(&mut wallet, tx_a);

let tx_b = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: tx_a_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(20_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};
insert_tx(&mut wallet, tx_b);

let balance = wallet.balance();

assert!(balance.trusted_pending > Amount::ZERO);
assert!(balance.untrusted_pending > Amount::ZERO);
}

#[test]
fn test_spending_untrusted_is_untrusted() {
let (mut wallet, _) = get_funded_wallet_wpkh();

let external_tx = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: Txid::all_zeros(), // not owned by wallet
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(50_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::External)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

let external_txid = external_tx.compute_txid();
insert_tx(&mut wallet, external_tx);

let tx_spend = Transaction {
input: vec![TxIn {
previous_output: OutPoint {
txid: external_txid,
vout: 0,
},
..Default::default()
}],
output: vec![TxOut {
value: Amount::from_sat(45_000),
script_pubkey: wallet
.next_unused_address(KeychainKind::Internal)
.address
.script_pubkey(),
}],
version: transaction::Version::ONE,
lock_time: absolute::LockTime::ZERO,
};

insert_tx(&mut wallet, tx_spend);

let balance = wallet.balance();

assert_eq!(balance.untrusted_pending, Amount::from_sat(45_000));
assert_eq!(balance.trusted_pending, Amount::ZERO);
}