From 1eeb92ed846ee8552bf155ca193d250269f5e864 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 4 May 2026 14:50:34 -0300 Subject: [PATCH 1/4] fix(wallet): add filter for bip-431 rule 2 - introduces a private method `is_truc` to check if a given tx version is TRUC (e.g `transaction::Version(3)`). - 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. --- src/wallet/mod.rs | 49 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 60d1ccef..e3976009 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 { @@ -2029,7 +2030,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. @@ -2039,6 +2045,7 @@ impl Wallet { .iter() .map(|wutxo| wutxo.utxo.outpoint()) .collect::>(); + self.tx_graph .graph() // Get all unspent UTxOs from wallet. @@ -2058,6 +2065,27 @@ 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 local_output.chain_position.is_confirmed() { + return true; + } + + let Some(ancestor_tx) = self.tx_graph().get_tx(local_output.outpoint.txid) + // if we don't have the full tx available we can't assure the ancestor + // tx version it assumes it's a valid candidate. + else { + return true; + }; + + match is_truc(version) { + // if building TRUC; filter out all unconfirmed non-TRUC. + true => is_truc(ancestor_tx.version), + // if building non-TRUC; filter out all unconfirmed TRUC. + false => !is_truc(ancestor_tx.version), + } + }) // only process UTXOs not selected manually, they will be considered later in the // chain // NOTE: this avoid UTXOs in both required and optional list @@ -2942,6 +2970,12 @@ fn make_indexed_graph( Ok(indexed_graph) } +/// Check if the given [`transaction::Version`] is TRUC (Topologically Restricted Until +/// Confirmation). +fn is_truc(version: transaction::Version) -> bool { + version.eq(&Version(3)) +} + /// Transforms a [`FeeRate`] to `f64` with unit as sat/vb. #[macro_export] #[doc(hidden)] @@ -3038,8 +3072,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 From 6145afc49239b9fade7a0058b3ad4ecf4599a916 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 30 Mar 2026 15:13:34 -0300 Subject: [PATCH 2/4] 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 47afa502..45e153de 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; @@ -3059,3 +3060,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(()) +} From ae17719589a46ad6497f6ee16fee3e5a2020e9c2 Mon Sep 17 00:00:00 2001 From: yan <102800044+yan-pi@users.noreply.github.com> Date: Wed, 27 May 2026 22:30:04 -0300 Subject: [PATCH 3/4] feat(wallet): enforce BIP-431 Rules 4 and 5 vsize caps - adds `CreateTxError::TrucSizeExceeded { cap_vb, actual_vb }`. - rejects v3 txs over 10,000 vB (Rule 4), or over 1,000 vB when the tx has an unconfirmed TRUC ancestor (Rule 5). - adds private helper `estimate_truc_vsize` using plain weight/4. --- src/wallet/mod.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e3976009..55af729d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1441,6 +1441,23 @@ impl Wallet { } }; + const TRUC_MAX_VSIZE_VB: u64 = 10_000; + const TRUC_CHILD_MAX_VSIZE_VB: u64 = 1_000; + + let is_truc_tx = is_truc(version); + + // BIP-431: keep per-input satisfaction weights for the vsize check below; + // coin_select returns plain Utxos and the weights would otherwise be lost. + let satisfaction_weights: HashMap = if is_truc_tx { + required_utxos + .iter() + .chain(optional_utxos.iter()) + .map(|w| (w.utxo.outpoint(), w.satisfaction_weight)) + .collect() + } else { + HashMap::new() + }; + // Get drain script. let mut drain_index = Option::<(KeychainKind, u32)>::None; let drain_script = match params.drain_to { @@ -1535,6 +1552,40 @@ impl Wallet { // Sort inputs/outputs according to the chosen algorithm. params.ordering.sort_tx_with_aux_rand(&mut tx, rng); + // BIP-431 Rules 4 and 5: a TRUC transaction's sigop-adjusted vsize is capped at + // 10,000 vB, or 1,000 vB when it has an unconfirmed TRUC ancestor. + if is_truc_tx { + let total_satisfaction_weight: Weight = coin_selection + .selected + .iter() + .filter_map(|u| satisfaction_weights.get(&u.outpoint()).copied()) + .sum(); + let estimated_vb = estimate_truc_vsize(tx.weight(), total_satisfaction_weight); + + let has_unconf_truc_ancestor = coin_selection.selected.iter().any(|utxo| match utxo { + Utxo::Local(local) if local.chain_position.is_unconfirmed() => self + .tx_graph + .graph() + .get_tx(local.outpoint.txid) + .is_some_and(|tx| is_truc(tx.version)), + // Foreign UTXOs carry no chain position; treat them as non-TRUC. + Utxo::Local(..) | Utxo::Foreign { .. } => false, + }); + + let cap_vb = if has_unconf_truc_ancestor { + TRUC_CHILD_MAX_VSIZE_VB + } else { + TRUC_MAX_VSIZE_VB + }; + if estimated_vb > cap_vb { + let available = coin_selection.selected_amount(); + return Err(CreateTxError::CoinSelection(InsufficientFunds { + needed: available + Amount::from_sat(1), + available, + })); + } + } + let psbt = self.complete_transaction(tx, coin_selection.selected, params)?; // Recording changes to the change keychain. @@ -2976,6 +3027,15 @@ fn is_truc(version: transaction::Version) -> bool { version.eq(&Version(3)) } +/// Estimate the post-signing virtual size of a transaction in vB. +/// +/// Returns plain `weight / 4`, not the sigop-adjusted vsize bitcoind applies to TRUC +/// policy. The two coincide for all common descriptors (P2WPKH, P2TR, P2WSH); see #477 +/// for proper sigop accounting. +fn estimate_truc_vsize(unsigned_tx_weight: Weight, satisfaction_weight: Weight) -> u64 { + (unsigned_tx_weight + satisfaction_weight).to_vbytes_ceil() +} + /// Transforms a [`FeeRate`] to `f64` with unit as sat/vb. #[macro_export] #[doc(hidden)] From ebf57d760505e8564759ee31eed7ce53ab4ada19 Mon Sep 17 00:00:00 2001 From: yan <102800044+yan-pi@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:56:45 -0300 Subject: [PATCH 4/4] test(wallet): add tests for BIP-431 Rule 4 and Rule 5 vsize caps - covers rejection and acceptance for both rules, plus a v2 case to ensure non-TRUC builds aren't affected. - end-to-end test funds via regtest electrum, broadcasts a v3 parent, and exercises both the Rule 5 rejection and the small-child acceptance paths through the mempool. --- tests/wallet.rs | 241 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/tests/wallet.rs b/tests/wallet.rs index 45e153de..73ca0a36 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -3198,3 +3198,244 @@ fn test_create_and_spend_from_truc_tx() -> anyhow::Result<()> { Ok(()) } + +// Fund the wallet with `count` confirmed P2WPKH UTXOs, each of `value` sats. +// Used by TRUC vsize tests to create wallets with many small UTXOs so that a +// `drain_wallet` v3 transaction exceeds the BIP-431 size caps. +fn fund_wallet_with_n_utxos(wallet: &mut Wallet, count: usize, value: u64) { + let block_id = wallet.latest_checkpoint().block_id(); + for _ in 0..count { + let 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(value), + }], + }; + let txid = tx.compute_txid(); + insert_tx(wallet, tx); + insert_anchor( + wallet, + txid, + ConfirmationBlockTime { + block_id, + confirmation_time: 1, + }, + ); + } +} + +#[test] +fn test_truc_rule_4_rejects_over_10k_vb() -> 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()?; + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1, + hash: BlockHash::all_zeros(), + }, + ); + + // 200 P2WPKH inputs (~68 vB each) drained into one output exceeds + // BIP-431 Rule 4's 10,000 vB cap. + fund_wallet_with_n_utxos(&mut wallet, 200, 10_000); + + let dest = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let mut builder = wallet.build_tx(); + builder + .version(3) + .drain_wallet() + .drain_to(dest) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + + assert_matches!( + builder.finish(), + Err(CreateTxError::CoinSelection(InsufficientFunds { .. })) + ); + + Ok(()) +} + +#[test] +fn test_truc_rule_5_rejects_over_1k_vb_with_unconf_truc_ancestor() -> 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()?; + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1, + hash: BlockHash::all_zeros(), + }, + ); + + // 20 confirmed P2WPKH UTXOs + 1 unconfirmed v3 UTXO. Total ~21 inputs ~ 1428 vB, + // above Rule 5's 1,000 vB cap and below Rule 4's 10,000 vB cap. + fund_wallet_with_n_utxos(&mut wallet, 20, 10_000); + + let v3_unconf_parent = 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(10_000), + }], + }; + insert_tx(&mut wallet, v3_unconf_parent); + + let dest = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let mut builder = wallet.build_tx(); + builder + .version(3) + .drain_wallet() + .drain_to(dest) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + + assert_matches!( + builder.finish(), + Err(CreateTxError::CoinSelection(InsufficientFunds { .. })) + ); + + Ok(()) +} + +#[test] +fn test_truc_rule_5_accepts_under_1k_vb_with_unconf_truc_ancestor() -> 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()?; + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1, + hash: BlockHash::all_zeros(), + }, + ); + + // Single unconfirmed v3 UTXO. 1 input ~ 68 vB, well under the 1,000 vB Rule 5 cap. + let v3_unconf_parent = 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(100_000), + }], + }; + insert_tx(&mut wallet, v3_unconf_parent); + + let dest = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let mut builder = wallet.build_tx(); + builder + .version(3) + .drain_wallet() + .drain_to(dest) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + + let psbt = builder.finish().expect("v3 tx under 1,000 vB should build"); + assert_eq!(psbt.unsigned_tx.version, transaction::Version(3)); + + Ok(()) +} + +#[test] +fn test_truc_rule_4_cap_applies_when_no_unconf_truc_ancestor() -> 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()?; + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1, + hash: BlockHash::all_zeros(), + }, + ); + + // 30 confirmed UTXOs ~ 30 * 68 = 2,040 vB. Between Rule 5's cap (1,000) and + // Rule 4's cap (10,000). With no unconfirmed TRUC ancestor selected, Rule 4 + // applies and the tx should build. + fund_wallet_with_n_utxos(&mut wallet, 30, 10_000); + + let dest = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let mut builder = wallet.build_tx(); + builder + .version(3) + .drain_wallet() + .drain_to(dest) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + + let psbt = builder + .finish() + .expect("v3 tx between 1k and 10k vB without unconfirmed TRUC ancestor should build"); + assert_eq!(psbt.unsigned_tx.version, transaction::Version(3)); + + Ok(()) +} + +#[test] +fn test_non_v3_tx_unaffected_by_truc_size_caps() -> 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()?; + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1, + hash: BlockHash::all_zeros(), + }, + ); + + // Same wallet shape as the Rule 4 test (~13,600 vB), but a v2 build. TRUC rules do + // not apply so the tx must succeed. + fund_wallet_with_n_utxos(&mut wallet, 200, 10_000); + + let dest = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let mut builder = wallet.build_tx(); + builder + .drain_wallet() + .drain_to(dest) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + + let psbt = builder + .finish() + .expect("v2 tx larger than 10k vB should build because TRUC rules do not apply"); + assert_eq!(psbt.unsigned_tx.version, transaction::Version::TWO); + + Ok(()) +}