From a33a7bdab2c2c796c2808db9912a7d4a27553493 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 2 Sep 2025 09:26:39 -0700 Subject: [PATCH] Account for pending splice in claimable balances There are two states in which a pending splice balance should be considered: 1. The channel has closed with a confirmed holder commitment transaction from a splice that did not become locked. The balance from this transaction is reported. 2. The channel is open and has multiple holder commitment transaction candidates that are valid based on the funding transaction that confirms. We want to report the pending splice balance to users while it has yet to become locked, such that they are able to see their funds have moved from their onchain wallet to the channel. We default to reporting the latest splice/RBF balance via `Balance::claimable_amount_satoshis` to mimic how an onchain wallet would behave when reporting unconfirmed balance, otherwise we report the balance for the confirmed splice transaction. --- lightning/src/chain/channelmonitor.rs | 124 +++++++++++++++++++------- lightning/src/ln/monitor_tests.rs | 75 +++++++++++----- 2 files changed, 146 insertions(+), 53 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index ee36c40130e..520f46c1afd 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -777,6 +777,24 @@ pub enum BalanceSource { Htlc, } +/// The claimable balance of a holder commitment transaction that has yet to be broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(test, derive(PartialOrd, Ord))] +pub struct HolderCommitmentTransactionBalance { + /// The amount available to claim, in satoshis, excluding the on-chain fees which will be + /// required to do so. + pub amount_satoshis: u64, + /// The transaction fee we pay for the closing commitment transaction. This amount is not + /// included in the [`HolderCommitmentTransactionBalance::amount_satoshis`] value. + /// This amount includes the sum of dust HTLCs on the commitment transaction, any elided anchors, + /// as well as the sum of msat amounts rounded down from non-dust HTLCs. + /// + /// Note that if this channel is inbound (and thus our counterparty pays the commitment + /// transaction fee) this value will be zero. For [`ChannelMonitor`]s created prior to LDK + /// 0.0.124, the channel is always treated as outbound (and thus this value is never zero). + pub transaction_fee_satoshis: u64, +} + /// Details about the balance(s) available for spending once the channel appears on chain. /// /// See [`ChannelMonitor::get_claimable_balances`] for more details on when these will or will not @@ -785,21 +803,26 @@ pub enum BalanceSource { #[cfg_attr(test, derive(PartialOrd, Ord))] pub enum Balance { /// The channel is not yet closed (or the commitment or closing transaction has not yet - /// appeared in a block). The given balance is claimable (less on-chain fees) if the channel is - /// force-closed now. + /// appeared in a block). ClaimableOnChannelClose { - /// The amount available to claim, in satoshis, excluding the on-chain fees which will be - /// required to do so. - amount_satoshis: u64, - /// The transaction fee we pay for the closing commitment transaction. This amount is not - /// included in the [`Balance::ClaimableOnChannelClose::amount_satoshis`] value. - /// This amount includes the sum of dust HTLCs on the commitment transaction, any elided anchors, - /// as well as the sum of msat amounts rounded down from non-dust HTLCs. + /// A list of balance candidates based on the latest set of valid holder commitment + /// transactions that can hit the chain. Typically, a channel only has one valid holder + /// commitment transaction that spends the current funding output. As soon as a channel is + /// spliced, an alternative holder commitment transaction exists spending the new funding + /// output. More alternative holder commitment transactions can exist as the splice remains + /// pending and RBF attempts are made. /// - /// Note that if this channel is inbound (and thus our counterparty pays the commitment - /// transaction fee) this value will be zero. For [`ChannelMonitor`]s created prior to LDK - /// 0.0.124, the channel is always treated as outbound (and thus this value is never zero). - transaction_fee_satoshis: u64, + /// The candidates are sorted by the order in which the holder commitment transactions were + /// negotiated. When only one candidate exists, the channel does not have a splice pending. + /// When multiple candidates exist, the last one reflects the balance of the + /// latest splice/RBF attempt, while the first reflects the balance prior to the splice + /// occurring. + balance_candidates: Vec, + /// The index within [`Balance::ClaimableOnChannelClose::balance_candidates`] for the + /// balance according to the current onchain state of the channel. This can be helpful when + /// wanting to determine the claimable amount when the holder commitment transaction for the + /// current funding transaction is broadcast and/or confirms. + confirmed_balance_candidate_index: usize, /// The amount of millisatoshis which has been burned to fees from HTLCs which are outbound /// from us and are related to a payment which was sent by us. This is the sum of the /// millisatoshis part of all HTLCs which are otherwise represented by @@ -821,7 +844,7 @@ pub enum Balance { /// to us and for which we know the preimage. This is the sum of the millisatoshis part of /// all HTLCs which would be represented by [`Balance::ContentiousClaimable`] on channel /// close, but whose current value is included in - /// [`Balance::ClaimableOnChannelClose::amount_satoshis`], as well as any dust HTLCs which + /// [`HolderCommitmentTransactionBalance::amount_satoshis`], as well as any dust HTLCs which /// would otherwise be represented the same. /// /// This amount (rounded up to a whole satoshi value) will not be included in the counterparty's @@ -928,7 +951,18 @@ impl Balance { #[rustfmt::skip] pub fn claimable_amount_satoshis(&self) -> u64 { match self { - Balance::ClaimableOnChannelClose { amount_satoshis, .. }| + Balance::ClaimableOnChannelClose { + balance_candidates, confirmed_balance_candidate_index, .. + } => { + // If we have multiple candidates due to a splice, and one of the splice + // transactions has confirmed, report the corresponding balance. Otherwise, the + // splice is unconfirmed, so report the balance for the latest negotiated attempt. + if *confirmed_balance_candidate_index != 0 { + balance_candidates[*confirmed_balance_candidate_index].amount_satoshis + } else { + balance_candidates.last().map(|balance| balance.amount_satoshis).unwrap_or(0) + } + }, Balance::ClaimableAwaitingConfirmations { amount_satoshis, .. }| Balance::ContentiousClaimable { amount_satoshis, .. }| Balance::CounterpartyRevokedOutputClaimable { amount_satoshis, .. } @@ -2671,7 +2705,8 @@ impl ChannelMonitorImpl { debug_assert!(htlc_input_idx_opt.is_some()); BitcoinOutPoint::new(*txid, htlc_input_idx_opt.unwrap_or(0)) } else { - debug_assert!(!self.channel_type_features().supports_anchors_zero_fee_htlc_tx()); + let funding = get_confirmed_funding_scope!(self); + debug_assert!(!funding.channel_type_features().supports_anchors_zero_fee_htlc_tx()); BitcoinOutPoint::new(*txid, 0) } } else { @@ -2833,8 +2868,9 @@ impl ChannelMonitor { } if let Some(txid) = confirmed_txid { + let funding_spent = get_confirmed_funding_scope!(us); let mut found_commitment_tx = false; - if let Some(counterparty_tx_htlcs) = us.funding.counterparty_claimable_outpoints.get(&txid) { + if let Some(counterparty_tx_htlcs) = funding_spent.counterparty_claimable_outpoints.get(&txid) { // First look for the to_remote output back to us. if let Some(conf_thresh) = pending_commitment_tx_conf_thresh { if let Some(value) = us.onchain_events_awaiting_threshold_conf.iter().find_map(|event| { @@ -2855,7 +2891,7 @@ impl ChannelMonitor { // confirmation with the same height or have never met our dust amount. } } - if Some(txid) == us.funding.current_counterparty_commitment_txid || Some(txid) == us.funding.prev_counterparty_commitment_txid { + if Some(txid) == funding_spent.current_counterparty_commitment_txid || Some(txid) == funding_spent.prev_counterparty_commitment_txid { walk_htlcs!(false, false, counterparty_tx_htlcs.iter().map(|(a, b)| (a, b.as_ref().map(|b| &**b)))); } else { walk_htlcs!(false, true, counterparty_tx_htlcs.iter().map(|(a, b)| (a, b.as_ref().map(|b| &**b)))); @@ -2898,17 +2934,17 @@ impl ChannelMonitor { } } found_commitment_tx = true; - } else if txid == us.funding.current_holder_commitment_tx.trust().txid() { + } else if txid == funding_spent.current_holder_commitment_tx.trust().txid() { walk_htlcs!(true, false, holder_commitment_htlcs!(us, CURRENT_WITH_SOURCES)); if let Some(conf_thresh) = pending_commitment_tx_conf_thresh { res.push(Balance::ClaimableAwaitingConfirmations { - amount_satoshis: us.funding.current_holder_commitment_tx.to_broadcaster_value_sat(), + amount_satoshis: funding_spent.current_holder_commitment_tx.to_broadcaster_value_sat(), confirmation_height: conf_thresh, source: BalanceSource::HolderForceClosed, }); } found_commitment_tx = true; - } else if let Some(prev_holder_commitment_tx) = &us.funding.prev_holder_commitment_tx { + } else if let Some(prev_holder_commitment_tx) = &funding_spent.prev_holder_commitment_tx { if txid == prev_holder_commitment_tx.trust().txid() { walk_htlcs!(true, false, holder_commitment_htlcs!(us, PREV_WITH_SOURCES).unwrap()); if let Some(conf_thresh) = pending_commitment_tx_conf_thresh { @@ -2927,7 +2963,7 @@ impl ChannelMonitor { // neither us nor our counterparty misbehaved. At worst we've under-estimated // the amount we can claim as we'll punish a misbehaving counterparty. res.push(Balance::ClaimableAwaitingConfirmations { - amount_satoshis: us.funding.current_holder_commitment_tx.to_broadcaster_value_sat(), + amount_satoshis: funding_spent.current_holder_commitment_tx.to_broadcaster_value_sat(), confirmation_height: conf_thresh, source: BalanceSource::CoopClose, }); @@ -2939,6 +2975,8 @@ impl ChannelMonitor { let mut outbound_forwarded_htlc_rounded_msat = 0; let mut inbound_claiming_htlc_rounded_msat = 0; let mut inbound_htlc_rounded_msat = 0; + // We share the same set of HTLCs across all scopes, so we don't need to check the other + // scopes as it'd be redundant. for (htlc, source) in holder_commitment_htlcs!(us, CURRENT_WITH_SOURCES) { let rounded_value_msat = if htlc.transaction_output_index.is_none() { htlc.amount_msat @@ -2980,16 +3018,40 @@ impl ChannelMonitor { } } } - let to_self_value_sat = us.funding.current_holder_commitment_tx.to_broadcaster_value_sat(); + let balance_candidates = core::iter::once(&us.funding) + .chain(us.pending_funding.iter()) + .map(|funding| { + let to_self_value_sat = funding.current_holder_commitment_tx.to_broadcaster_value_sat(); + // In addition to `commit_tx_fee_sat`, this can also include dust HTLCs, any + // elided anchors, and the total msat amount rounded down from non-dust HTLCs. + let transaction_fee_satoshis = if us.holder_pays_commitment_tx_fee.unwrap_or(true) { + let transaction = &funding.current_holder_commitment_tx.trust().built_transaction().transaction; + let output_value_sat: u64 = transaction.output.iter().map(|txout| txout.value.to_sat()).sum(); + funding.channel_parameters.channel_value_satoshis - output_value_sat + } else { + 0 + }; + HolderCommitmentTransactionBalance { + amount_satoshis: to_self_value_sat + claimable_inbound_htlc_value_sat, + transaction_fee_satoshis, + } + }) + .collect(); + let confirmed_balance_candidate_index = core::iter::once(&us.funding) + .chain(us.pending_funding.iter()) + .enumerate() + .find(|(_, funding)| { + us.alternative_funding_confirmed + .map(|(funding_txid_confirmed, _)| funding.funding_txid() == funding_txid_confirmed) + // If `alternative_funding_confirmed` is not set, we can assume the current + // funding is confirmed. + .unwrap_or(true) + }) + .map(|(idx, _)| idx) + .expect("FundingScope for confirmed alternative funding must exist"); res.push(Balance::ClaimableOnChannelClose { - amount_satoshis: to_self_value_sat + claimable_inbound_htlc_value_sat, - // In addition to `commit_tx_fee_sat`, this can also include dust HTLCs, any elided anchors, - // and the total msat amount rounded down from non-dust HTLCs - transaction_fee_satoshis: if us.holder_pays_commitment_tx_fee.unwrap_or(true) { - let transaction = &us.funding.current_holder_commitment_tx.trust().built_transaction().transaction; - let output_value_sat: u64 = transaction.output.iter().map(|txout| txout.value.to_sat()).sum(); - us.funding.channel_parameters.channel_value_satoshis - output_value_sat - } else { 0 }, + balance_candidates, + confirmed_balance_candidate_index, outbound_payment_htlc_rounded_msat, outbound_forwarded_htlc_rounded_msat, inbound_claiming_htlc_rounded_msat, diff --git a/lightning/src/ln/monitor_tests.rs b/lightning/src/ln/monitor_tests.rs index 8e08e5c6ba6..64ec63f93e9 100644 --- a/lightning/src/ln/monitor_tests.rs +++ b/lightning/src/ln/monitor_tests.rs @@ -13,7 +13,7 @@ use crate::sign::{ecdsa::EcdsaChannelSigner, OutputSpender, SignerProvider, SpendableOutputDescriptor}; use crate::chain::Watch; -use crate::chain::channelmonitor::{ANTI_REORG_DELAY, ARCHIVAL_DELAY_BLOCKS,LATENCY_GRACE_PERIOD_BLOCKS, COUNTERPARTY_CLAIMABLE_WITHIN_BLOCKS_PINNABLE, Balance, BalanceSource, ChannelMonitorUpdateStep}; +use crate::chain::channelmonitor::{Balance, BalanceSource, ChannelMonitorUpdateStep, HolderCommitmentTransactionBalance, ANTI_REORG_DELAY, ARCHIVAL_DELAY_BLOCKS, COUNTERPARTY_CLAIMABLE_WITHIN_BLOCKS_PINNABLE, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::chaininterface::{ConfirmationTarget, LowerBoundedFeeEstimator, compute_feerate_sat_per_1000_weight}; use crate::events::bump_transaction::{BumpTransactionEvent}; @@ -334,8 +334,11 @@ fn do_chanmon_claim_value_coop_close(anchors: bool) { let commitment_tx_fee = chan_feerate * chan_utils::commitment_tx_base_weight(&channel_type_features) / 1000; let anchor_outputs_value = if anchors { channel::ANCHOR_OUTPUT_VALUE_SATOSHI * 2 } else { 0 }; assert_eq!(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 1_000_000 - 1_000 - commitment_tx_fee - anchor_outputs_value, - transaction_fee_satoshis: commitment_tx_fee, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 1_000_000 - 1_000 - commitment_tx_fee - anchor_outputs_value, + transaction_fee_satoshis: commitment_tx_fee, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 0, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0, @@ -343,7 +346,11 @@ fn do_chanmon_claim_value_coop_close(anchors: bool) { }], nodes[0].chain_monitor.chain_monitor.get_monitor(chan_id).unwrap().get_claimable_balances()); assert_eq!(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 1_000, transaction_fee_satoshis: 0, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 1_000, + transaction_fee_satoshis: 0, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 0, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0, @@ -530,9 +537,12 @@ fn do_test_claim_value_force_close(anchors: bool, prev_commitment_tx: bool) { let anchor_outputs_value = if anchors { 2 * channel::ANCHOR_OUTPUT_VALUE_SATOSHI } else { 0 }; let amount_satoshis = 1_000_000 - 3_000 - 4_000 - 1_000 - 3 - commitment_tx_fee - anchor_outputs_value - 1; /* msat amount that is burned to fees */ assert_eq!(sorted_vec(vec![Balance::ClaimableOnChannelClose { - amount_satoshis, - // In addition to `commitment_tx_fee`, this also includes the dust HTLC, and the total msat amount rounded down from non-dust HTLCs - transaction_fee_satoshis: 1_000_000 - 4_000 - 3_000 - 1_000 - amount_satoshis - anchor_outputs_value, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis, + // In addition to `commitment_tx_fee`, this also includes the dust HTLC, and the total msat amount rounded down from non-dust HTLCs + transaction_fee_satoshis: 1_000_000 - 4_000 - 3_000 - 1_000 - amount_satoshis - anchor_outputs_value, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 3300, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0, @@ -540,8 +550,11 @@ fn do_test_claim_value_force_close(anchors: bool, prev_commitment_tx: bool) { }, sent_htlc_balance.clone(), sent_htlc_timeout_balance.clone()]), sorted_vec(nodes[0].chain_monitor.chain_monitor.get_monitor(chan_id).unwrap().get_claimable_balances())); assert_eq!(sorted_vec(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 1_000, - transaction_fee_satoshis: 0, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 1_000, + transaction_fee_satoshis: 0, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 0, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0, @@ -594,9 +607,12 @@ fn do_test_claim_value_force_close(anchors: bool, prev_commitment_tx: bool) { anchor_outputs_value - // The anchor outputs value in satoshis 1; // The rounded up msat part of the one HTLC let mut a_expected_balances = vec![Balance::ClaimableOnChannelClose { - amount_satoshis, // Channel funding value in satoshis - // In addition to `commitment_tx_fee`, this also includes the dust HTLC, and the total msat amount rounded down from non-dust HTLCs - transaction_fee_satoshis: 1_000_000 - 4_000 - 3_000 - 1_000 - amount_satoshis - anchor_outputs_value, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis, // Channel funding value in satoshis + // In addition to `commitment_tx_fee`, this also includes the dust HTLC, and the total msat amount rounded down from non-dust HTLCs + transaction_fee_satoshis: 1_000_000 - 4_000 - 3_000 - 1_000 - amount_satoshis - anchor_outputs_value, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 3000 + if prev_commitment_tx { 200 /* 1 to-be-failed HTLC */ } else { 300 /* 2 HTLCs */ }, outbound_forwarded_htlc_rounded_msat: 0, @@ -609,8 +625,11 @@ fn do_test_claim_value_force_close(anchors: bool, prev_commitment_tx: bool) { assert_eq!(sorted_vec(a_expected_balances), sorted_vec(nodes[0].chain_monitor.chain_monitor.get_monitor(chan_id).unwrap().get_claimable_balances())); assert_eq!(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 1_000 + 3_000 + 4_000, - transaction_fee_satoshis: 0, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 1_000 + 3_000 + 4_000, + transaction_fee_satoshis: 0, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 0, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 3000 + if prev_commitment_tx { @@ -1126,8 +1145,11 @@ fn test_no_preimage_inbound_htlc_balances() { let commitment_tx_fee = chan_feerate * (chan_utils::commitment_tx_base_weight(&channel_type_features) + 2 * chan_utils::COMMITMENT_TX_WEIGHT_PER_HTLC) / 1000; assert_eq!(sorted_vec(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 1_000_000 - 500_000 - 10_000 - commitment_tx_fee, - transaction_fee_satoshis: commitment_tx_fee, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 1_000_000 - 500_000 - 10_000 - commitment_tx_fee, + transaction_fee_satoshis: commitment_tx_fee, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 0, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0, @@ -1136,8 +1158,11 @@ fn test_no_preimage_inbound_htlc_balances() { sorted_vec(nodes[0].chain_monitor.chain_monitor.get_monitor(chan_id).unwrap().get_claimable_balances())); assert_eq!(sorted_vec(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 500_000 - 20_000, - transaction_fee_satoshis: 0, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 500_000 - 20_000, + transaction_fee_satoshis: 0, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 0, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0, @@ -1430,8 +1455,11 @@ fn do_test_revoked_counterparty_commitment_balances(anchors: bool, confirm_htlc_ assert_eq!( sorted_vec(vec![ Balance::ClaimableOnChannelClose { - amount_satoshis: 100_000 - 5_000 - 4_000 - 3 - 2_000 + 3_000 - 1 /* rounded up msat parts of HTLCs */, - transaction_fee_satoshis: 0, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 100_000 - 5_000 - 4_000 - 3 - 2_000 + 3_000 - 1 /* rounded up msat parts of HTLCs */, + transaction_fee_satoshis: 0, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 3200, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 100, @@ -1967,8 +1995,11 @@ fn do_test_revoked_counterparty_aggregated_claims(anchors: bool) { let _a_htlc_msgs = get_htlc_update_msgs!(&nodes[0], nodes[1].node.get_our_node_id()); assert_eq!(sorted_vec(vec![Balance::ClaimableOnChannelClose { - amount_satoshis: 100_000 - 4_000 - 3_000 - 1 /* rounded up msat parts of HTLCs */, - transaction_fee_satoshis: 0, + balance_candidates: vec![HolderCommitmentTransactionBalance { + amount_satoshis: 100_000 - 4_000 - 3_000 - 1 /* rounded up msat parts of HTLCs */, + transaction_fee_satoshis: 0, + }], + confirmed_balance_candidate_index: 0, outbound_payment_htlc_rounded_msat: 100, outbound_forwarded_htlc_rounded_msat: 0, inbound_claiming_htlc_rounded_msat: 0,