diff --git a/src/types.rs b/src/types.rs index 28d7e1f4..b0e5398e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -14,6 +14,8 @@ use chain::{ChainPosition, ConfirmationBlockTime}; use core::convert::AsRef; use core::fmt; +use crate::collections::BTreeMap; + use bitcoin::transaction::{OutPoint, Sequence, TxOut}; use bitcoin::{psbt, Weight}; @@ -141,6 +143,55 @@ impl Utxo { } } +/// The finalization status for a single PSBT input. +#[derive(Debug, PartialEq)] +pub enum FinalizeInputOutcome { + /// The input was already finalized before this call. + AlreadyFinalized, + /// The input was successfully finalized during this call. + Finalized, + /// The wallet could not derive a descriptor for the input. + MissingDescriptor, + /// The wallet found the descriptor but could not construct the input satisfaction. + CouldNotSatisfy(miniscript::Error), +} + +impl FinalizeInputOutcome { + /// Whether the input is finalized after this call. + pub fn is_finalized(&self) -> bool { + matches!(self, Self::AlreadyFinalized | Self::Finalized) + } +} + +/// The outcome of a PSBT finalization attempt. +#[derive(Debug, PartialEq)] +pub struct FinalizePsbtOutcome { + outcomes: BTreeMap, +} + +impl FinalizePsbtOutcome { + pub(crate) fn new(outcomes: BTreeMap) -> Self { + Self { outcomes } + } + + /// Whether all inputs are finalized after this call. + pub fn is_finalized(&self) -> bool { + self.outcomes + .values() + .all(FinalizeInputOutcome::is_finalized) + } + + /// Borrow the per-input finalization outcomes. + pub fn outcomes(&self) -> &BTreeMap { + &self.outcomes + } + + /// Consume the collection and return the per-input finalization outcomes. + pub fn into_outcomes(self) -> BTreeMap { + self.outcomes + } +} + /// Index out of bounds error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct IndexOutOfBoundsError { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 60d1ccef..d08e63ef 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1917,8 +1917,61 @@ impl Wallet { } }) .collect::>(); + let current_height = sign_options + .assume_height + .unwrap_or_else(|| self.chain.tip().height()); + + Ok(self + .try_finalize_psbt_with( + psbt, + Some(current_height), + |_, input| { + confirmation_heights + .get(&input.previous_output.txid) + .copied() + }, + true, + )? + .is_finalized()) + } + + /// Attempt to finalize each input of a PSBT and return per-input finalization results. + /// + /// Use this method when you need to inspect why a specific input could not be finalized. Call + /// [`FinalizePsbtOutcome::is_finalized`] on the returned value to check whether all inputs are + /// finalized after the call. + /// + /// Per-input finalization failures are reported as [`FinalizeInputOutcome`] values. This method + /// only returns `Err` when the PSBT is malformed, for example if its inputs are out of bounds. + /// + /// Timelock satisfaction is evaluated from the PSBT transaction fields. This method does not + /// redact or clear output metadata. + pub fn try_finalize_psbt( + &self, + psbt: &mut Psbt, + ) -> Result { + self.try_finalize_psbt_with(psbt, None, |_, _| None, false) + } + + fn try_finalize_psbt_with( + &self, + psbt: &mut Psbt, + current_height: Option, + mut confirmation_height_for_input: F, + clear_output_derivations: bool, + ) -> Result + where + F: FnMut(usize, &bitcoin::TxIn) -> Option, + { + let tx = &psbt.unsigned_tx; + if psbt.inputs.len() < tx.input.len() { + return Err(IndexOutOfBoundsError::new( + psbt.inputs.len(), + psbt.inputs.len(), + )); + } - let mut finished = true; + let mut outcomes = BTreeMap::new(); for (n, input) in tx.input.iter().enumerate() { let psbt_input = &psbt @@ -1926,14 +1979,9 @@ impl Wallet { .get(n) .ok_or(IndexOutOfBoundsError::new(n, psbt.inputs.len()))?; if psbt_input.final_script_sig.is_some() || psbt_input.final_script_witness.is_some() { + outcomes.insert(n, FinalizeInputOutcome::AlreadyFinalized); continue; } - let confirmation_height = confirmation_heights - .get(&input.previous_output.txid) - .copied(); - let current_height = sign_options - .assume_height - .unwrap_or_else(|| self.chain.tip().height()); // - Try to derive the descriptor by looking at the txout. If it's in our database, we // know exactly which `keychain` to use, and which derivation index it is. @@ -1953,48 +2001,66 @@ impl Wallet { match desc { Some(desc) => { let mut tmp_input = bitcoin::TxIn::default(); - match desc.satisfy( - &mut tmp_input, - ( - PsbtInputSatisfier::new(psbt, n), - After::new(Some(current_height), false), - Older::new(Some(current_height), confirmation_height, false), - ), - ) { + let satisfy_result = if let Some(current_height) = current_height { + let confirmation_height = confirmation_height_for_input(n, input); + desc.satisfy( + &mut tmp_input, + ( + PsbtInputSatisfier::new(psbt, n), + After::new(Some(current_height), false), + Older::new(Some(current_height), confirmation_height, false), + ), + ) + } else { + desc.satisfy(&mut tmp_input, PsbtInputSatisfier::new(psbt, n)) + }; + + match satisfy_result { Ok(_) => { let length = psbt.inputs.len(); - // Set the UTXO fields, final script_sig and witness - // and clear everything else. let psbt_input = psbt .inputs .get_mut(n) .ok_or(IndexOutOfBoundsError::new(n, length))?; let original = mem::take(psbt_input); - psbt_input.non_witness_utxo = original.non_witness_utxo; - psbt_input.witness_utxo = original.witness_utxo; - if !tmp_input.script_sig.is_empty() { - psbt_input.final_script_sig = Some(tmp_input.script_sig); - } - if !tmp_input.witness.is_empty() { - psbt_input.final_script_witness = Some(tmp_input.witness); - } + let final_script_sig = + (!tmp_input.script_sig.is_empty()).then_some(tmp_input.script_sig); + let final_script_witness = + (!tmp_input.witness.is_empty()).then_some(tmp_input.witness); + + // BIP174 finalization clears input metadata except UTXOs, final scripts, + // and opaque fields the finalizer does not understand. + *psbt_input = bitcoin::psbt::Input { + non_witness_utxo: original.non_witness_utxo, + witness_utxo: original.witness_utxo, + final_script_sig, + final_script_witness, + proprietary: original.proprietary, + unknown: original.unknown, + ..Default::default() + }; + outcomes.insert(n, FinalizeInputOutcome::Finalized); + } + Err(err) => { + outcomes.insert(n, FinalizeInputOutcome::CouldNotSatisfy(err)); } - Err(_) => finished = false, } } - None => finished = false, + None => { + outcomes.insert(n, FinalizeInputOutcome::MissingDescriptor); + } } } - // Clear derivation paths from outputs. - if finished { + let finalized = FinalizePsbtOutcome::new(outcomes); + if clear_output_derivations && finalized.is_finalized() { for output in &mut psbt.outputs { output.bip32_derivation.clear(); output.tap_key_origins.clear(); } } - Ok(finished) + Ok(finalized) } /// Return the secp256k1 context used for all signing operations. diff --git a/tests/wallet.rs b/tests/wallet.rs index 47afa502..0204cfd5 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -10,7 +10,10 @@ use bdk_wallet::psbt::PsbtUtils; use bdk_wallet::signer::{SignOptions, SignerError, SignersContainer}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; -use bdk_wallet::{AddressInfo, Balance, PersistedWallet, Update, Wallet, WalletTx}; +use bdk_wallet::{ + AddressInfo, Balance, FinalizeInputOutcome, IndexOutOfBoundsError, PersistedWallet, Update, + Wallet, WalletTx, +}; use bitcoin::constants::COINBASE_MATURITY; use bitcoin::hashes::Hash; use bitcoin::script::PushBytesBuf; @@ -1606,6 +1609,275 @@ fn test_try_finalize_sign_option() { } } +#[test] +fn test_try_finalize_psbt_outcomes() { + { + let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.drain_to(addr.script_pubkey()).drain_wallet(); + let mut psbt = builder.finish().unwrap(); + + let is_final = wallet + .sign( + &mut psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + ) + .unwrap(); + assert!(!is_final); + let output_bip32_derivations = psbt + .outputs + .iter() + .map(|output| output.bip32_derivation.clone()) + .collect::>(); + assert!( + output_bip32_derivations + .iter() + .any(|derivation| !derivation.is_empty()), + "expected wallet-owned outputs to retain derivation data before finalization" + ); + + let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap(); + + assert!(finalized.is_finalized()); + assert_matches!( + finalized.outcomes().get(&0), + Some(FinalizeInputOutcome::Finalized) + ); + assert!( + psbt.inputs[0].final_script_sig.is_some() + || psbt.inputs[0].final_script_witness.is_some() + ); + assert_eq!( + psbt.outputs + .iter() + .map(|output| output.bip32_derivation.clone()) + .collect::>(), + output_bip32_derivations + ); + + let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap(); + + assert!(finalized.is_finalized()); + assert_matches!( + finalized.outcomes().get(&0), + Some(FinalizeInputOutcome::AlreadyFinalized) + ); + } + + { + let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000)); + let mut psbt = builder.finish().unwrap(); + + let dud_input = bitcoin::psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: miniscript::Descriptor::::from_str( + "wpkh(025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357)", + ) + .unwrap() + .script_pubkey(), + }), + ..Default::default() + }; + + psbt.inputs.push(dud_input); + psbt.unsigned_tx.input.push(bitcoin::TxIn::default()); + + let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap(); + + assert!(!finalized.is_finalized()); + assert_matches!( + finalized.outcomes().get(&0), + Some(FinalizeInputOutcome::CouldNotSatisfy( + bdk_wallet::miniscript::Error::MissingSig(_) + )) + ); + assert_matches!( + finalized.outcomes().get(&1), + Some(FinalizeInputOutcome::MissingDescriptor) + ); + } +} + +#[test] +fn test_try_finalize_psbt_preserves_opaque_input_fields() { + let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.drain_to(addr.script_pubkey()).drain_wallet(); + let mut psbt = builder.finish().unwrap(); + + wallet + .sign( + &mut psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + ) + .unwrap(); + + let proprietary_key = bitcoin::psbt::raw::ProprietaryKey { + prefix: b"bdk-test".to_vec(), + subtype: 0, + key: vec![1, 2, 3], + }; + let proprietary_value = vec![4, 5, 6]; + let unknown_key = bitcoin::psbt::raw::Key { + type_value: 0x42, + key: vec![7, 8, 9], + }; + let unknown_value = vec![10, 11, 12]; + + psbt.inputs[0] + .proprietary + .insert(proprietary_key.clone(), proprietary_value.clone()); + psbt.inputs[0] + .unknown + .insert(unknown_key.clone(), unknown_value.clone()); + + let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap(); + + assert!(finalized.is_finalized()); + assert_eq!( + psbt.inputs[0].proprietary.get(&proprietary_key), + Some(&proprietary_value) + ); + assert_eq!( + psbt.inputs[0].unknown.get(&unknown_key), + Some(&unknown_value) + ); +} + +#[test] +fn test_try_finalize_psbt_returns_index_out_of_bounds_for_malformed_psbt() { + { + let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.drain_to(addr.script_pubkey()).drain_wallet(); + let mut psbt = builder.finish().unwrap(); + + psbt.inputs.clear(); + + let err = wallet.try_finalize_psbt(&mut psbt).unwrap_err(); + + assert_eq!(err, IndexOutOfBoundsError::new(0, 0)); + } + + { + let (mut wallet, _) = get_funded_wallet_single(get_test_wpkh()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.drain_to(addr.script_pubkey()).drain_wallet(); + let mut psbt = builder.finish().unwrap(); + + wallet + .sign( + &mut psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + ) + .unwrap(); + assert!(psbt.inputs[0].final_script_sig.is_none()); + assert!(psbt.inputs[0].final_script_witness.is_none()); + + psbt.unsigned_tx + .input + .push(psbt.unsigned_tx.input[0].clone()); + + let err = wallet.try_finalize_psbt(&mut psbt).unwrap_err(); + + assert_eq!(err, IndexOutOfBoundsError::new(1, 1)); + assert!(psbt.inputs[0].final_script_sig.is_none()); + assert!(psbt.inputs[0].final_script_witness.is_none()); + } +} + +#[test] +fn test_try_finalize_psbt_uses_psbt_timelocks() { + { + let (mut wallet, _) = get_funded_wallet_single(get_test_single_sig_cltv()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000)); + let mut psbt = builder.finish().unwrap(); + + wallet + .sign( + &mut psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + ) + .unwrap(); + + let mut valid_psbt = psbt.clone(); + assert!(wallet + .try_finalize_psbt(&mut valid_psbt) + .unwrap() + .is_finalized()); + + psbt.unsigned_tx.lock_time = absolute::LockTime::from_height(0).unwrap(); + + let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap(); + + assert!(!finalized.is_finalized()); + assert_matches!( + finalized.outcomes().get(&0), + Some(FinalizeInputOutcome::CouldNotSatisfy(_)) + ); + assert!(psbt.inputs[0].final_script_sig.is_none()); + assert!(psbt.inputs[0].final_script_witness.is_none()); + } + + { + let (mut wallet, _) = get_funded_wallet_single(get_test_single_sig_csv()); + let addr = wallet.next_unused_address(KeychainKind::External); + let mut builder = wallet.build_tx(); + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000)); + let mut psbt = builder.finish().unwrap(); + + wallet + .sign( + &mut psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + ) + .unwrap(); + + let mut valid_psbt = psbt.clone(); + assert!(wallet + .try_finalize_psbt(&mut valid_psbt) + .unwrap() + .is_finalized()); + + psbt.unsigned_tx.input[0].sequence = Sequence::MAX; + + let finalized = wallet.try_finalize_psbt(&mut psbt).unwrap(); + + assert!(!finalized.is_finalized()); + assert_matches!( + finalized.outcomes().get(&0), + Some(FinalizeInputOutcome::CouldNotSatisfy(_)) + ); + assert!(psbt.inputs[0].final_script_sig.is_none()); + assert!(psbt.inputs[0].final_script_witness.is_none()); + } +} + #[test] fn test_taproot_try_finalize_sign_option() { let (mut wallet, _) = get_funded_wallet_single(get_test_tr_with_taptree());