diff --git a/src/wallet/error.rs b/src/wallet/error.rs index ddd07478..07ae5af5 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -215,6 +215,16 @@ pub enum CreateTxError { MissingNonWitnessUtxo(OutPoint), /// Miniscript PSBT error MiniscriptPsbt(MiniscriptPsbtError), + /// TRUC (BIP-431) virtual size cap exceeded. + /// + /// `cap_vb == 10_000` means Rule 4 (any TRUC tx). + /// `cap_vb == 1_000` means Rule 5 (TRUC tx with unconfirmed TRUC ancestor). + TrucSizeExceeded { + /// The cap that was exceeded, in virtual bytes. + cap_vb: u64, + /// The estimated virtual size of the candidate transaction. + actual_vb: u64, + }, } impl fmt::Display for CreateTxError { @@ -281,6 +291,12 @@ impl fmt::Display for CreateTxError { CreateTxError::MiniscriptPsbt(err) => { write!(f, "Miniscript PSBT error: {err}") } + CreateTxError::TrucSizeExceeded { cap_vb, actual_vb } => { + write!( + f, + "TRUC virtual size cap exceeded: estimated {actual_vb} vB > {cap_vb} vB" + ) + } } } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 27b291da..a22bb969 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,39 @@ 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 { + return Err(CreateTxError::TrucSizeExceeded { + cap_vb, + actual_vb: estimated_vb, + }); + } + } + let psbt = self.complete_transaction(tx, coin_selection.selected, params)?; // Recording changes to the change keychain. @@ -3030,6 +3080,21 @@ 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)) +} + +/// 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)] diff --git a/tests/wallet.rs b/tests/wallet.rs index 18621a3a..534f8c98 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -3470,3 +3470,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::TrucSizeExceeded { cap_vb: 10_000, .. }) + ); + + 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::TrucSizeExceeded { cap_vb: 1_000, .. }) + ); + + 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(()) +}