From 48b6c595a11ad0b4f0e6ed2ea54b84e549ae6ecc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 20 Oct 2026 17:18:35 +0200 Subject: [PATCH 1/5] Upgrade BDK Wallet to 3.2 Our wallet needs per-input finalization results to distinguish unfinished foreign inputs from failures to finalize its own inputs. Raise the minimum BDK Wallet version to 3.2, which provides these results alongside fixes for TRUC input selection and changeset merging. Co-Authored-By: HAL 9000 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f880b91162..9dbc9548e4 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightn bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"], optional = true } bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"], optional = true } -bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} +bdk_wallet = { version = "3.2.0", default-features = false, features = ["std", "keys-bip39"]} bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } From 458e43423b14d9755ea1e100eec9278105259914 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 20 Oct 2026 17:24:07 +0200 Subject: [PATCH 2/5] Move to caller-owned wallet signers BDK's wallet-owned signing APIs are deprecated, but our signing paths still need their witness-UTXO policy, sighash checks, low-R signatures, and input finalization. Use caller-owned signers for both keychains to preserve these signing options while dropping the deprecated APIs. Keep signing available after reload, and cover witness-only signing from both keychains. Refs #654 Co-Authored-By: HAL 9000 --- src/builder.rs | 24 +++++++-- src/error.rs | 2 - src/wallet/mod.rs | 135 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index ddf9104cc2..21f15508b4 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -17,6 +17,8 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex, Once, RwLock}; use std::time::SystemTime; +use bdk_wallet::descriptor::IntoWalletDescriptor; +use bdk_wallet::signer::SignersContainer; use bdk_wallet::template::Bip84; use bdk_wallet::{KeychainKind, Wallet as BdkWallet}; use bitcoin::bip32::{ChildNumber, Xpriv}; @@ -1758,17 +1760,30 @@ fn build_with_store_internal( BuildError::WalletSetupFailed })?; - let descriptor = Bip84(xprv, KeychainKind::External); - let change_descriptor = Bip84(xprv, KeychainKind::Internal); + let secp_ctx = Secp256k1::new(); + let (descriptor, keymap) = Bip84(xprv, KeychainKind::External) + .into_wallet_descriptor(&secp_ctx, config.network.into()) + .map_err(|e| { + log_error!(logger, "Failed to derive external wallet descriptor: {}", e); + BuildError::WalletSetupFailed + })?; + let (change_descriptor, change_keymap) = Bip84(xprv, KeychainKind::Internal) + .into_wallet_descriptor(&secp_ctx, config.network.into()) + .map_err(|e| { + log_error!(logger, "Failed to derive internal wallet descriptor: {}", e); + BuildError::WalletSetupFailed + })?; + let signers = [ + SignersContainer::build(keymap, &descriptor, &secp_ctx), + SignersContainer::build(change_keymap, &change_descriptor, &secp_ctx), + ]; let mut wallet_persister = KVStoreWalletPersister::new(Arc::clone(&kv_store), Arc::clone(&logger)); - #[allow(deprecated)] let wallet_opt = runtime .block_on(async { BdkWallet::load() .descriptor(KeychainKind::External, Some(descriptor.clone())) .descriptor(KeychainKind::Internal, Some(change_descriptor.clone())) - .extract_keys() .check_network(config.network) .load_wallet_async(&mut wallet_persister) .await @@ -1957,6 +1972,7 @@ fn build_with_store_internal( let wallet = Arc::new(Wallet::new( bdk_wallet, + signers, wallet_persister, persisted_pool_indices, Arc::clone(&tx_broadcaster), diff --git a/src/error.rs b/src/error.rs index d187c68200..202cb38878 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,7 +11,6 @@ use bdk_chain::bitcoin::psbt::ExtractTxError as BdkExtractTxError; use bdk_chain::local_chain::CannotConnectError as BdkChainConnectionError; use bdk_chain::tx_graph::CalculateFeeError as BdkChainCalculateFeeError; use bdk_wallet::error::CreateTxError as BdkCreateTxError; -#[allow(deprecated)] use bdk_wallet::signer::SignerError as BdkSignerError; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -255,7 +254,6 @@ impl fmt::Display for Error { impl std::error::Error for Error {} -#[allow(deprecated)] impl From for Error { fn from(_: BdkSignerError) -> Self { Self::OnchainTxSigningFailed diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b883ea918a..808b229ffe 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -15,9 +15,10 @@ use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::ChainPosition; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; -#[allow(deprecated)] -use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WalletEvent}; +use bdk_wallet::signer::SignersContainer; +use bdk_wallet::{ + Balance, KeychainKind, LocalOutput, PersistedWallet, SignOptions, Update, WalletEvent, +}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -160,6 +161,8 @@ impl AddressPool { pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, + // External and internal signing keys are kept outside the BDK wallet. + signers: [SignersContainer; 2], persister: tokio::sync::Mutex, address_pool: Mutex, // Serializes refill runs so concurrent pops never over-reveal. @@ -186,10 +189,11 @@ pub(crate) struct Wallet { impl Wallet { pub(crate) fn new( wallet: bdk_wallet::PersistedWallet, - wallet_persister: KVStoreWalletPersister, persisted_pool_indices: Vec, - broadcaster: Arc, fee_estimator: Arc, - chain_source: Arc, payment_store: Arc, runtime: Arc, - config: Arc, logger: Arc, pending_payment_store: Arc, + signers: [SignersContainer; 2], wallet_persister: KVStoreWalletPersister, + persisted_pool_indices: Vec, broadcaster: Arc, + fee_estimator: Arc, chain_source: Arc, + payment_store: Arc, runtime: Arc, config: Arc, + logger: Arc, pending_payment_store: Arc, channel_tx_facts_store: Arc, ) -> Self { let address_pool = Mutex::new(AddressPool::new(persisted_pool_indices, &wallet, &logger)); @@ -198,6 +202,7 @@ impl Wallet { let address_pool_refill_lock = tokio::sync::Mutex::new(()); Self { inner, + signers, persister, address_pool, address_pool_refill_lock, @@ -1700,7 +1705,6 @@ impl Wallet { Ok(()) } - #[allow(deprecated)] pub(crate) async fn create_funding_transaction( &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, locktime: LockTime, fee_rate: Option, @@ -1725,7 +1729,11 @@ impl Wallet { }, }; - match locked_wallet.sign(&mut psbt, SignOptions::default()) { + match locked_wallet.sign_with_signers( + &mut psbt, + &self.signers.each_ref(), + SignOptions::default(), + ) { Ok(finalized) => { if !finalized { return Err(Error::OnchainTxCreationFailed); @@ -2166,7 +2174,6 @@ impl Wallet { .map_err(|_| Error::InvalidAddress) } - #[allow(deprecated)] pub(crate) async fn send_to_address( &self, address: &bitcoin::Address, send_amount: OnchainSendAmount, fee_rate: Option, @@ -2288,7 +2295,11 @@ impl Wallet { _ => {}, } - match locked_wallet.sign(&mut psbt, SignOptions::default()) { + match locked_wallet.sign_with_signers( + &mut psbt, + &self.signers.each_ref(), + SignOptions::default(), + ) { Ok(finalized) => { if !finalized { return Err(Error::OnchainTxCreationFailed); @@ -2527,7 +2538,6 @@ impl Wallet { Ok(utxos) } - #[allow(deprecated)] async fn get_change_script_inner(&self) -> Result { let mut locked_persister = self.persister.lock().await; let (address_info, change_set) = { @@ -2541,7 +2551,6 @@ impl Wallet { Ok(address_info.address.script_pubkey()) } - #[allow(deprecated)] pub(crate) fn sign_owned_inputs(&self, unsigned_tx: Transaction) -> Result { let locked_wallet = self.inner.lock().expect("lock"); @@ -2564,7 +2573,7 @@ impl Wallet { let mut sign_options = SignOptions::default(); sign_options.trust_witness_utxo = true; - match locked_wallet.sign(&mut psbt, sign_options) { + match locked_wallet.sign_with_signers(&mut psbt, &self.signers.each_ref(), sign_options) { Ok(finalized) => debug_assert!(!finalized), Err(e) => { log_error!(self.logger, "Failed to sign owned inputs: {}", e); @@ -2582,7 +2591,6 @@ impl Wallet { } } - #[allow(deprecated)] fn sign_psbt_inner(&self, mut psbt: Psbt) -> Result { let locked_wallet = self.inner.lock().expect("lock"); @@ -2592,7 +2600,7 @@ impl Wallet { let mut sign_options = SignOptions::default(); sign_options.trust_witness_utxo = true; - match locked_wallet.sign(&mut psbt, sign_options) { + match locked_wallet.sign_with_signers(&mut psbt, &self.signers.each_ref(), sign_options) { Ok(_finalized) => { // BDK will fail to finalize for all LDK-provided inputs of the PSBT. Unfortunately // we can't check more fine grained if it succeeded for all the other inputs here, @@ -3367,7 +3375,6 @@ impl Wallet { Ok(FundingStatusUpdate::Applied) } - #[allow(deprecated)] pub(crate) async fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, ) -> Result { @@ -3618,7 +3625,11 @@ impl Wallet { return Err(Error::InsufficientFunds); } - match locked_wallet.sign(&mut psbt, SignOptions::default()) { + match locked_wallet.sign_with_signers( + &mut psbt, + &self.signers.each_ref(), + SignOptions::default(), + ) { Ok(finalized) => { if !finalized { log_error!(self.logger, "Failed to finalize signing for fee bump of {}", txid); @@ -4176,6 +4187,7 @@ mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; + use bdk_wallet::descriptor::IntoWalletDescriptor; use bdk_wallet::Wallet as BdkWallet; use bitcoin::hashes::Hash; use bitcoin::Network; @@ -4384,19 +4396,26 @@ mod tests { let mut wallet_persister = KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); - #[allow(deprecated)] + let secp_ctx = Secp256k1::new(); + let (descriptor, keymap) = + EXTERNAL_DESCRIPTOR.into_wallet_descriptor(&secp_ctx, config.network.into()).unwrap(); + let (change_descriptor, change_keymap) = + INTERNAL_DESCRIPTOR.into_wallet_descriptor(&secp_ctx, config.network.into()).unwrap(); + let signers = [ + SignersContainer::build(keymap, &descriptor, &secp_ctx), + SignersContainer::build(change_keymap, &change_descriptor, &secp_ctx), + ]; let bdk_wallet = if load_existing { BdkWallet::load() - .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) - .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) - .extract_keys() + .descriptor(KeychainKind::External, Some(descriptor)) + .descriptor(KeychainKind::Internal, Some(change_descriptor)) .check_network(Network::Regtest) .load_wallet_async(&mut wallet_persister) .await .unwrap() .unwrap() } else { - BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + BdkWallet::create(descriptor, change_descriptor) .network(Network::Regtest) .create_wallet_async(&mut wallet_persister) .await @@ -4460,6 +4479,7 @@ mod tests { Arc::new(Wallet::new( bdk_wallet, + signers, wallet_persister, persisted_pool_indices, broadcaster, @@ -4493,6 +4513,75 @@ mod tests { ) } + #[tokio::test] + async fn sign_both_keychains_before_and_after_reload() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let mut wallet = new_test_wallet(Arc::clone(&store), false).await; + let secp_ctx = Secp256k1::new(); + + for reload in [false, true] { + if reload { + wallet = new_test_wallet(Arc::clone(&store), true).await; + } + + let prevouts = { + let locked_wallet = wallet.inner.lock().unwrap(); + [KeychainKind::External, KeychainKind::Internal].map(|keychain| TxOut { + value: Amount::from_sat(100_000), + script_pubkey: locked_wallet.peek_address(keychain, 0).address.script_pubkey(), + }) + }; + let unsigned_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: (0..2) + .map(|vout| bitcoin::TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([42; 32]), vout }, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: Amount::from_sat(199_000), + script_pubkey: prevouts[0].script_pubkey.clone(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).unwrap(); + for (input, prevout) in psbt.inputs.iter_mut().zip(&prevouts) { + // LDK supplies witness UTXOs without the full previous transactions. + input.witness_utxo = Some(prevout.clone()); + } + + let tx = wallet.sign_psbt_inner(psbt).unwrap(); + let mut sighash_cache = bitcoin::sighash::SighashCache::new(&tx); + for (index, (input, prevout)) in tx.input.iter().zip(&prevouts).enumerate() { + assert_eq!(input.witness.len(), 2, "both keychains must be finalized"); + let signature = bitcoin::ecdsa::Signature::from_slice(&input.witness[0]).unwrap(); + let public_key = bitcoin::PublicKey::from_slice(&input.witness[1]).unwrap(); + assert_eq!( + ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()), + prevout.script_pubkey + ); + assert_eq!(signature.sighash_type, bitcoin::sighash::EcdsaSighashType::All); + assert!(input.witness[0].len() <= 71, "ECDSA signatures must use low R"); + let sighash = sighash_cache + .p2wpkh_signature_hash( + index, + &prevout.script_pubkey, + prevout.value, + signature.sighash_type, + ) + .unwrap(); + secp_ctx + .verify_ecdsa( + &bitcoin::secp256k1::Message::from_digest(sighash.to_byte_array()), + &signature.signature, + &public_key.inner, + ) + .unwrap(); + } + } + } + #[tokio::test] async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { let fail_store = FailSwitchStore::new(); From 03a911d4cff0deca2307a2f84766a35a3237d391 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 20 Oct 2026 17:26:23 +0200 Subject: [PATCH 3/5] Use upstream Electrum EOF fix Concurrent requests sharing an Electrum connection can remain blocked when the active reader sees a clean EOF. This can keep blocking Tokio workers alive through node shutdown. Require the released fix in electrum-client 0.25.1 and cover the failure at the LDK Node boundary so dependency updates cannot reintroduce the hang. BDK Electrum now supports this client version. Fixes #1007 Refs #1055 (cherry picked from commit aff6ce0d1148c0648fd4ea4f6412608211c93d9a) Co-Authored-By: HAL 9000 --- Cargo.toml | 2 +- src/chain/electrum.rs | 86 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9dbc9548e4..d5d73fc56e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,7 +118,7 @@ tokio = { version = "1.39", default-features = false, features = [ "rt-multi-thr tokio-util = { version = "0.7.10", default-features = false, features = ["rt"] } esplora-client = { version = "0.12", default-features = false, features = ["tokio", "async-https-rustls"], optional = true } ldk-esplora-client = { package = "esplora-client", version = "0.13", default-features = false, features = ["tokio", "async-https-rustls"], optional = true } -electrum-client = { version = "0.25", default-features = false, features = ["proxy", "use-rustls-ring"], optional = true } +electrum-client = { version = "0.25.1", default-features = false, features = ["proxy", "use-rustls-ring"], optional = true } libc = "0.2" uniffi = { version = "0.29.5", features = ["build"], optional = true } serde = { version = "1.0.210", default-features = false, features = ["std", "derive"] } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 86025998e8..c19861e739 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -919,14 +919,98 @@ impl Filter for ElectrumRuntimeClient { #[cfg(test)] mod tests { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::mpsc; + use std::sync::{mpsc, Barrier}; use std::thread; use bitcoin::blockdata::constants::genesis_block; use super::*; + #[test] + fn clean_eof_wakes_concurrent_electrum_requests() { + const REQUEST_COUNT: usize = 8; + for _ in 0..10 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let server_address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let mut version_request = String::new(); + assert!(reader.read_line(&mut version_request).unwrap() > 0); + let version_request: serde_json::Value = + serde_json::from_str(&version_request).unwrap(); + let version_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": version_request["id"], + "result": ["test-server", "1.4"], + }); + writeln!(reader.get_mut(), "{version_response}").unwrap(); + reader.get_mut().flush().unwrap(); + reader.get_mut().set_read_timeout(Some(Duration::from_millis(250))).unwrap(); + + let mut requests_received = 0; + while requests_received < 2 { + let mut request = String::new(); + match reader.read_line(&mut request) { + Ok(0) => break, + Ok(_) => requests_received += 1, + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break + }, + Err(e) => panic!("failed to read Electrum request: {e}"), + } + } + if requests_received > 1 { + thread::sleep(Duration::from_millis(50)); + } + requests_received + }); + + let config = ElectrumConfigBuilder::new().retry(0).build(); + let client = Arc::new( + ElectrumClient::from_config(&format!("tcp://{server_address}"), config).unwrap(), + ); + let start = Arc::new(Barrier::new(REQUEST_COUNT + 1)); + let (finished_sender, finished_receiver) = mpsc::channel(); + let mut requests = Vec::new(); + for _ in 0..REQUEST_COUNT { + let client = Arc::clone(&client); + let start = Arc::clone(&start); + let finished_sender = finished_sender.clone(); + requests.push(thread::spawn(move || { + start.wait(); + let result = + client.raw_call("server.ping", Vec::::new()); + finished_sender.send(result.is_err()).unwrap(); + })); + } + start.wait(); + let requests_received = server.join().unwrap(); + + for _ in 0..REQUEST_COUNT { + let failed_as_expected = finished_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("clean EOF must wake every concurrent Electrum request"); + assert!(failed_as_expected); + } + for request in requests { + request.join().unwrap(); + } + if requests_received > 1 { + return; + } + } + panic!("failed to issue concurrent Electrum requests"); + } + struct RecordingConfirm { calls: AtomicUsize, relevant_txid: Txid, From 5bc67b7972e10493476c8f9fb1eff2763222461f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 20 Oct 2026 18:20:24 +0200 Subject: [PATCH 4/5] Require wallet inputs to be finalized Mixed wallet and LDK transactions could be returned with unsigned wallet inputs because the aggregate signing result also accounts for unfinished foreign inputs. Require BDK to finalize every recognized wallet input while allowing inputs without a wallet descriptor to remain unfinished. Cover both signing paths with tests that exercise missing wallet signers. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 93 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 808b229ffe..1b868ea6d6 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -17,7 +17,8 @@ use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; use bdk_wallet::signer::SignersContainer; use bdk_wallet::{ - Balance, KeychainKind, LocalOutput, PersistedWallet, SignOptions, Update, WalletEvent, + Balance, FinalizeInputOutcome, KeychainKind, LocalOutput, PersistedWallet, SignOptions, Update, + WalletEvent, }; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; @@ -2580,6 +2581,7 @@ impl Wallet { return Err(()); }, } + self.finalize_wallet_inputs(&locked_wallet, &mut psbt)?; match psbt.extract_tx() { Ok(tx) => Ok(tx), @@ -2600,17 +2602,12 @@ impl Wallet { let mut sign_options = SignOptions::default(); sign_options.trust_witness_utxo = true; - match locked_wallet.sign_with_signers(&mut psbt, &self.signers.each_ref(), sign_options) { - Ok(_finalized) => { - // BDK will fail to finalize for all LDK-provided inputs of the PSBT. Unfortunately - // we can't check more fine grained if it succeeded for all the other inputs here, - // so we just ignore the returned `finalized` bool. - }, - Err(err) => { + locked_wallet + .sign_with_signers(&mut psbt, &self.signers.each_ref(), sign_options) + .map_err(|err| { log_error!(self.logger, "Failed to sign transaction: {}", err); - return Err(()); - }, - } + })?; + self.finalize_wallet_inputs(&locked_wallet, &mut psbt)?; let tx = psbt.extract_tx().map_err(|e| { log_error!(self.logger, "Failed to extract transaction: {}", e); @@ -2620,6 +2617,23 @@ impl Wallet { Ok(tx) } + fn finalize_wallet_inputs( + &self, wallet: &bdk_wallet::Wallet, psbt: &mut Psbt, + ) -> Result<(), ()> { + let outcome = wallet.try_finalize_psbt(psbt).map_err(|e| { + log_error!(self.logger, "Failed to finalize transaction: {}", e); + })?; + for (index, input_outcome) in outcome.into_outcomes() { + // MissingDescriptor is expected for LDK's inputs, which BDK cannot finalize. + // A descriptor that BDK recognizes must be satisfied before we return the transaction. + if let FinalizeInputOutcome::CouldNotSatisfy(e) = input_outcome { + log_error!(self.logger, "Failed to finalize wallet input {}: {}", index, e); + return Err(()); + } + } + Ok(()) + } + /// Builds this node's share of the `active` round of an interactive funding whose negotiated /// history is `candidates`, and the per-candidate figures of that history, for recording the /// round under `payment_id`. Returns `None` when there is nothing to record: no local @@ -4582,6 +4596,63 @@ mod tests { } } + #[tokio::test] + async fn sign_owned_inputs_requires_wallet_finalization() { + let (mut wallet, psbt) = mixed_input_psbt().await; + let tx = wallet.sign_owned_inputs(psbt.unsigned_tx.clone()).unwrap(); + assert_eq!(tx.input[0].witness.len(), 2); + assert!(tx.input[1].witness.is_empty(), "foreign inputs may remain unsigned"); + + Arc::get_mut(&mut wallet).unwrap().signers = Default::default(); + assert!( + wallet.sign_owned_inputs(psbt.unsigned_tx).is_err(), + "an unfinished wallet input must fail signing" + ); + } + + #[tokio::test] + async fn sign_psbt_requires_wallet_finalization() { + let (mut wallet, psbt) = mixed_input_psbt().await; + let tx = wallet.sign_psbt_inner(psbt.clone()).unwrap(); + assert_eq!(tx.input[0].witness.len(), 2); + assert!(tx.input[1].witness.is_empty(), "foreign inputs may remain unsigned"); + + Arc::get_mut(&mut wallet).unwrap().signers = Default::default(); + assert!( + wallet.sign_psbt_inner(psbt).is_err(), + "an unfinished wallet input must fail signing" + ); + } + + /// A PSBT spending one wallet output and one foreign output awaiting its owner's signature. + async fn mixed_input_psbt() -> (Arc, Psbt) { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + let funding_tx = wallet_paying_tx(&wallet, 42); + insert_confirmed_tx(&wallet, funding_tx.clone(), 1); + let foreign_output = + TxOut { value: Amount::from_sat(90_000), script_pubkey: ScriptBuf::new().to_p2wsh() }; + let unsigned_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: [ + OutPoint { txid: funding_tx.compute_txid(), vout: 0 }, + OutPoint { txid: Txid::from_byte_array([43; 32]), vout: 0 }, + ] + .into_iter() + .map(|previous_output| bitcoin::TxIn { previous_output, ..Default::default() }) + .collect(), + output: vec![TxOut { + value: Amount::from_sat(179_000), + script_pubkey: foreign_output.script_pubkey.clone(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(funding_tx.output[0].clone()); + psbt.inputs[1].witness_utxo = Some(foreign_output); + (wallet, psbt) + } + #[tokio::test] async fn refill_publishes_addresses_only_after_their_reveal_is_persisted() { let fail_store = FailSwitchStore::new(); From 32f0c52aa0824b5d11dd5295639e07f69916d022 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 20 Oct 2026 18:22:20 +0200 Subject: [PATCH 5/5] Test TRUC eligibility in funding estimates The maximum funding estimate relies on BDK's input selection. The BDK 3.2 TRUC filtering change needs coverage at this boundary. Check that unconfirmed v2 change remains usable, while v3 outputs only increase the available funding amount after confirmation. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1b868ea6d6..b7e2e095f6 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -8143,6 +8143,44 @@ mod tests { .is_none()); } + #[tokio::test] + async fn max_funding_waits_for_truc_output_confirmation() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let wallet = new_test_wallet(store, false).await; + let change_script = wallet + .inner + .lock() + .unwrap() + .reveal_next_address(KeychainKind::Internal) + .address + .script_pubkey(); + let fee_rate = FeeRate::from_sat_per_kwu(250); + + // Both outputs are trusted change, but our v2 funding transactions can only spend + // the v2 output while the v3 (TRUC) parent remains unconfirmed. + let mut regular_tx = wallet_paying_tx(&wallet, 42); + regular_tx.output[0].script_pubkey = change_script.clone(); + insert_unconfirmed_tx(&wallet, regular_tx); + let max_without_truc = wallet.get_max_funding_amount(0, fee_rate).unwrap(); + assert!(max_without_truc > 0); + + let mut truc_tx = wallet_paying_tx(&wallet, 43); + truc_tx.version = bitcoin::transaction::Version(3); + truc_tx.output[0].script_pubkey = change_script; + insert_unconfirmed_tx(&wallet, truc_tx.clone()); + assert_eq!( + wallet.get_max_funding_amount(0, fee_rate).unwrap(), + max_without_truc, + "unconfirmed TRUC outputs must not increase the v2 funding amount" + ); + + insert_confirmed_tx(&wallet, truc_tx, 1); + assert!( + wallet.get_max_funding_amount(0, fee_rate).unwrap() > max_without_truc, + "confirmed TRUC outputs must become available for funding" + ); + } + #[tokio::test] async fn max_funding_estimate_keeps_reserved_change_address_used() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new()));