diff --git a/src/lib.rs b/src/lib.rs index e5075f2282..bfaec913c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1273,6 +1273,85 @@ impl Node { ) } + /// Computes the maximum amount that can be spliced into an existing channel + /// using all confirmed on-chain wallet funds. + /// + /// Performs a dry-run drain transaction at the current `ChannelFunding` + /// fee rate, accounting for splice tx fees and the anchor-channels reserve. + /// Returns the wallet's net contribution — directly suitable as the + /// `splice_amount_sats` argument to [`Self::splice_in`]. + /// + /// Returns `Error::InsufficientFunds` if the wallet's confirmed balance + /// (after reserves and fees) would be below the dust limit, and + /// `Error::ChannelSplicingFailed` if the channel is unknown or not yet + /// ready. + /// + /// This is a query helper; it does not initiate any splice negotiation + /// or modify wallet state. + pub fn get_max_splice_in_amount( + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + ) -> Result { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + let channel_details = open_channels + .iter() + .find(|c| c.user_channel_id == user_channel_id.0) + .ok_or_else(|| { + log_error!( + self.logger, + "Channel not found for user_channel_id {} and counterparty {}", + user_channel_id, + counterparty_node_id + ); + Error::ChannelSplicingFailed + })?; + + const EMPTY_SCRIPT_SIG_WEIGHT: u64 = + 1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64; + + let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap(); + + let funding_txo = channel_details.funding_txo.ok_or_else(|| { + log_error!( + self.logger, + "Cannot compute max splice amount: channel not yet ready", + ); + Error::ChannelSplicingFailed + })?; + + let funding_script = make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(); + + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo: bitcoin::TxOut { + value: Amount::from_sat(channel_details.channel_value_satoshis), + script_pubkey: funding_script.clone(), + }, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; + + let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + let cur_anchor_reserve_sats = + total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + + self.wallet + .get_max_splice_in_amount( + vec![shared_input], + funding_script, + cur_anchor_reserve_sats, + fee_rate, + ) + .map(|a| a.to_sat()) + .map_err(|()| { + log_error!( + self.logger, + "Insufficient confirmed wallet funds for splice into channel {}", + user_channel_id, + ); + Error::InsufficientFunds + }) + } + /// Add funds from the on-chain wallet into an existing channel. /// /// This provides for increasing a channel's outbound liquidity without re-balancing or closing diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 5bcd8364bc..7315a426ef 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -577,6 +577,85 @@ impl Wallet { Ok(txid) } + /// Computes the maximum amount that can be spliced into a channel using all + /// confirmed wallet UTXOs, given a fixed foreign-contributed input set + /// (typically the channel's existing funding outpoint). + /// + /// Builds a dry-run drain transaction: foreign UTXOs in `must_spend` are + /// added as `add_foreign_utxo`, all confirmed wallet UTXOs are drained, + /// the entire selected value goes to `drain_script` (sized to match the + /// real splice funding output), and `cur_anchor_reserve_sats` is reserved + /// as a wallet change output if non-dust. The returned amount equals + /// drain output value − total foreign input value, i.e. the wallet's net + /// contribution after splice fees and anchor reserves. + /// + /// Returns `Err(())` if the resulting wallet contribution is below the + /// dust limit, or if BDK's coin selection fails for any other reason. + pub(crate) fn get_max_splice_in_amount( + &self, must_spend: Vec, drain_script: ScriptBuf, + cur_anchor_reserve_sats: u64, fee_rate: FeeRate, + ) -> Result { + // Conservative dust threshold matching the legacy P2PKH dust limit at + // the standard 3 sat/vB relay-fee policy. P2WPKH (294) and P2WSH (330) + // would each be tighter; + const DUST_LIMIT_SATS: u64 = 546; + + let mut locked_wallet = self.inner.lock().unwrap(); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::External), + ExtendedDescriptor::Wpkh(_) + )); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::Internal), + ExtendedDescriptor::Wpkh(_) + )); + + let reserve_change_script = (cur_anchor_reserve_sats > DUST_LIMIT_SATS) + .then(|| locked_wallet.peek_address(KeychainKind::Internal, 0).address.script_pubkey()); + + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.only_witness_utxo(); + + for input in &must_spend { + let psbt_input = psbt::Input { + witness_utxo: Some(input.previous_utxo.clone()), + ..Default::default() + }; + let weight = Weight::from_wu(input.satisfaction_weight); + tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; + } + + tx_builder.drain_wallet().drain_to(drain_script.clone()).fee_rate(fee_rate); + tx_builder.exclude_unconfirmed(); + + if let Some(script) = reserve_change_script { + tx_builder.add_recipient(script, Amount::from_sat(cur_anchor_reserve_sats)); + } + + let psbt = tx_builder.finish().map_err(|e| { + log_error!(self.logger, "Failed to compute max splice-in amount: {}", e); + })?; + + let drain_output_amount = psbt + .unsigned_tx + .output + .iter() + .find(|o| o.script_pubkey == drain_script) + .map(|o| o.value) + .ok_or(())?; + + let foreign_input_total: Amount = + must_spend.iter().map(|i| i.previous_utxo.value).sum(); + + let wallet_contribution = drain_output_amount.checked_sub(foreign_input_total).ok_or(())?; + + if wallet_contribution.to_sat() < DUST_LIMIT_SATS { + return Err(()); + } + + Ok(wallet_contribution) + } + pub(crate) fn select_confirmed_utxos( &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, ) -> Result, ()> {