diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b4eb7709..fe40846b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1429,7 +1429,7 @@ impl Wallet { let (required_utxos, optional_utxos) = { // NOTE: manual selection overrides unspendable let mut required: Vec = params.utxos.clone(); - let optional = self.filter_utxos(¶ms, current_height.to_consensus_u32()); + let optional = self.filter_utxos(¶ms, current_height.to_consensus_u32(), version); // If `drain_wallet` is true, all UTxOs are required. if params.drain_wallet { @@ -1978,18 +1978,24 @@ impl Wallet { /// Given the options returns the list of utxos that must be used to form the /// transaction and any further that may be used if needed. - fn filter_utxos(&self, params: &TxParams, current_height: u32) -> Vec { + fn filter_utxos( + &self, + params: &TxParams, + current_height: u32, + tx_version: transaction::Version, + ) -> Vec { if params.manually_selected_only { vec![] // Only process optional UTxOs if manually_selected_only is false. } else { + let tx_graph = self.tx_graph.graph(); + let excludes_unconfirmed_v3 = tx_version != transaction::Version(3); let manually_selected_outpoints = params .utxos .iter() .map(|wutxo| wutxo.utxo.outpoint()) .collect::>(); - self.tx_graph - .graph() + tx_graph // Get all unspent UTxOs from wallet. // NOTE: the UTxOs returned by the following method already belong to wallet as the // call chain uses get_tx_node infallibly. @@ -2023,6 +2029,15 @@ impl Wallet { .filter(|local_output| { params.bumping_fee.is_none() || local_output.chain_position.is_confirmed() }) + // Bitcoin Core's TRUC policy requires spending unconfirmed v3 outputs with a v3 + // transaction. + .filter(|local_output| { + !excludes_unconfirmed_v3 + || local_output.chain_position.is_confirmed() + || tx_graph + .get_tx(local_output.outpoint.txid) + .is_none_or(|tx| tx.version != transaction::Version(3)) + }) .map(|utxo| WeightedUtxo { satisfaction_weight: self .public_descriptor(utxo.keychain) @@ -2982,7 +2997,11 @@ mod test { builder.add_utxo(outpoint).expect("should add local utxo"); let params = builder.params.clone(); // enforce selection of first output in transaction - let received = wallet.filter_utxos(¶ms, wallet.latest_checkpoint().block_id().height); + let received = wallet.filter_utxos( + ¶ms, + wallet.latest_checkpoint().block_id().height, + transaction::Version::TWO, + ); // Notice expected doesn't include the first output from two_output_tx as it should be // filtered out. let expected = vec![wallet diff --git a/tests/wallet.rs b/tests/wallet.rs index c6048593..294e09e6 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -235,6 +235,131 @@ fn test_create_tx_custom_version() { assert_eq!(psbt.unsigned_tx.version.0, 42); } +#[test] +fn test_create_tx_non_v3_excludes_unconfirmed_v3_utxos() { + 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"); + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1, + hash: BlockHash::all_zeros(), + }, + ); + + let confirmed_tx = Transaction { + version: transaction::Version::ONE, + lock_time: absolute::LockTime::ZERO, + input: vec![], + output: vec![TxOut { + script_pubkey: wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(), + value: Amount::from_sat(10_000), + }], + }; + let confirmed_txid = confirmed_tx.compute_txid(); + insert_tx(&mut wallet, confirmed_tx); + let block_id = wallet.latest_checkpoint().block_id(); + insert_anchor( + &mut wallet, + confirmed_txid, + ConfirmationBlockTime { + block_id, + confirmation_time: 1, + }, + ); + + let truc_tx = Transaction { + version: transaction::Version(3), + lock_time: absolute::LockTime::ZERO, + input: vec![], + output: vec![TxOut { + script_pubkey: wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(), + value: Amount::from_sat(30_000), + }], + }; + let truc_txid = truc_tx.compute_txid(); + insert_tx(&mut wallet, truc_tx); + + let recipient = wallet.next_unused_address(KeychainKind::External); + + let mut builder = wallet.build_tx(); + builder + .fee_rate(FeeRate::ZERO) + .add_recipient(recipient.script_pubkey(), Amount::from_sat(20_000)); + assert_matches!( + builder.finish(), + Err(CreateTxError::CoinSelection( + coin_selection::InsufficientFunds { .. } + )) + ); + + let mut builder = wallet.build_tx(); + builder + .fee_rate(FeeRate::ZERO) + .version(3) + .add_recipient(recipient.script_pubkey(), Amount::from_sat(20_000)); + let psbt = builder + .finish() + .expect("v3 transaction should be buildable"); + + assert_eq!(psbt.unsigned_tx.version, transaction::Version(3)); + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output.txid == truc_txid), + "version=3 transaction should be able to spend the unconfirmed v3 output" + ); +} + +#[test] +fn test_create_tx_non_v3_allows_unconfirmed_non_v3_utxos() { + 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 unconfirmed_tx = Transaction { + version: transaction::Version::ONE, + lock_time: absolute::LockTime::ZERO, + input: vec![], + output: vec![TxOut { + script_pubkey: wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(), + value: Amount::from_sat(30_000), + }], + }; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); + insert_tx(&mut wallet, unconfirmed_tx); + + let recipient = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder + .fee_rate(FeeRate::ZERO) + .add_recipient(recipient.script_pubkey(), Amount::from_sat(20_000)); + let psbt = builder + .finish() + .expect("non-v3 unconfirmed outputs should remain spendable"); + + assert!( + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output.txid == unconfirmed_txid), + "expected the unconfirmed non-v3 output to be available for selection" + ); +} + #[test] fn test_create_tx_default_locktime_is_last_sync_height() { let (mut wallet, _) = get_funded_wallet_wpkh();