From 589e76aab8a9fc806cee78bd70cf97e1fbcf9429 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 4 May 2026 14:50:34 -0300 Subject: [PATCH 1/2] fix(wallet): add filter for bip-431 rule 2 - add new step in `filter_utxos` to validate BIP-431 rule 2, which validates the proper usage of unconfirmed TRUC/non-TRUC ancestor in a TRUC/non-TRUC tx. - update docs explaining the manual selection caveat when building non-TRUC/TRUC txs. --- src/wallet/mod.rs | 37 ++++++++++++++++++++++++++++++++----- src/wallet/tx_builder.rs | 8 ++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 5bd42ba7..27b291da 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -41,8 +41,9 @@ use bitcoin::{ psbt, secp256k1::Secp256k1, sighash::{EcdsaSighashType, TapSighashType}, - transaction, Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, - Sequence, SignedAmount, Transaction, TxOut, Txid, Weight, Witness, + transaction::{self, Version}, + Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, Sequence, + SignedAmount, Transaction, TxOut, Txid, Weight, Witness, }; use miniscript::{ descriptor::KeyMap, @@ -1429,7 +1430,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 { @@ -2095,7 +2096,12 @@ 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, + version: Version, + ) -> Vec { if params.manually_selected_only { vec![] // Only process optional UTxOs if manually_selected_only is false. @@ -2105,6 +2111,7 @@ impl Wallet { .iter() .map(|wutxo| wutxo.utxo.outpoint()) .collect::>(); + self.tx_graph .graph() // Get all unspent UTxOs from wallet. @@ -2124,6 +2131,21 @@ impl Wallet { .is_mature(current_height) .then(|| new_local_utxo(k, i, full_txo)) }) + // Only add to optional UTXOs those that follows BIP-431 (TRUC) specification. + // see https://github.com/bitcoin/bips/blob/master/bip-0431.mediawiki#specification + .filter(|local_output| { + // If the output is confirmed, it can be spent by either non-TRUC/TRUC + // transactions. + if local_output.chain_position.is_confirmed() { + return true; + } + + // If building TRUC (V3), the unconfirmed outputs MUST be TRUC (V3). Otherwise, + // if building a non-TRUC, the unconfirmed outputs MUST be non-TRUC. + self.tx_graph() + .get_tx(local_output.outpoint.txid) + .is_some_and(|tx| (tx.version == Version(3)) == (version == Version(3))) + }) // only process UTXOs not selected manually, they will be considered later in the // chain // NOTE: this avoid UTXOs in both required and optional list @@ -3104,8 +3126,13 @@ mod test { let mut builder = wallet.build_tx(); builder.add_utxo(outpoint).expect("should add local utxo"); let params = builder.params.clone(); + let version = params.version.unwrap_or(Version::TWO); // 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, + version, + ); // 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/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index ec50be9a..5f26424c 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -322,6 +322,10 @@ impl<'a, Cs> TxBuilder<'a, Cs> { /// /// These have priority over the "unspendable" UTXOs, meaning that if a UTXO is present both in /// the "UTXOs" and the "unspendable" list, it will be spent. + /// + /// Manually selected UTXOs bypass optional-UTXO filtering (for example TRUC version + /// compatibility checks). Callers must ensure any manually selected unconfirmed UTXO is valid + /// for the transaction version being built. pub fn add_utxo(&mut self, outpoint: OutPoint) -> Result<&mut Self, AddUtxoError> { self.add_utxos(&[outpoint]) } @@ -339,6 +343,10 @@ impl<'a, Cs> TxBuilder<'a, Cs> { /// A manually added local UTXO will always have greater precedence than a foreign UTXO. No /// matter if it was added before or after the foreign UTXO. /// + /// Manually selected UTXOs bypass optional-UTXO filtering (for example TRUC version + /// compatibility checks). Callers must ensure any manually selected unconfirmed UTXO is valid + /// for the transaction version being built. + /// /// At a minimum to add a foreign UTXO we need: /// /// 1. `outpoint`: To add it to the raw transaction. From 7982b6e8fe0b47984d0b148b325f8b4bd9e44481 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 30 Mar 2026 15:13:34 -0300 Subject: [PATCH 2/2] test(wallet): add test for bip-431 rule 2 - introduce `test_create_and_spend_from_tx` to exercise BIP-431 rule-2 filtering in-memory, not relying on `bdk_testenv`/`bdk_electrum`. NOTE: I asked Claude to remove the `bdk_testenv` dependency, keeping the test behavior in-memory. Co-Authored-By: Claude Opus 4.8 --- tests/wallet.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/wallet.rs b/tests/wallet.rs index 0204cfd5..18621a3a 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use assert_matches::assert_matches; use bdk_chain::{BlockId, CanonicalizationParams, ConfirmationBlockTime}; use bdk_wallet::coin_selection; +use bdk_wallet::coin_selection::InsufficientFunds; use bdk_wallet::descriptor::{calc_checksum, DescriptorError, IntoWalletDescriptor}; use bdk_wallet::error::CreateTxError; use bdk_wallet::psbt::PsbtUtils; @@ -3331,3 +3332,141 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() { "UTXOs should be ordered with required first, then selected" ); } + +#[test] +fn test_create_and_spend_from_truc_tx() -> anyhow::Result<()> { + 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("should create wallet successfully!"); + + // establish a chain tip so confirmed funds can be anchored to a block in the active chain. + let block = BlockId { + height: 1_000, + hash: BlockHash::all_zeros(), + }; + insert_checkpoint(&mut wallet, block); + let anchor = ConfirmationBlockTime { + block_id: block, + confirmation_time: 0, + }; + + // add funds to the wallet (two 250k sats confirmed UTXOs) + receive_output(&mut wallet, Amount::from_sat(250_000), anchor); + receive_output(&mut wallet, Amount::from_sat(250_000), anchor); + + let balance = wallet.balance(); + assert_eq!( + balance.total(), + Amount::from_sat(500_000), + "wallet balance SHOULD be 500K after funding" + ); + + // Should be able to create TRUC (v3) transactions. + // NOTE: "A TRUC transaction can spend outputs from confirmed non-TRUC transactions. A non-TRUC + // transaction can spend outputs from confirmed TRUC transactions" See, rule #2: https://github.com/bitcoin/bips/blob/master/bip-0431.mediawiki#specification + + // create txA (TRUC) + let recv_addr = wallet.next_unused_address(KeychainKind::External); + + let mut builder = wallet.build_tx(); + builder.add_recipient(recv_addr.script_pubkey(), Amount::from_sat(125_000)); + builder.version(3); + + let mut psbt = builder.finish().expect("should create txA (TRUC) successfully! as per BIP-431 it can spend confirmed outputs from non-TRUC txs."); + + let _ = wallet.sign(&mut psbt, SignOptions::default())?; + let tx_a = psbt.extract_tx()?; + let txid_a = tx_a.compute_txid(); + + // "broadcast" txA (TRUC): insert it into the wallet's local view as an unconfirmed tx. + insert_tx(&mut wallet, tx_a); + + let balance = wallet.balance(); + assert_eq!( + balance.untrusted_pending, + Amount::from_sat(125_000), + "wallet balance SHOULD have 125K unconfirmed (TRUC) UTXO after txA!" + ); + + // create txB (non-TRUC) + let recv_addr = wallet.next_unused_address(KeychainKind::External); + + let mut builder = wallet.build_tx(); + builder.add_recipient(recv_addr.script_pubkey(), Amount::from_sat(125_000)); + + let mut psbt = builder + .finish() + .expect("SHOULD create txB (non-TRUC) successfully! However, a non-TRUC transaction can only spend confirmed outputs from TRUC transactions"); + + let _ = wallet.sign(&mut psbt, SignOptions::default()); + let tx_b = psbt.extract_tx()?; + + // txB MUST NOT use the available unconfirmed TRUC UTXO. + assert!( + tx_b.input + .iter() + .all(|txin| txin.previous_output.txid.ne(&txid_a)), + "SHOULD NOT try to spend an unconfirmed TRUC output in a non-TRUC tx!" + ); + + // "broadcast" txB (non-TRUC) + let txid_b = tx_b.compute_txid(); + insert_tx(&mut wallet, tx_b); + + let balance = wallet.balance(); + assert_eq!( + balance.untrusted_pending, + Amount::from_sat(250_000), + "wallet balance SHOULD have 250K unconfirmed, both non-TRUC (txB) and TRUC (txA) UTXOs after txB!" + ); + + // create txC (TRUC) + let recv_addr = wallet.next_unused_address(KeychainKind::External); + + let mut builder = wallet.build_tx(); + builder.add_recipient(recv_addr.script_pubkey(), Amount::from_sat(200_000)); + builder.version(3); + + let mut psbt = builder.finish().expect("should create txC (TRUC) successfully! as per BIP-431 it can spend unconfirmed outputs from TRUC txs."); + + let _ = wallet.sign(&mut psbt, SignOptions::default())?; + let tx_c = psbt.extract_tx()?; + + // txC MUST ONLY use the available confirmed UTXOs AND/OR unconfirmed TRUC UTXOs. + assert!( + tx_c.input + .iter() + .all(|txin| txin.previous_output.txid.ne(&txid_b)), + "SHOULD NOT try to spend an unconfirmed non-TRUC output in a TRUC tx!" + ); + + // "broadcast" txC (TRUC) + insert_tx(&mut wallet, tx_c); + + let balance = wallet.balance(); + assert_eq!( + balance.untrusted_pending, + Amount::from_sat(325_000), + "wallet balance SHOULD have 325K unconfirmed UTXOs after txC!" + ); + + // create txD (non-TRUC) + let recv_addr = wallet.next_unused_address(KeychainKind::External); + + let mut builder = wallet.build_tx(); + builder.add_recipient(recv_addr.script_pubkey(), Amount::from_sat(400_000)); + + let psbt = builder.finish(); + + assert!( + matches!( + psbt, + Err(CreateTxError::CoinSelection(InsufficientFunds { .. })) + ), + "SHOULD fail if it's trying to spend an unconfirmed TRUC output in a non-TRUC tx!" + ); + + Ok(()) +}