diff --git a/bdk-ffi/src/bitcoin.rs b/bdk-ffi/src/bitcoin.rs index bd7fc486..6b113d9e 100644 --- a/bdk-ffi/src/bitcoin.rs +++ b/bdk-ffi/src/bitcoin.rs @@ -40,6 +40,7 @@ use bdk_wallet::bitcoin::Wtxid as BitcoinWtxid; use bdk_wallet::miniscript::psbt::PsbtExt; use bdk_wallet::serde_json; +use std::convert::TryFrom; use std::fmt::Display; use std::fs::File; use std::io::{BufReader, BufWriter}; @@ -669,6 +670,8 @@ pub struct Input { pub unknown: HashMap>, } +use crate::error::AddForeignUtxoError; + impl From<&BdkInput> for Input { fn from(input: &BdkInput) -> Self { Input { @@ -809,6 +812,338 @@ impl From<&BdkInput> for Input { } } +impl TryFrom for BdkInput { + type Error = AddForeignUtxoError; + + fn try_from(input: Input) -> Result { + use bdk_wallet::bitcoin::ecdsa; + use bdk_wallet::bitcoin::hashes::Hash as HashTrait; + use bdk_wallet::bitcoin::key::PublicKey as Secp256k1PublicKey; + use bdk_wallet::bitcoin::psbt::PsbtSighashType; + use bdk_wallet::bitcoin::secp256k1::XOnlyPublicKey; + use bdk_wallet::bitcoin::taproot::{ + ControlBlock as BdkControlBlock, LeafVersion, TapLeafHash, TapNodeHash, + }; + use std::str::FromStr; + + let non_witness_utxo = input.non_witness_utxo.map(|tx| tx.0.clone()); + + let witness_utxo = input.witness_utxo.map(|txout| txout.into()); + + let partial_sigs = input + .partial_sigs + .into_iter() + .map(|(k, v)| { + let pubkey = Secp256k1PublicKey::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid public key in partial_sigs: {}", e), + } + })?; + let sig = ecdsa::Signature::from_slice(&v).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid signature in partial_sigs: {}", e), + } + })?; + Ok((pubkey, sig)) + }) + .collect::, AddForeignUtxoError>>()?; + + let sighash_type = input + .sighash_type + .map(|s| { + PsbtSighashType::from_str(&s).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid sighash type: {}", e), + } + }) + }) + .transpose()?; + + let redeem_script = input.redeem_script.map(|s| s.0.clone()); + let witness_script = input.witness_script.map(|s| s.0.clone()); + + let bip32_derivation = input + .bip32_derivation + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::bip32::{DerivationPath, Fingerprint}; + use bdk_wallet::bitcoin::secp256k1::PublicKey as Secp256k1RawPublicKey; + let pubkey = Secp256k1RawPublicKey::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid public key in bip32_derivation: {}", e), + } + })?; + let fingerprint = Fingerprint::from_str(&v.fingerprint).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid fingerprint: {}", e), + } + })?; + let path: DerivationPath = v.path.0.clone(); + Ok((pubkey, (fingerprint, path))) + }) + .collect::, AddForeignUtxoError>>()?; + + let final_script_sig = input.final_script_sig.map(|s| s.0.clone()); + + let final_script_witness = input.final_script_witness.map(|w| { + use bdk_wallet::bitcoin::Witness; + Witness::from_slice(&w) + }); + + let ripemd160_preimages = input + .ripemd160_preimages + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::hashes::ripemd160; + let hash = ripemd160::Hash::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid ripemd160 hash: {}", e), + } + })?; + Ok((hash, v)) + }) + .collect::>()?; + + let sha256_preimages = input + .sha256_preimages + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::hashes::sha256; + let hash = sha256::Hash::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid sha256 hash: {}", e), + } + })?; + Ok((hash, v)) + }) + .collect::>()?; + + let hash160_preimages = input + .hash160_preimages + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::hashes::hash160; + let hash = hash160::Hash::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid hash160: {}", e), + } + })?; + Ok((hash, v)) + }) + .collect::>()?; + + let hash256_preimages = input + .hash256_preimages + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::hashes::sha256d; + let hash = sha256d::Hash::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid hash256: {}", e), + } + })?; + Ok((hash, v)) + }) + .collect::>()?; + + let tap_key_sig = input + .tap_key_sig + .map(|s| { + use bdk_wallet::bitcoin::taproot::Signature; + Signature::from_slice(&s).map_err(|e| AddForeignUtxoError::InputConversionError { + error_message: format!("invalid taproot signature: {}", e), + }) + }) + .transpose()?; + + let tap_script_sigs = input + .tap_script_sigs + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::taproot::Signature; + let xonly = XOnlyPublicKey::from_str(&k.xonly_pubkey).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid xonly pubkey: {}", e), + } + })?; + let leaf_hash = TapLeafHash::from_str(&k.tap_leaf_hash).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid tap leaf hash: {}", e), + } + })?; + let sig = Signature::from_slice(&v).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid taproot script signature: {}", e), + } + })?; + Ok(((xonly, leaf_hash), sig)) + }) + .collect::>()?; + + let tap_scripts = input + .tap_scripts + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::key::XOnlyPublicKey as BdkXOnlyPublicKey; + use bdk_wallet::bitcoin::taproot::TapNodeHash; + + let internal_key = BdkXOnlyPublicKey::from_slice(&k.internal_key).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid internal key: {}", e), + } + })?; + + let output_key_parity = k.output_key_parity; + let leaf_version_u8 = k.leaf_version; + + let merkle_branch: Vec = k + .merkle_branch + .into_iter() + .map(|h| { + TapNodeHash::from_str(&h).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid merkle branch hash: {}", e), + } + }) + }) + .collect::>()?; + + let mut control_block_bytes = vec![output_key_parity | leaf_version_u8]; + control_block_bytes.extend_from_slice(&internal_key.serialize()); + for hash in &merkle_branch { + control_block_bytes.extend_from_slice(&hash.to_byte_array()); + } + + let control_block = BdkControlBlock::decode(&control_block_bytes).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid control block: {}", e), + } + })?; + + let leaf_version = LeafVersion::from_consensus(leaf_version_u8).map_err(|_| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid leaf version: {}", leaf_version_u8), + } + })?; + + Ok((control_block, (v.script.0.clone(), leaf_version))) + }) + .collect::>()?; + + let tap_key_origins = input + .tap_key_origins + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::bip32::{DerivationPath, Fingerprint}; + + let xonly = XOnlyPublicKey::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid xonly pubkey in tap_key_origins: {}", e), + } + })?; + + let leaf_hashes: Vec = v + .tap_leaf_hashes + .into_iter() + .map(|h| { + TapLeafHash::from_str(&h).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid tap leaf hash: {}", e), + } + }) + }) + .collect::>()?; + + let fingerprint = + Fingerprint::from_str(&v.key_source.fingerprint).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid fingerprint in tap_key_origins: {}", e), + } + })?; + + let path: DerivationPath = v.key_source.path.0.clone(); + + Ok((xonly, (leaf_hashes, (fingerprint, path)))) + }) + .collect::>()?; + + let tap_internal_key = input + .tap_internal_key + .map(|k| { + XOnlyPublicKey::from_str(&k).map_err(|e| { + AddForeignUtxoError::InputConversionError { + error_message: format!("invalid tap internal key: {}", e), + } + }) + }) + .transpose()?; + + let tap_merkle_root = input + .tap_merkle_root + .map(|k| { + TapNodeHash::from_str(&k).map_err(|e| AddForeignUtxoError::InputConversionError { + error_message: format!("invalid tap merkle root: {}", e), + }) + }) + .transpose()?; + + let proprietary = input + .proprietary + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::psbt::raw::ProprietaryKey as BdkProprietaryKey; + ( + BdkProprietaryKey { + prefix: k.prefix, + subtype: k.subtype, + key: k.key, + }, + v, + ) + }) + .collect(); + + let unknown = input + .unknown + .into_iter() + .map(|(k, v)| { + use bdk_wallet::bitcoin::psbt::raw::Key as BdkKey; + ( + BdkKey { + type_value: k.type_value, + key: k.key, + }, + v, + ) + }) + .collect(); + + Ok(BdkInput { + non_witness_utxo, + witness_utxo, + partial_sigs, + sighash_type, + redeem_script, + witness_script, + bip32_derivation, + final_script_sig, + final_script_witness, + ripemd160_preimages, + sha256_preimages, + hash160_preimages, + hash256_preimages, + tap_key_sig, + tap_script_sigs, + tap_scripts, + tap_key_origins, + tap_internal_key, + tap_merkle_root, + proprietary, + unknown, + }) + } +} + /// Store information about taproot leaf node. #[derive(Debug, uniffi::Object)] #[uniffi::export(Display)] diff --git a/bdk-ffi/src/error.rs b/bdk-ffi/src/error.rs index 10a5dbdb..10b6d8b1 100644 --- a/bdk-ffi/src/error.rs +++ b/bdk-ffi/src/error.rs @@ -24,6 +24,7 @@ use bdk_wallet::miniscript::descriptor::DescriptorKeyParseError as BdkDescriptor use bdk_wallet::miniscript::psbt::Error as BdkPsbtFinalizeError; #[allow(deprecated)] use bdk_wallet::signer::SignerError as BdkSignerError; +use bdk_wallet::tx_builder::AddForeignUtxoError as BdkAddForeignUtxoError; use bdk_wallet::tx_builder::AddUtxoError; use bdk_wallet::LoadWithPersistError as BdkLoadWithPersistError; use bdk_wallet::{chain, CreateWithPersistError as BdkCreateWithPersistError}; @@ -34,6 +35,21 @@ use std::convert::TryInto; // error definitions // ------------------------------------------------------------------------ +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum AddForeignUtxoError { + #[error("foreign utxo outpoint txid does not match PSBT input txid")] + InvalidTxid, + + #[error("requested outpoint doesn't exist in the tx: {outpoint}")] + InvalidOutpoint { outpoint: String }, + + #[error("foreign utxo missing witness_utxo or non_witness_utxo")] + MissingUtxo, + + #[error("failed to convert Input to BdkInput: {error_message}")] + InputConversionError { error_message: String }, +} + #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum AddressParseError { #[error("base58 address encoding error")] @@ -787,6 +803,40 @@ pub enum CbfError { // error conversions // ------------------------------------------------------------------------ +impl From for CreateTxError { + fn from(error: AddForeignUtxoError) -> Self { + match error { + AddForeignUtxoError::InvalidTxid => CreateTxError::Descriptor { + error_message: "foreign utxo outpoint txid does not match PSBT input txid" + .to_string(), + }, + AddForeignUtxoError::InvalidOutpoint { outpoint } => { + CreateTxError::UnknownUtxo { outpoint } + } + AddForeignUtxoError::MissingUtxo => CreateTxError::Descriptor { + error_message: "foreign utxo missing witness_utxo or non_witness_utxo".to_string(), + }, + AddForeignUtxoError::InputConversionError { error_message } => { + CreateTxError::Descriptor { error_message } + } + } + } +} + +impl From for AddForeignUtxoError { + fn from(error: BdkAddForeignUtxoError) -> Self { + match error { + BdkAddForeignUtxoError::InvalidTxid { .. } => AddForeignUtxoError::InvalidTxid, + BdkAddForeignUtxoError::InvalidOutpoint(outpoint) => { + AddForeignUtxoError::InvalidOutpoint { + outpoint: outpoint.to_string(), + } + } + BdkAddForeignUtxoError::MissingUtxo => AddForeignUtxoError::MissingUtxo, + } + } +} + impl From for ElectrumError { fn from(error: BdkElectrumError) -> Self { match error { diff --git a/bdk-ffi/src/tests/tx_builder.rs b/bdk-ffi/src/tests/tx_builder.rs index 4a9c9df4..f96eb7d1 100644 --- a/bdk-ffi/src/tests/tx_builder.rs +++ b/bdk-ffi/src/tests/tx_builder.rs @@ -1,4 +1,4 @@ -use crate::bitcoin::{Amount, Network, Script}; +use crate::bitcoin::{Amount, Input, Network, OutPoint, Script, TxOut}; use crate::descriptor::Descriptor; use crate::esplora::EsploraClient; use crate::store::Persister; @@ -6,6 +6,8 @@ use crate::tx_builder::TxBuilder; use crate::types::FullScanScriptInspector; use crate::wallet::Wallet; +use bdk_wallet::bitcoin::hashes::hex::FromHex; +use std::collections::HashMap; use std::sync::Arc; struct FullScanInspector; @@ -86,3 +88,459 @@ fn create_and_sync_wallet() -> Wallet { println!("Wallet balance: {:?}", wallet.balance().total.to_sat()); wallet } + +#[test] +fn test_only_witness_utxo_with_finish() { + let wallet = create_and_sync_wallet(); + + // Check if wallet has any UTXOs to work with + let utxos = wallet.list_unspent(); + let has_utxos = !utxos.is_empty(); + + println!("Wallet has {} UTXOs", utxos.len()); + + // Only run the actual transaction test if wallet has UTXOs + // Otherwise we at least verify the builder methods work + if !has_utxos { + println!("No UTXOs available, testing builder methods only"); + + // At minimum, verify the builder methods don't panic + let address = wallet + .next_unused_address(bdk_wallet::KeychainKind::External) + .address; + let _builder = TxBuilder::new() + .add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ) + .only_witness_utxo(); + + println!("Builder methods work correctly even without UTXOs"); + return; + } + + let address = wallet + .next_unused_address(bdk_wallet::KeychainKind::External) + .address; + + // Get policy paths for the multisig wallet + let ext_policy = wallet.policies(bdk_wallet::KeychainKind::External); + let int_policy = wallet.policies(bdk_wallet::KeychainKind::Internal); + + if let (Ok(Some(ext_policy)), Ok(Some(int_policy))) = (ext_policy, int_policy) { + let ext_path: HashMap<_, _> = vec![(ext_policy.id().clone(), vec![0, 1])] + .into_iter() + .collect(); + let int_path: HashMap<_, _> = vec![(int_policy.id().clone(), vec![0, 1])] + .into_iter() + .collect(); + + let wallet_arc = Arc::new(wallet); + + // Build transaction without only_witness_utxo + let tx_without_flag = TxBuilder::new() + .add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ) + .policy_path(ext_path.clone(), bdk_wallet::KeychainKind::External) + .policy_path(int_path.clone(), bdk_wallet::KeychainKind::Internal) + .do_not_spend_change() + .finish(&wallet_arc); + + // Build transaction with only_witness_utxo + let tx_with_flag = TxBuilder::new() + .add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ) + .policy_path(ext_path, bdk_wallet::KeychainKind::External) + .policy_path(int_path, bdk_wallet::KeychainKind::Internal) + .do_not_spend_change() + .only_witness_utxo() + .finish(&wallet_arc); + + // Both should succeed if we have UTXOs + match (tx_without_flag, tx_with_flag) { + (Ok(psbt_without), Ok(psbt_with)) => { + let size_without = psbt_without.serialize().len(); + let size_with = psbt_with.serialize().len(); + + println!( + "PSBT size without only_witness_utxo: {} bytes", + size_without + ); + println!("PSBT size with only_witness_utxo: {} bytes", size_with); + + // The PSBT with only_witness_utxo should be smaller or equal + // (for SegWit inputs, non_witness_utxo adds the full transaction) + assert!( + size_with <= size_without, + "Expected PSBT with only_witness_utxo to be smaller, but got {} bytes vs {} bytes", + size_with, + size_without + ); + } + (Err(e1), _) => { + println!("Transaction without flag failed: {:?}", e1); + panic!("Expected transaction to succeed with UTXOs available"); + } + (_, Err(e2)) => { + println!("Transaction with flag failed: {:?}", e2); + panic!("Expected transaction with only_witness_utxo to succeed"); + } + } + } else { + println!("Failed to retrieve policies, skipping transaction test"); + } +} + +#[test] +fn test_add_foreign_utxo_missing_witness_data() { + let wallet = create_and_sync_wallet(); + let address = wallet + .next_unused_address(bdk_wallet::KeychainKind::External) + .address; + + // Create a foreign UTXO without witness_utxo or non_witness_utxo + let outpoint = OutPoint { + txid: Arc::new( + crate::bitcoin::Txid::from_string( + "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456".to_string(), + ) + .unwrap(), + ), + vout: 0, + }; + + let psbt_input = Input { + non_witness_utxo: None, + witness_utxo: None, // Missing! + partial_sigs: HashMap::new(), + sighash_type: None, + redeem_script: None, + witness_script: None, + bip32_derivation: HashMap::new(), + final_script_sig: None, + final_script_witness: None, + ripemd160_preimages: HashMap::new(), + sha256_preimages: HashMap::new(), + hash160_preimages: HashMap::new(), + hash256_preimages: HashMap::new(), + tap_key_sig: None, + tap_script_sigs: HashMap::new(), + tap_scripts: HashMap::new(), + tap_key_origins: HashMap::new(), + tap_internal_key: None, + tap_merkle_root: None, + proprietary: HashMap::new(), + unknown: HashMap::new(), + }; + + let tx_builder = TxBuilder::new().add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ); + + let result = tx_builder.add_foreign_utxo(outpoint, psbt_input, 68); + assert!(result.is_ok(), "add_foreign_utxo should accept the input"); + + // Now try to finish - this should fail due to missing witness data + let finish_result = result.unwrap().finish(&Arc::new(wallet)); + + // This should fail with MissingUtxo error + assert!( + finish_result.is_err(), + "Expected finish() to fail with missing witness_utxo/non_witness_utxo" + ); + + if let Err(e) = finish_result { + let error_msg = format!("{:?}", e); + println!("Got expected error: {}", error_msg); + // The error should mention missing utxo data + assert!( + error_msg.contains("missing") + || error_msg.contains("Utxo") + || error_msg.contains("utxo"), + "Error should mention missing UTXO data, got: {}", + error_msg + ); + } +} + +#[test] +fn test_add_foreign_utxo_with_witness_utxo_succeeds() { + let wallet = create_and_sync_wallet(); + let address = wallet + .next_unused_address(bdk_wallet::KeychainKind::External) + .address; + + let outpoint = OutPoint { + txid: Arc::new( + crate::bitcoin::Txid::from_string( + "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456".to_string(), + ) + .unwrap(), + ), + vout: 0, + }; + + // Create a valid witness UTXO + let witness_utxo = TxOut { + value: Arc::new(Amount::from_sat(50000)), + script_pubkey: Arc::new(Script::new( + Vec::from_hex("0014d85c2b71d0060b09c9886aeb815e50991dda124d").unwrap(), + )), + }; + + let psbt_input = Input { + non_witness_utxo: None, + witness_utxo: Some(witness_utxo), + partial_sigs: HashMap::new(), + sighash_type: None, + redeem_script: None, + witness_script: None, + bip32_derivation: HashMap::new(), + final_script_sig: None, + final_script_witness: None, + ripemd160_preimages: HashMap::new(), + sha256_preimages: HashMap::new(), + hash160_preimages: HashMap::new(), + hash256_preimages: HashMap::new(), + tap_key_sig: None, + tap_script_sigs: HashMap::new(), + tap_scripts: HashMap::new(), + tap_key_origins: HashMap::new(), + tap_internal_key: None, + tap_merkle_root: None, + proprietary: HashMap::new(), + unknown: HashMap::new(), + }; + + let tx_builder = TxBuilder::new().add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ); + + let result = tx_builder.add_foreign_utxo(outpoint, psbt_input, 68); + assert!( + result.is_ok(), + "Failed to add foreign UTXO: {:?}", + result.err() + ); + + // Try to finish - might fail due to other reasons (insufficient funds from foreign UTXO alone) + // but shouldn't fail due to missing witness data + let finish_result = result.unwrap().finish(&Arc::new(wallet)); + + // If it fails, check it's not due to missing witness data + if let Err(e) = finish_result { + let error_msg = format!("{:?}", e); + println!("Transaction failed with: {}", error_msg); + // Should NOT be a missing witness data error + assert!( + !error_msg.contains("MissingUtxo") && !error_msg.contains("missing witness"), + "Should not fail due to missing witness data, got: {}", + error_msg + ); + } +} + +#[test] +fn test_add_multiple_foreign_utxos_and_finish() { + let wallet = create_and_sync_wallet(); + let address = wallet + .next_unused_address(bdk_wallet::KeychainKind::External) + .address; + + // Create first foreign UTXO + let outpoint1 = OutPoint { + txid: Arc::new( + crate::bitcoin::Txid::from_string( + "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456".to_string(), + ) + .unwrap(), + ), + vout: 0, + }; + + let witness_utxo1 = TxOut { + value: Arc::new(Amount::from_sat(50000)), + script_pubkey: Arc::new(Script::new( + Vec::from_hex("0014d85c2b71d0060b09c9886aeb815e50991dda124d").unwrap(), + )), + }; + + let psbt_input1 = Input { + non_witness_utxo: None, + witness_utxo: Some(witness_utxo1), + partial_sigs: HashMap::new(), + sighash_type: None, + redeem_script: None, + witness_script: None, + bip32_derivation: HashMap::new(), + final_script_sig: None, + final_script_witness: None, + ripemd160_preimages: HashMap::new(), + sha256_preimages: HashMap::new(), + hash160_preimages: HashMap::new(), + hash256_preimages: HashMap::new(), + tap_key_sig: None, + tap_script_sigs: HashMap::new(), + tap_scripts: HashMap::new(), + tap_key_origins: HashMap::new(), + tap_internal_key: None, + tap_merkle_root: None, + proprietary: HashMap::new(), + unknown: HashMap::new(), + }; + + // Create second foreign UTXO + let outpoint2 = OutPoint { + txid: Arc::new( + crate::bitcoin::Txid::from_string( + "6ea4e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9457".to_string(), + ) + .unwrap(), + ), + vout: 1, + }; + + let witness_utxo2 = TxOut { + value: Arc::new(Amount::from_sat(75000)), + script_pubkey: Arc::new(Script::new( + Vec::from_hex("0014a85c2b71d0060b09c9886aeb815e50991dda125e").unwrap(), + )), + }; + + let psbt_input2 = Input { + non_witness_utxo: None, + witness_utxo: Some(witness_utxo2), + partial_sigs: HashMap::new(), + sighash_type: None, + redeem_script: None, + witness_script: None, + bip32_derivation: HashMap::new(), + final_script_sig: None, + final_script_witness: None, + ripemd160_preimages: HashMap::new(), + sha256_preimages: HashMap::new(), + hash160_preimages: HashMap::new(), + hash256_preimages: HashMap::new(), + tap_key_sig: None, + tap_script_sigs: HashMap::new(), + tap_scripts: HashMap::new(), + tap_key_origins: HashMap::new(), + tap_internal_key: None, + tap_merkle_root: None, + proprietary: HashMap::new(), + unknown: HashMap::new(), + }; + + // Add both foreign UTXOs + let tx_builder = TxBuilder::new().add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ); + + let tx_builder = tx_builder + .add_foreign_utxo(outpoint1, psbt_input1, 68) + .expect("Failed to add first foreign UTXO"); + + let tx_builder = tx_builder + .add_foreign_utxo(outpoint2, psbt_input2, 68) + .expect("Failed to add second foreign UTXO"); + + // Try to finish + let finish_result = tx_builder.finish(&Arc::new(wallet)); + + // If it fails, verify it's not due to witness data issues + if let Err(e) = finish_result { + let error_msg = format!("{:?}", e); + println!( + "Transaction with multiple foreign UTXOs failed with: {}", + error_msg + ); + assert!( + !error_msg.contains("MissingUtxo"), + "Should not fail due to missing witness data" + ); + } +} + +#[test] +fn test_combined_only_witness_utxo_and_foreign_utxo_with_finish() { + let wallet = create_and_sync_wallet(); + let address = wallet + .next_unused_address(bdk_wallet::KeychainKind::External) + .address; + + let outpoint = OutPoint { + txid: Arc::new( + crate::bitcoin::Txid::from_string( + "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456".to_string(), + ) + .unwrap(), + ), + vout: 0, + }; + + let witness_utxo = TxOut { + value: Arc::new(Amount::from_sat(50000)), + script_pubkey: Arc::new(Script::new( + Vec::from_hex("0014d85c2b71d0060b09c9886aeb815e50991dda124d").unwrap(), + )), + }; + + let psbt_input = Input { + non_witness_utxo: None, + witness_utxo: Some(witness_utxo), + partial_sigs: HashMap::new(), + sighash_type: None, + redeem_script: None, + witness_script: None, + bip32_derivation: HashMap::new(), + final_script_sig: None, + final_script_witness: None, + ripemd160_preimages: HashMap::new(), + sha256_preimages: HashMap::new(), + hash160_preimages: HashMap::new(), + hash256_preimages: HashMap::new(), + tap_key_sig: None, + tap_script_sigs: HashMap::new(), + tap_scripts: HashMap::new(), + tap_key_origins: HashMap::new(), + tap_internal_key: None, + tap_merkle_root: None, + proprietary: HashMap::new(), + unknown: HashMap::new(), + }; + + // Combine both features + let tx_builder = TxBuilder::new() + .add_recipient( + &(*address.script_pubkey()).to_owned(), + Arc::new(Amount::from_sat(1000)), + ) + .only_witness_utxo(); + + let tx_builder = tx_builder + .add_foreign_utxo(outpoint, psbt_input, 68) + .expect("Failed to add foreign UTXO with only_witness_utxo"); + + // Try to finish - this tests that both features work together + let finish_result = tx_builder.finish(&Arc::new(wallet)); + + // Verify the combination doesn't cause conflicts + if let Err(e) = finish_result { + let error_msg = format!("{:?}", e); + println!("Combined features transaction failed with: {}", error_msg); + // Should not be due to witness data conflicts + assert!( + !error_msg.contains("MissingUtxo") && !error_msg.contains("witness") + || error_msg.contains("Insufficient"), + "Should not fail due to witness-related conflicts, got: {}", + error_msg + ); + } +} diff --git a/bdk-ffi/src/tx_builder.rs b/bdk-ffi/src/tx_builder.rs index ef11d99f..2b783583 100644 --- a/bdk-ffi/src/tx_builder.rs +++ b/bdk-ffi/src/tx_builder.rs @@ -1,14 +1,15 @@ -use crate::bitcoin::{Amount, FeeRate, OutPoint, Psbt, Script, Txid}; -use crate::error::CreateTxError; +use crate::bitcoin::{Amount, FeeRate, Input, OutPoint, Psbt, Script, Txid}; +use crate::error::{AddForeignUtxoError, CreateTxError}; use crate::types::{LockTime, ScriptAmount}; use crate::wallet::Wallet; use bdk_wallet::bitcoin::absolute::LockTime as BdkLockTime; use bdk_wallet::bitcoin::amount::Amount as BdkAmount; +use bdk_wallet::bitcoin::psbt::Input as BdkInput; use bdk_wallet::bitcoin::script::PushBytesBuf; use bdk_wallet::bitcoin::Psbt as BdkPsbt; use bdk_wallet::bitcoin::ScriptBuf as BdkScriptBuf; -use bdk_wallet::bitcoin::{OutPoint as BdkOutPoint, Sequence}; +use bdk_wallet::bitcoin::{OutPoint as BdkOutPoint, Sequence, Weight as BdkWeight}; use bdk_wallet::KeychainKind; use std::collections::BTreeMap; @@ -42,6 +43,8 @@ pub struct TxBuilder { version: Option, exclude_unconfirmed: bool, exclude_below_confirmations: Option, + only_witness_utxo: bool, + foreign_utxos: Vec<(BdkOutPoint, BdkInput, BdkWeight)>, } #[allow(clippy::new_without_default)] @@ -70,6 +73,8 @@ impl TxBuilder { version: None, exclude_unconfirmed: false, exclude_below_confirmations: None, + only_witness_utxo: false, + foreign_utxos: Vec::new(), } } @@ -357,7 +362,7 @@ impl TxBuilder { /// Build a transaction with a specific version. /// - /// The version should always be greater than 0 and greater than 1 if the wallet’s descriptors contain an "older" + /// The version should always be greater than 0 and greater than 1 if the wallet's descriptors contain an "older" /// (`OP_CSV`) operator. pub fn version(&self, version: i32) -> Arc { Arc::new(TxBuilder { @@ -366,6 +371,73 @@ impl TxBuilder { }) } + /// Only fill witness_utxo. + /// + /// Only fill-in the `psbt::Input::witness_utxo` field when spending from SegWit descriptors. + /// This reduces the size of the PSBT. It is valid to fill in both `witness_utxo` and `non_witness_utxo`, + /// but the PSBT will be larger. + /// + /// This is automatically set when working with SegWit transactions. The `witness_utxo` field + /// is enough to construct the signature hash. + pub fn only_witness_utxo(&self) -> Arc { + Arc::new(TxBuilder { + only_witness_utxo: true, + ..self.clone() + }) + } + + /// Add a foreign UTXO i.e. a UTXO not owned by this wallet. + /// + /// At a minimum to add a foreign UTXO we need: + /// + /// 1. `outpoint`: To add it to the raw transaction + /// 2. `psbt_input`: To know the value + /// 3. `satisfaction_weight`: To know how much weight/vbytes the input will add to the transaction for fee calculation + /// + /// There are several security concerns about adding foreign UTXOs that application + /// developers should consider. First, how do you know the value of the input is correct? If a + /// `non_witness_utxo` is provided in the `psbt_input` then this method implicitly verifies the + /// value by checking it against the transaction. If only a `witness_utxo` is provided then this + /// method doesn't verify the value but just takes it as a given – it is up to you to check that + /// whoever sent you the `input` was not lying! + /// + /// Secondly, you must somehow provide `satisfaction_weight` of the input. Depending on your + /// application it may be important that this be known precisely. If not, a malicious + /// counterparty may fool you into putting in a value that is too low, giving the transaction a + /// lower than expected feerate. They could also fool you into putting a value that is too high + /// causing you to pay a fee that is too high. The party who is broadcasting the transaction can + /// of course check the real input weight matches the expected weight prior to broadcasting. + /// + /// To guarantee the `satisfaction_weight` is correct, you can require the party providing the + /// `psbt_input` provide a miniscript descriptor for the input so you can check it against the + /// `script_pubkey` and then ask it for the [`max_weight_to_satisfy`]. + /// + /// This is an **EXPERIMENTAL** feature, API and other major changes are expected. + /// + /// # Errors + /// + /// Returns an error if the `outpoint` doesn't exist or the `satisfaction_weight` is not provided. + /// + /// [`max_weight_to_satisfy`]: https://docs.rs/miniscript/latest/miniscript/trait.Descriptor.html#method.max_weight_to_satisfy + pub fn add_foreign_utxo( + &self, + outpoint: OutPoint, + psbt_input: Input, + satisfaction_weight: u64, + ) -> Result, AddForeignUtxoError> { + let bdk_outpoint: BdkOutPoint = outpoint.into(); + let bdk_input: BdkInput = psbt_input.try_into()?; + let bdk_weight = BdkWeight::from_wu(satisfaction_weight); + + let mut foreign_utxos = self.foreign_utxos.clone(); + foreign_utxos.push((bdk_outpoint, bdk_input, bdk_weight)); + + Ok(Arc::new(TxBuilder { + foreign_utxos, + ..self.clone() + })) + } + /// Finish building the transaction. /// /// Uses the thread-local random number generator (rng). @@ -440,6 +512,14 @@ impl TxBuilder { if let Some(min_confirms) = self.exclude_below_confirmations { tx_builder.exclude_below_confirmations(min_confirms); } + if self.only_witness_utxo { + tx_builder.only_witness_utxo(); + } + for (outpoint, input, weight) in &self.foreign_utxos { + tx_builder + .add_foreign_utxo(*outpoint, input.clone(), *weight) + .map_err(AddForeignUtxoError::from)?; + } let psbt = tx_builder.finish().map_err(CreateTxError::from)?; Ok(Arc::new(psbt.into()))