Skip to content

Commit 5dce44b

Browse files
authored
Add Node::get_max_splice_in_amount accessor (#28)
Computes the wallet's maximum splice-in contribution for a channel via a dry-run drain transaction at the live ChannelFunding fee rate, preserving the anchor-channels reserve as a wallet change output. Plugs directly into splice_in as splice_amount_sats, so callers can splice everything without computing fees themselves. Backports upstream's Node::splice_in_with_all (4a2cefc) without the splice API rework (d9336f2) that sits between it and our pin. A pin bump is painful because of LSPS4 fork rebases on rust-lightning, so we replicate just the amount computation here and leave splice_in unchanged. Once we do bump past d9336f2, this accessor is the only thing that gets replaced with splice_in_with_all, and mdkd's caller follows. The drain pattern mirrors Wallet::send_to_address's AllRetainingReserve branch: drain_wallet plus drain_to(funding script), with an add_recipient for cur_anchor_reserve_sats when non-dust so the reserve survives as wallet change. Wallet net contribution = drain output value minus foreign input total, which is what splice_in adds to the channel. The motivating caller is mdkd's auto-splice manager. It previously computed a static 400 vB upper-bound fee, capped at N=3 client UTXOs, so consolidation flows had to close channels one or two at a time. With this accessor mdkd uses BDK's exact coin selection and that caveat goes away. A pure-mdkd alternative was considered: binary-search splice_in calls until one succeeds. We didn't take it because Error::ChannelSplicingFailed collapses BDK selection failure and genuine peer rejection into one variant, so the retry loop can't tell "shrink the amount" from "give up".
1 parent 8d956d7 commit 5dce44b

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,85 @@ impl Node {
12731273
)
12741274
}
12751275

1276+
/// Computes the maximum amount that can be spliced into an existing channel
1277+
/// using all confirmed on-chain wallet funds.
1278+
///
1279+
/// Performs a dry-run drain transaction at the current `ChannelFunding`
1280+
/// fee rate, accounting for splice tx fees and the anchor-channels reserve.
1281+
/// Returns the wallet's net contribution — directly suitable as the
1282+
/// `splice_amount_sats` argument to [`Self::splice_in`].
1283+
///
1284+
/// Returns `Error::InsufficientFunds` if the wallet's confirmed balance
1285+
/// (after reserves and fees) would be below the dust limit, and
1286+
/// `Error::ChannelSplicingFailed` if the channel is unknown or not yet
1287+
/// ready.
1288+
///
1289+
/// This is a query helper; it does not initiate any splice negotiation
1290+
/// or modify wallet state.
1291+
pub fn get_max_splice_in_amount(
1292+
&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
1293+
) -> Result<u64, Error> {
1294+
let open_channels =
1295+
self.channel_manager.list_channels_with_counterparty(&counterparty_node_id);
1296+
let channel_details = open_channels
1297+
.iter()
1298+
.find(|c| c.user_channel_id == user_channel_id.0)
1299+
.ok_or_else(|| {
1300+
log_error!(
1301+
self.logger,
1302+
"Channel not found for user_channel_id {} and counterparty {}",
1303+
user_channel_id,
1304+
counterparty_node_id
1305+
);
1306+
Error::ChannelSplicingFailed
1307+
})?;
1308+
1309+
const EMPTY_SCRIPT_SIG_WEIGHT: u64 =
1310+
1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64;
1311+
1312+
let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap();
1313+
1314+
let funding_txo = channel_details.funding_txo.ok_or_else(|| {
1315+
log_error!(
1316+
self.logger,
1317+
"Cannot compute max splice amount: channel not yet ready",
1318+
);
1319+
Error::ChannelSplicingFailed
1320+
})?;
1321+
1322+
let funding_script = make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh();
1323+
1324+
let shared_input = Input {
1325+
outpoint: funding_txo.into_bitcoin_outpoint(),
1326+
previous_utxo: bitcoin::TxOut {
1327+
value: Amount::from_sat(channel_details.channel_value_satoshis),
1328+
script_pubkey: funding_script.clone(),
1329+
},
1330+
satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT,
1331+
};
1332+
1333+
let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
1334+
let cur_anchor_reserve_sats =
1335+
total_anchor_channels_reserve_sats(&self.channel_manager, &self.config);
1336+
1337+
self.wallet
1338+
.get_max_splice_in_amount(
1339+
vec![shared_input],
1340+
funding_script,
1341+
cur_anchor_reserve_sats,
1342+
fee_rate,
1343+
)
1344+
.map(|a| a.to_sat())
1345+
.map_err(|()| {
1346+
log_error!(
1347+
self.logger,
1348+
"Insufficient confirmed wallet funds for splice into channel {}",
1349+
user_channel_id,
1350+
);
1351+
Error::InsufficientFunds
1352+
})
1353+
}
1354+
12761355
/// Add funds from the on-chain wallet into an existing channel.
12771356
///
12781357
/// This provides for increasing a channel's outbound liquidity without re-balancing or closing

src/wallet/mod.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,85 @@ impl Wallet {
577577
Ok(txid)
578578
}
579579

580+
/// Computes the maximum amount that can be spliced into a channel using all
581+
/// confirmed wallet UTXOs, given a fixed foreign-contributed input set
582+
/// (typically the channel's existing funding outpoint).
583+
///
584+
/// Builds a dry-run drain transaction: foreign UTXOs in `must_spend` are
585+
/// added as `add_foreign_utxo`, all confirmed wallet UTXOs are drained,
586+
/// the entire selected value goes to `drain_script` (sized to match the
587+
/// real splice funding output), and `cur_anchor_reserve_sats` is reserved
588+
/// as a wallet change output if non-dust. The returned amount equals
589+
/// drain output value − total foreign input value, i.e. the wallet's net
590+
/// contribution after splice fees and anchor reserves.
591+
///
592+
/// Returns `Err(())` if the resulting wallet contribution is below the
593+
/// dust limit, or if BDK's coin selection fails for any other reason.
594+
pub(crate) fn get_max_splice_in_amount(
595+
&self, must_spend: Vec<Input>, drain_script: ScriptBuf,
596+
cur_anchor_reserve_sats: u64, fee_rate: FeeRate,
597+
) -> Result<Amount, ()> {
598+
// Conservative dust threshold matching the legacy P2PKH dust limit at
599+
// the standard 3 sat/vB relay-fee policy. P2WPKH (294) and P2WSH (330)
600+
// would each be tighter;
601+
const DUST_LIMIT_SATS: u64 = 546;
602+
603+
let mut locked_wallet = self.inner.lock().unwrap();
604+
debug_assert!(matches!(
605+
locked_wallet.public_descriptor(KeychainKind::External),
606+
ExtendedDescriptor::Wpkh(_)
607+
));
608+
debug_assert!(matches!(
609+
locked_wallet.public_descriptor(KeychainKind::Internal),
610+
ExtendedDescriptor::Wpkh(_)
611+
));
612+
613+
let reserve_change_script = (cur_anchor_reserve_sats > DUST_LIMIT_SATS)
614+
.then(|| locked_wallet.peek_address(KeychainKind::Internal, 0).address.script_pubkey());
615+
616+
let mut tx_builder = locked_wallet.build_tx();
617+
tx_builder.only_witness_utxo();
618+
619+
for input in &must_spend {
620+
let psbt_input = psbt::Input {
621+
witness_utxo: Some(input.previous_utxo.clone()),
622+
..Default::default()
623+
};
624+
let weight = Weight::from_wu(input.satisfaction_weight);
625+
tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?;
626+
}
627+
628+
tx_builder.drain_wallet().drain_to(drain_script.clone()).fee_rate(fee_rate);
629+
tx_builder.exclude_unconfirmed();
630+
631+
if let Some(script) = reserve_change_script {
632+
tx_builder.add_recipient(script, Amount::from_sat(cur_anchor_reserve_sats));
633+
}
634+
635+
let psbt = tx_builder.finish().map_err(|e| {
636+
log_error!(self.logger, "Failed to compute max splice-in amount: {}", e);
637+
})?;
638+
639+
let drain_output_amount = psbt
640+
.unsigned_tx
641+
.output
642+
.iter()
643+
.find(|o| o.script_pubkey == drain_script)
644+
.map(|o| o.value)
645+
.ok_or(())?;
646+
647+
let foreign_input_total: Amount =
648+
must_spend.iter().map(|i| i.previous_utxo.value).sum();
649+
650+
let wallet_contribution = drain_output_amount.checked_sub(foreign_input_total).ok_or(())?;
651+
652+
if wallet_contribution.to_sat() < DUST_LIMIT_SATS {
653+
return Err(());
654+
}
655+
656+
Ok(wallet_contribution)
657+
}
658+
580659
pub(crate) fn select_confirmed_utxos(
581660
&self, must_spend: Vec<Input>, must_pay_to: &[TxOut], fee_rate: FeeRate,
582661
) -> Result<Vec<FundingTxInput>, ()> {

0 commit comments

Comments
 (0)