Expose pending splice details in ChannelDetails
What changed, and why it matters
This commit adds a new read-only API field that lets users query the status of in-progress channel splice operations on demand. It does not change protocol behavior, permissions, or how funds are handled; it only exposes internal state that was previously visible only through event notifications. There is no indication this introduces a security vulnerability.
No security action required. This is a feature/API-visibility change. Reviewers may want to confirm that no sensitive internal data beyond intended splice metadata is exposed, and that serialization backward compatibility is handled correctly.
Security signals we found
No new network messages or protocol changes
No new cryptographic operations
No changes to authorization, fee handling, or transaction signing
Only adds read-only introspection of existing splice state
Extensive test coverage added for serialization and state transitions
Evidence from the diff
The patch introduces ChannelDetails::splice_details and supporting types (SpliceDetails, SpliceCandidateDetails, SpliceCandidateStatus, ConfirmedSpliceCandidate) to report pending splice/RBF candidate state. It adds a to_details conversion on PendingFunding, a pending_splice_details helper on Channel, and serialization entries for the new optional TLV field. Fuzz targets and router tests are updated to populate the new field. The change is purely informational: it reads existing channel state and returns it via list_channels.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channel_state.rslightning/src/ln/splicing_tests.rsfuzz/src/chanmon_consistency.rsfuzz/src/router.rslightning/src/routing/router.rsInspect captured patch +1581 / −4
diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs
index dfd1b4a..959509c 100644
--- a/fuzz/src/chanmon_consistency.rs
+++ b/fuzz/src/chanmon_consistency.rs
@@ -4233,6 +4233,13 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
},
_ => break 'fuzz_loop,
}
+
+ // Compute `ChannelDetails` for every channel after each step (ignoring the result) so the
+ // fuzzer exercises the splice-details derivation in `to_details` across as many states as
+ // possible.
+ for node in harness.nodes.iter() {
+ let _ = node.list_channels();
+ }
}
harness.finish();
}
diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs
index 2295ae3..aa3d274 100644
--- a/fuzz/src/router.rs
+++ b/fuzz/src/router.rs
@@ -257,6 +257,7 @@ pub fn do_test<Out: test_logger::Output>(data: &[u8], out: Out) {
pending_inbound_htlcs: Vec::new(),
pending_outbound_htlcs: Vec::new(),
current_dust_exposure_msat: None,
+ splice_details: None,
});
}
Some(&$first_hops_vec[..])
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 3242ac8..ce35787 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -47,8 +47,9 @@ use crate::ln::chan_utils::{
EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT,
};
use crate::ln::channel_state::{
- ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails,
- OutboundHTLCDetails, OutboundHTLCStateDetails,
+ ChannelShutdownState, ConfirmedSpliceCandidate, CounterpartyForwardingInfo, InboundHTLCDetails,
+ InboundHTLCStateDetails, OutboundHTLCDetails, OutboundHTLCStateDetails, SpliceCandidateDetails,
+ SpliceCandidateStatus, SpliceDetails,
};
use crate::ln::channelmanager::{
self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg,
@@ -3360,6 +3361,88 @@ impl PendingFunding {
})
}
+ fn to_details<SP: SignerProvider>(
+ &self, context: &ChannelContext<SP>, best_block_height: u32,
+ ) -> SpliceDetails {
+ let mut candidates: Vec<SpliceCandidateDetails> = self
+ .negotiated_candidates
+ .iter()
+ .map(|candidate| SpliceCandidateDetails {
+ contribution: candidate.contribution.clone(),
+ status: SpliceCandidateStatus::Negotiated {
+ txid: candidate
+ .funding
+ .get_funding_txid()
+ .expect("negotiated candidates should have a funding txid"),
+ new_channel_value_satoshis: candidate.funding.get_value_satoshis(),
+ },
+ })
+ .collect();
+
+ // The round currently under negotiation, if any, follows the negotiated candidates.
+ if let Some(funding_negotiation) = self.funding_negotiation.as_ref() {
+ let is_initiator = funding_negotiation.is_initiator();
+ let funding_feerate_sat_per_1000_weight =
+ funding_negotiation.funding_feerate_sat_per_1000_weight();
+ let status = match funding_negotiation {
+ FundingNegotiation::AwaitingAck { .. } => SpliceCandidateStatus::AwaitingAck {
+ is_initiator,
+ funding_feerate_sat_per_1000_weight,
+ },
+ FundingNegotiation::ConstructingTransaction { funding, .. } => {
+ SpliceCandidateStatus::ConstructingTransaction {
+ is_initiator,
+ funding_feerate_sat_per_1000_weight,
+ new_channel_value_satoshis: funding.get_value_satoshis(),
+ }
+ },
+ FundingNegotiation::AwaitingSignatures { funding, .. } => {
+ SpliceCandidateStatus::AwaitingSignatures {
+ is_initiator,
+ funding_feerate_sat_per_1000_weight,
+ new_channel_value_satoshis: funding.get_value_satoshis(),
+ txid: funding
+ .get_funding_txid()
+ .expect("a splice awaiting signatures should have a funding txid"),
+ }
+ },
+ };
+ candidates.push(SpliceCandidateDetails {
+ contribution: self.negotiation_contribution.clone(),
+ status,
+ });
+ }
+ // At most one candidate can confirm, as they all double-spend the same input. A zero-conf
+ // splice is locked (we send `splice_locked`) before it has any confirmations, so also report
+ // a candidate we have locked even at zero confirmations.
+ let confirmed_candidate = self.negotiated_candidates.iter().find_map(|candidate| {
+ let confirmations = candidate.funding.get_funding_tx_confirmations(best_block_height);
+ let txid = candidate
+ .funding
+ .get_funding_txid()
+ .expect("negotiated candidates should have a funding txid");
+ // The `splice_locked` we sent always refers to the confirmed candidate, as it is
+ // cleared if that candidate is ever unconfirmed by a reorg.
+ let splice_locked_sent = self.sent_funding_txid == Some(txid);
+ if confirmations == 0 && !splice_locked_sent {
+ return None;
+ }
+ Some(ConfirmedSpliceCandidate {
+ txid,
+ confirmations,
+ confirmations_required: context
+ .minimum_depth(&candidate.funding)
+ .expect("set for a ready channel"),
+ splice_locked_sent,
+ })
+ });
+ SpliceDetails {
+ candidates,
+ confirmed_candidate,
+ received_splice_locked_txid: self.received_funding_txid,
+ }
+ }
+
fn check_get_splice_locked<SP: SignerProvider>(
&mut self, context: &ChannelContext<SP>, confirmed_funding_index: usize, height: u32,
) -> Option<msgs::SpliceLocked> {
@@ -7455,6 +7538,46 @@ where
)
}
+ /// Returns details about any pending splice attempts for inclusion in
+ /// [`crate::ln::channel_state::ChannelDetails`].
+ pub fn pending_splice_details(&self, best_block_height: u32) -> Option<SpliceDetails> {
+ let mut details = self
+ .pending_splice
+ .as_ref()
+ .map(|pending_splice| pending_splice.to_details(&self.context, best_block_height));
+
+ // A contribution committed via `funding_contributed` sits in `quiescent_action` until
+ // quiescence is reached and it begins negotiating; surface it as the last candidate, in a
+ // `WaitingOn*` status describing what it is waiting on.
+ if let Some(contribution) = self.queued_funding_contribution() {
+ // It begins negotiating at the next quiescence if there is no pending candidate or it can
+ // replace one via RBF; otherwise it must wait for the pending candidate to lock.
+ let status = if self.pending_splice.is_none()
+ || self.queued_contribution_can_rbf(contribution)
+ {
+ SpliceCandidateStatus::WaitingOnQuiescence
+ } else {
+ SpliceCandidateStatus::WaitingOnLock
+ };
+ let candidate =
+ SpliceCandidateDetails { contribution: Some(contribution.clone()), status };
+ match &mut details {
+ Some(details) => details.candidates.push(candidate),
+ // No `PendingFunding` yet (a first splice still awaiting quiescence), but the queued
+ // contribution is still worth surfacing.
+ None => {
+ details = Some(SpliceDetails {
+ candidates: vec![candidate],
+ confirmed_candidate: None,
+ received_splice_locked_txid: None,
+ });
+ },
+ }
+ }
+
+ details
+ }
+
fn has_pending_splice_awaiting_signatures(&self) -> bool {
self.pending_splice
.as_ref()
@@ -12869,6 +12992,38 @@ where
Ok(())
}
+ /// Whether a committed-but-not-yet-negotiating contribution can replace the pending candidate
+ /// via RBF, rather than having to wait for that candidate to lock. Used to classify a queued
+ /// contribution's status while it awaits quiescence.
+ fn queued_contribution_can_rbf(&self, contribution: &FundingContribution) -> bool {
+ let pending_splice = match &self.pending_splice {
+ Some(pending_splice) => pending_splice,
+ None => return false,
+ };
+ // A zero-conf channel can never RBF, and a candidate that is already locking can no longer
+ // be replaced.
+ if self.is_rbf_compatible().is_err() {
+ return false;
+ }
+ if pending_splice.sent_funding_txid.is_some()
+ || pending_splice.received_funding_txid.is_some()
+ {
+ return false;
+ }
+ // The replacement must pay a higher feerate than the most recent round: the one currently
+ // under negotiation if any (which is the candidate we would replace once it signs),
+ // otherwise the most recently negotiated candidate. The in-flight feerate is fixed when the
+ // round starts, so affordability is determinable even before it signs.
+ let prev_feerate = match pending_splice.funding_negotiation.as_ref() {
+ Some(funding_negotiation) => funding_negotiation.funding_feerate_sat_per_1000_weight(),
+ None => match pending_splice.last_funding_feerate_sat_per_1000_weight {
+ Some(prev_feerate) => prev_feerate,
+ None => return false,
+ },
+ };
+ contribution.feerate() >= min_rbf_feerate(prev_feerate)
+ }
+
fn can_initiate_rbf(&self) -> Result<FeeRate, String> {
self.is_rbf_compatible()?;
@@ -13075,6 +13230,18 @@ where
contribution
};
+ // A queued splice never coexists with a negotiation we initiated: we return early above if
+ // one is already in flight, and a queued action is cleared the moment it becomes our
+ // negotiation at quiescence. It may coexist with a counterparty-initiated negotiation (e.g.
+ // queuing our own contribution while accepting their splice), so we only rule out our own.
+ debug_assert!(
+ self.pending_splice
+ .as_ref()
+ .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref())
+ .map_or(true, |funding_negotiation| !funding_negotiation.is_initiator()),
+ "A queued splice must not coexist with a funding negotiation we initiated",
+ );
+
self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime })
}
diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs
index 6e5d633..ea99d4c 100644
--- a/lightning/src/ln/channel_state.rs
+++ b/lightning/src/ln/channel_state.rs
@@ -12,10 +12,12 @@
use alloc::vec::Vec;
use bitcoin::secp256k1::PublicKey;
+use bitcoin::Txid;
use crate::chain::chaininterface::{FeeEstimator, LowerBoundedFeeEstimator};
use crate::chain::transaction::OutPoint;
use crate::ln::channel::Channel;
+use crate::ln::funding::FundingContribution;
use crate::ln::types::ChannelId;
use crate::sign::SignerProvider;
use crate::types::features::{ChannelTypeFeatures, InitFeatures};
@@ -275,7 +277,8 @@ impl_ser_tlv_based!(ChannelCounterparty, {
///
/// When a channel is spliced, most fields continue to refer to the original pre-splice channel
/// state until the splice transaction reaches sufficient confirmations to be locked (and we
-/// exchange `splice_locked` messages with our peer). See individual fields for details.
+/// exchange `splice_locked` messages with our peer). See individual fields for details, and
+/// [`SpliceDetails`] for how a splice is negotiated and locked.
///
/// [`ChannelManager::list_channels`]: crate::ln::channelmanager::ChannelManager::list_channels
/// [`ChannelManager::list_usable_channels`]: crate::ln::channelmanager::ChannelManager::list_usable_channels
@@ -494,6 +497,11 @@ pub struct ChannelDetails {
///
/// [`ChannelConfig::max_dust_htlc_exposure`]: crate::util::config::ChannelConfig::max_dust_htlc_exposure
pub current_dust_exposure_msat: Option<u64>,
+ /// Details of any pending splice attempts on this channel, or `None` if no splice is pending.
+ ///
+ /// See [`SpliceDetails`] for what is included. This will be `None` for objects serialized with
+ /// LDK versions prior to 0.3.
+ pub splice_details: Option<SpliceDetails>,
}
impl ChannelDetails {
@@ -619,6 +627,9 @@ impl ChannelDetails {
pending_inbound_htlcs: context.get_pending_inbound_htlc_details(funding),
pending_outbound_htlcs: context.get_pending_outbound_htlc_details(funding),
current_dust_exposure_msat: Some(balance.dust_exposure_msat),
+ splice_details: channel
+ .as_funded()
+ .and_then(|chan| chan.pending_splice_details(best_block_height)),
}
}
}
@@ -661,11 +672,230 @@ impl_ser_tlv_based!(ChannelDetails, {
(45, pending_outbound_htlcs, optional_vec),
(47, funding_redeem_script, option),
(49, current_dust_exposure_msat, option),
+ (51, splice_details, option),
(_unused, user_channel_id, (static_value,
_user_channel_id_low.unwrap_or(0) as u128 | ((_user_channel_id_high.unwrap_or(0) as u128) << 64)
)),
});
+/// Details of pending splice attempts on a channel, as returned in
+/// [`ChannelDetails::splice_details`].
+///
+/// Every splice or RBF round on the channel that has not yet locked is reported as a
+/// [`SpliceCandidateDetails`] in [`candidates`], from the moment a contribution is committed
+/// through negotiation, signing, and confirmation; see [`SpliceCandidateStatus`] for the stages.
+///
+/// A splice is initiated by calling [`ChannelManager::splice_channel`] to obtain a
+/// [`FundingTemplate`], building a [`FundingContribution`] from it, and committing that
+/// contribution with [`ChannelManager::funding_contributed`]. The contribution first appears as a
+/// candidate awaiting quiescence; once the channel is quiescent it is negotiated with the
+/// counterparty, and a completed negotiation produces a signed *candidate* splice transaction.
+/// While a candidate has been negotiated but not yet locked, calling
+/// [`ChannelManager::splice_channel`] again and contributing a higher-feerate replacement RBFs it,
+/// adding another candidate; the candidates all double-spend the same input, so at most one
+/// confirms. A node sends `splice_locked` for a candidate once it has sufficient confirmations
+/// (immediately, on a zero-conf channel), and considers the splice locked once it has both sent its
+/// own `splice_locked` and received the counterparty's, at which point that candidate is promoted
+/// to the channel's funding. The two sides may lock at different times, both because each counts
+/// confirmations from its own chain view and because they may require different numbers of
+/// confirmations.
+///
+/// The counterparty may also initiate a splice or RBF. Such a round is reported here as well, so a
+/// candidate may appear that we did not initiate; our [`contribution`] to it is `None` unless we
+/// added funds of our own.
+///
+/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
+/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
+/// [`FundingTemplate`]: crate::ln::funding::FundingTemplate
+/// [`candidates`]: Self::candidates
+/// [`contribution`]: SpliceCandidateDetails::contribution
+#[derive(Clone, Debug, PartialEq)]
+pub struct SpliceDetails {
+ /// The splice and RBF rounds on this channel that have not yet locked, in order: any negotiated
+ /// candidates awaiting confirmation (oldest first), the round currently under negotiation (if
+ /// any), and a contribution we have committed but not yet begun negotiating (last).
+ ///
+ /// More than one entry indicates an in-flight negotiation and/or RBF replacements alongside
+ /// negotiated candidates; the candidates all double-spend the same input, so at most one
+ /// ultimately confirms.
+ ///
+ /// Note that entries before [`SpliceCandidateStatus::AwaitingSignatures`] do not survive a
+ /// restart, as they reflect in-memory negotiation state.
+ pub candidates: Vec<SpliceCandidateDetails>,
+ /// The negotiated candidate that has confirmed on-chain (or, on a zero-conf channel, that we
+ /// have locked at zero confirmations), if any, along with its confirmation progress.
+ ///
+ /// At most one candidate can confirm, as the candidates all double-spend the same input, so
+ /// this identifies the single confirming candidate rather than tracking confirmations on each.
+ pub confirmed_candidate: Option<ConfirmedSpliceCandidate>,
+ /// The txid announced in the `splice_locked` received from the counterparty, i.e., the
+ /// candidate that they consider to have sufficient confirmations.
+ ///
+ /// Unlike the `splice_locked` we sent (see [`ConfirmedSpliceCandidate::splice_locked_sent`]),
+ /// this need not match [`confirmed_candidate`]: during a reorg, our counterparty may observe a
+ /// different candidate confirm.
+ ///
+ /// [`confirmed_candidate`]: Self::confirmed_candidate
+ pub received_splice_locked_txid: Option<Txid>,
+}
+
+impl_ser_tlv_based!(SpliceDetails, {
+ (1, candidates, required_vec),
+ (3, confirmed_candidate, option),
+ (5, received_splice_locked_txid, option),
+});
+
+/// A single splice or RBF round on a channel, as reported in [`SpliceDetails::candidates`].
+///
+/// The stage this round has reached is given by [`status`]; the details it carries (initiator,
+/// feerate, value, txid) become available as it progresses and are accessed through the
+/// [`SpliceCandidateStatus`] variant rather than as separate optional fields.
+///
+/// [`status`]: Self::status
+#[derive(Clone, Debug, PartialEq)]
+pub struct SpliceCandidateDetails {
+ /// Our contribution to this round, or `None` if we did not contribute (a counterparty-only
+ /// round).
+ ///
+ /// Once a round includes our contribution, every later round does as well: RBF attempts carry
+ /// the contribution forward (possibly adjusted to a new feerate) rather than dropping it,
+ /// preserving the splice intention.
+ ///
+ /// Note that [`FundingContribution::feerate`] is the feerate used when selecting the
+ /// contribution's inputs, which is not necessarily the exact feerate of the negotiated
+ /// transaction.
+ pub contribution: Option<FundingContribution>,
+ /// The stage this round has reached.
+ pub status: SpliceCandidateStatus,
+}
+
+impl_ser_tlv_based!(SpliceCandidateDetails, {
+ (1, contribution, option),
+ (3, status, required),
+});
+
+/// The stage a splice or RBF round has reached, as reported in [`SpliceCandidateDetails::status`].
+///
+/// A round committed via [`ChannelManager::funding_contributed`] begins in one of the `WaitingOn*`
+/// statuses, advances through the negotiation statuses once the channel is quiescent, and finally
+/// reaches [`Negotiated`] once signed.
+///
+/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
+/// [`Negotiated`]: Self::Negotiated
+#[derive(Clone, Debug, PartialEq)]
+pub enum SpliceCandidateStatus {
+ /// We have committed a contribution and are awaiting quiescence before it begins negotiating —
+ /// the first splice on the channel if there are no other candidates, or an RBF replacing an
+ /// existing candidate otherwise. If the counterparty initiates a round first, the contribution
+ /// may instead be included in that round.
+ WaitingOnQuiescence,
+ /// We have committed a contribution but cannot replace the pending candidate via RBF (our
+ /// contribution's feerate is too low, the channel is zero-conf, or a candidate is already
+ /// locking). It will be spliced once the pending candidate locks or, when only the feerate
+ /// prevents the RBF, sooner if the counterparty initiates an RBF that the contribution can
+ /// be included in.
+ WaitingOnLock,
+ /// We have proposed this round to the counterparty and are awaiting their acknowledgement.
+ AwaitingAck {
+ /// Whether we are the initiator of this round. When both sides want to splice, a tie-break at
+ /// quiescence decides which is the initiator and which is the acceptor. The initiator pays the
+ /// fees for the transaction's common fields and for the shared input and output (the previous
+ /// and new channel funding).
+ is_initiator: bool,
+ /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000
+ /// weight units.
+ funding_feerate_sat_per_1000_weight: u32,
+ },
+ /// The splice transaction is being interactively constructed.
+ ConstructingTransaction {
+ /// Whether we are the initiator of this round. When both sides want to splice, a tie-break at
+ /// quiescence decides which is the initiator and which is the acceptor. The initiator pays the
+ /// fees for the transaction's common fields and for the shared input and output (the previous
+ /// and new channel funding).
+ is_initiator: bool,
+ /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000
+ /// weight units.
+ funding_feerate_sat_per_1000_weight: u32,
+ /// The value, in satoshis, of the channel once this round confirms and is promoted.
+ new_channel_value_satoshis: u64,
+ },
+ /// The splice transaction has been negotiated and is awaiting signatures from both
+ /// counterparties.
+ AwaitingSignatures {
+ /// Whether we are the initiator of this round. When both sides want to splice, a tie-break at
+ /// quiescence decides which is the initiator and which is the acceptor. The initiator pays the
+ /// fees for the transaction's common fields and for the shared input and output (the previous
+ /// and new channel funding).
+ is_initiator: bool,
+ /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000
+ /// weight units.
+ funding_feerate_sat_per_1000_weight: u32,
+ /// The value, in satoshis, of the channel once this round confirms and is promoted.
+ new_channel_value_satoshis: u64,
+ /// The txid of the splice transaction.
+ txid: Txid,
+ },
+ /// The splice transaction has been signed and is awaiting sufficient on-chain confirmations for
+ /// both counterparties to exchange `splice_locked`.
+ Negotiated {
+ /// The txid of the splice transaction.
+ txid: Txid,
+ /// The value, in satoshis, of the channel once this candidate confirms and is promoted.
+ new_channel_value_satoshis: u64,
+ },
+}
+
+impl_ser_tlv_based_enum!(SpliceCandidateStatus,
+ (1, WaitingOnQuiescence) => {},
+ (3, WaitingOnLock) => {},
+ (5, AwaitingAck) => {
+ (1, is_initiator, required),
+ (3, funding_feerate_sat_per_1000_weight, required),
+ },
+ (7, ConstructingTransaction) => {
+ (1, is_initiator, required),
+ (3, funding_feerate_sat_per_1000_weight, required),
+ (5, new_channel_value_satoshis, required),
+ },
+ (9, AwaitingSignatures) => {
+ (1, is_initiator, required),
+ (3, funding_feerate_sat_per_1000_weight, required),
+ (5, new_channel_value_satoshis, required),
+ (7, txid, required),
+ },
+ (11, Negotiated) => {
+ (1, txid, required),
+ (3, new_channel_value_satoshis, required),
+ },
+);
+
+/// The confirmation progress of the negotiated splice candidate that has confirmed on-chain, as
+/// exposed in [`SpliceDetails::confirmed_candidate`].
+///
+/// At most one candidate can confirm, as the candidates all double-spend the same input, so this
+/// identifies the single confirming candidate by its txid.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ConfirmedSpliceCandidate {
+ /// The txid of the candidate that has confirmed on-chain. This matches the txid of the
+ /// [`SpliceCandidateStatus::Negotiated`] entry in [`SpliceDetails::candidates`] that confirmed.
+ pub txid: Txid,
+ /// The current number of confirmations of the candidate's transaction.
+ pub confirmations: u32,
+ /// The number of confirmations required before `splice_locked` can be sent for the candidate.
+ pub confirmations_required: u32,
+ /// Whether we have sent `splice_locked` for this candidate, i.e., we consider it to have
+ /// sufficient confirmations. The `splice_locked` we sent always refers to this confirmed
+ /// candidate, so it is tracked here rather than as a separate txid.
+ pub splice_locked_sent: bool,
+}
+
+impl_ser_tlv_based!(ConfirmedSpliceCandidate, {
+ (1, txid, required),
+ (3, confirmations, required),
+ (5, confirmations_required, required),
+ (7, splice_locked_sent, required),
+});
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// Further information on the details of the channel shutdown.
/// Upon channels being forced closed (i.e. commitment transaction confirmation detected
@@ -718,7 +948,10 @@ mod tests {
},
};
- use super::{ChannelCounterparty, ChannelDetails, ChannelShutdownState};
+ use super::{
+ ChannelCounterparty, ChannelDetails, ChannelShutdownState, ConfirmedSpliceCandidate,
+ SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails,
+ };
#[test]
fn test_channel_details_serialization() {
@@ -783,6 +1016,32 @@ mod tests {
is_dust: false,
}],
current_dust_exposure_msat: Some(150_000),
+ splice_details: Some(SpliceDetails {
+ // A reachable arrangement: a negotiated candidate we have confirmed and sent
+ // `splice_locked` for, followed by a committed contribution that cannot yet be spliced
+ // (that candidate is locking) and so waits. There is at most one in-flight round and at
+ // most one `WaitingOn*` entry, which is always last.
+ candidates: vec![
+ SpliceCandidateDetails {
+ contribution: None,
+ status: SpliceCandidateStatus::Negotiated {
+ txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(),
+ new_channel_value_satoshis: 60_000,
+ },
+ },
+ SpliceCandidateDetails {
+ contribution: None,
+ status: SpliceCandidateStatus::WaitingOnLock,
+ },
+ ],
+ confirmed_candidate: Some(ConfirmedSpliceCandidate {
+ txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(),
+ confirmations: 6,
+ confirmations_required: 6,
+ splice_locked_sent: true,
+ }),
+ received_splice_locked_txid: None,
+ }),
};
let mut buffer = Vec::new();
channel_details.write(&mut buffer).unwrap();
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 2bf7703..04fe241 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -22,6 +22,7 @@ use crate::ln::channel::{
DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE,
MIN_CHANNEL_VALUE_SATOSHIS,
};
+use crate::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus, SpliceDetails};
use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT};
use crate::ln::functional_test_utils::*;
use crate::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate};
@@ -560,6 +561,7 @@ pub struct SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> {
is_0conf: bool,
acceptor_has_contribution: bool,
expected_replaced_txid: Option<Txid>,
+ unconfirmed_funding_txid: Option<Txid>,
}
impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> {
@@ -570,6 +572,7 @@ impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> {
is_0conf: false,
acceptor_has_contribution: false,
expected_replaced_txid: None,
+ unconfirmed_funding_txid: None,
}
}
@@ -592,6 +595,14 @@ impl<'a, 'b, 'c, 'd> SignInteractiveFundingTxArgs<'a, 'b, 'c, 'd> {
self.expected_replaced_txid = Some(prior_txid);
self
}
+
+ /// The channel's funding transaction, identified by `unconfirmed_funding_txid`, is still
+ /// unconfirmed, so signing also (re-)broadcasts it; the helper asserts it is broadcast alongside
+ /// the splice.
+ pub fn with_unconfirmed_funding(mut self, unconfirmed_funding_txid: Txid) -> Self {
+ self.unconfirmed_funding_txid = Some(unconfirmed_funding_txid);
+ self
+ }
}
pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>(
@@ -603,6 +614,7 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>(
is_0conf,
acceptor_has_contribution,
expected_replaced_txid,
+ unconfirmed_funding_txid,
} = args;
let node_id_initiator = initiator.node.get_our_node_id();
let node_id_acceptor = acceptor.node.get_our_node_id();
@@ -695,6 +707,19 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>(
let tx = {
let mut initiator_txn = initiator.tx_broadcaster.txn_broadcast_with_types();
+ if let Some(unconfirmed_funding_txid) = unconfirmed_funding_txid {
+ // The initiator (re-)broadcasts its still-unconfirmed funding alongside the splice;
+ // remove it so only the splice (InteractiveFunding) remains to compare against the acceptor.
+ assert_eq!(initiator_txn.len(), 2);
+ let pos = initiator_txn
+ .iter()
+ .position(|(tx, tx_type)| {
+ tx.compute_txid() == unconfirmed_funding_txid
+ && matches!(tx_type, TransactionType::Funding { .. })
+ })
+ .expect("the unconfirmed funding should be (re-)broadcast");
+ initiator_txn.remove(pos);
+ }
assert_eq!(initiator_txn.len(), 1);
let mut acceptor_txn = acceptor.tx_broadcaster.txn_broadcast_with_types();
assert_eq!(acceptor_txn.len(), 1);
@@ -1228,6 +1253,21 @@ fn test_reload_resets_splice_negotiation_without_dropping_candidates() {
);
let _ = get_event!(&nodes[0], Event::SpliceNegotiationFailed);
+ // The reload dropped the in-flight RBF round (a `ConstructingTransaction` state does not persist),
+ // but the previously negotiated candidate survives as the sole candidate, with its contribution.
+ let details = nodes[0]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. }));
+ assert_eq!(details.candidates[0].contribution, Some(funding_contribution.clone()));
+
let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
assert_eq!(funding_template.min_rbf_feerate(), Some(rbf_feerate));
assert_eq!(funding_template.prior_contribution().unwrap(), &funding_contribution);
@@ -2493,6 +2533,25 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) {
);
// We should have another signing event generated upon reload as they're not persisted.
let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+
+ // The negotiation is awaiting signatures, so it has no negotiated candidate yet, only our
+ // in-flight contribution. That contribution (written under its own TLV) survives the reload.
+ let details = nodes[0]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert!(matches!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::AwaitingSignatures { .. }
+ ));
+ assert!(details.candidates[0].contribution.is_some());
+
if async_monitor_update {
persister_0a.set_update_ret(ChannelMonitorUpdateStatus::InProgress);
persister_1a.set_update_ret(ChannelMonitorUpdateStatus::InProgress);
@@ -4036,6 +4095,27 @@ fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() {
.unwrap();
assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty());
+ // The acceptor is mid-negotiation on the counterparty's splice and has its own contribution
+ // queued behind it; both surface at once.
+ let details = acceptor
+ .node
+ .list_channels()
+ .into_iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ // The counterparty's in-flight round, which we did not contribute to.
+ assert!(matches!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. }
+ ));
+ assert_eq!(details.candidates[0].contribution, None);
+ // Our own contribution, queued to RBF the counterparty's round once it completes.
+ assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnQuiescence);
+ assert_eq!(details.candidates[1].contribution, Some(queued_contribution.clone()));
+
acceptor.node.cancel_funding_contributed(&channel_id, &node_id_initiator).unwrap();
let reason = NegotiationFailureReason::LocallyCanceled;
expect_splice_failed_events(acceptor, &channel_id, queued_contribution, reason);
@@ -10459,3 +10539,1064 @@ fn test_async_splice_receives_tx_signatures_while_unrelated_monitor_update_pendi
);
expect_payment_sent(initiator, payment_preimage, None, true, true);
}
+
+/// Returns the txid carried by a candidate's status, panicking for statuses that have none.
+#[cfg(test)]
+fn candidate_txid(candidate: &SpliceCandidateDetails) -> Txid {
+ match candidate.status {
+ SpliceCandidateStatus::AwaitingSignatures { txid, .. }
+ | SpliceCandidateStatus::Negotiated { txid, .. } => txid,
+ ref other => panic!("candidate status carries no txid: {other:?}"),
+ }
+}
+
+/// Returns the new channel value carried by a candidate's status, panicking for statuses that have
+/// none.
+#[cfg(test)]
+fn candidate_value(candidate: &SpliceCandidateDetails) -> u64 {
+ match candidate.status {
+ SpliceCandidateStatus::ConstructingTransaction { new_channel_value_satoshis, .. }
+ | SpliceCandidateStatus::AwaitingSignatures { new_channel_value_satoshis, .. }
+ | SpliceCandidateStatus::Negotiated { new_channel_value_satoshis, .. } => {
+ new_channel_value_satoshis
+ },
+ ref other => panic!("candidate status carries no value: {other:?}"),
+ }
+}
+
+#[test]
+fn test_channel_details_pending_splice() {
+ // Test that `ChannelDetails::splice_details` reflects pending splice state throughout
+ // negotiation, signing, RBF, restarts, and locking.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let (persister_0, persister_1);
+ let (chain_monitor_0, chain_monitor_1);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let (node_0, node_1);
+ let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let splice_details = |node: &Node<'_, '_, '_>| {
+ node.node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ };
+
+ // No splice is pending yet.
+ assert_eq!(splice_details(&nodes[0]), None);
+ assert_eq!(splice_details(&nodes[1]), None);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Contributing funds queues the contribution but does not start the negotiation; that begins
+ // once the channel becomes quiescent and splice_init is sent. Until then it surfaces as a single
+ // candidate awaiting quiescence, carrying our contribution.
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ assert_eq!(
+ splice_details(&nodes[0]),
+ Some(SpliceDetails {
+ candidates: vec![SpliceCandidateDetails {
+ contribution: Some(contribution.clone()),
+ status: SpliceCandidateStatus::WaitingOnQuiescence,
+ }],
+ confirmed_candidate: None,
+ received_splice_locked_txid: None,
+ }),
+ );
+ assert_eq!(splice_details(&nodes[1]), None);
+
+ let new_channel_value_sat =
+ (initial_channel_value_sat as i64 + contribution.net_value().to_sat()) as u64;
+
+ let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_init);
+ let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
+
+ // Once quiescent, the initiator sends splice_init and awaits the counterparty's splice_ack. The
+ // new channel value and txid are not yet known, so the AwaitingAck status carries neither.
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert_eq!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::AwaitingAck {
+ is_initiator: true,
+ funding_feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW,
+ },
+ );
+ assert_eq!(details.candidates[0].contribution, Some(contribution.clone()));
+ assert_eq!(splice_details(&nodes[1]), None);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+
+ // The acceptor starts constructing the transaction upon receiving splice_init, at which
+ // point both contributions are known.
+ let details = splice_details(&nodes[1]).unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert_eq!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::ConstructingTransaction {
+ is_initiator: false,
+ funding_feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW,
+ new_channel_value_satoshis: new_channel_value_sat,
+ },
+ );
+ assert_eq!(details.candidates[0].contribution, None);
+
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert_eq!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::ConstructingTransaction {
+ is_initiator: true,
+ funding_feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW,
+ new_channel_value_satoshis: new_channel_value_sat,
+ },
+ );
+
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution.clone(),
+ new_funding_script.clone(),
+ );
+
+ // Once construction completes, the negotiation awaits signatures and the txid is known.
+ let details_0 = splice_details(&nodes[0]).unwrap();
+ let details_1 = splice_details(&nodes[1]).unwrap();
+ assert_eq!(details_0.candidates.len(), 1);
+ assert_eq!(details_1.candidates.len(), 1);
+ assert!(matches!(
+ details_0.candidates[0].status,
+ SpliceCandidateStatus::AwaitingSignatures { is_initiator: true, .. }
+ ));
+ assert!(matches!(
+ details_1.candidates[0].status,
+ SpliceCandidateStatus::AwaitingSignatures { is_initiator: false, .. }
+ ));
+ assert_eq!(candidate_txid(&details_0.candidates[0]), candidate_txid(&details_1.candidates[0]));
+ assert_eq!(candidate_value(&details_0.candidates[0]), new_channel_value_sat);
+ assert_eq!(details_0.candidates[0].contribution, Some(contribution.clone()));
+ assert_eq!(details_1.candidates[0].contribution, None);
+
+ let (splice_tx, splice_locked) =
+ sign_interactive_funding_tx(SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]));
+ assert!(splice_locked.is_none());
+ assert_eq!(candidate_txid(&details_0.candidates[0]), splice_tx.compute_txid());
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ // The acceptor did not contribute, so it gets no `SpliceNegotiated` event.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+
+ // With signatures exchanged, the negotiated splice is a candidate awaiting confirmations.
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. }));
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(candidate_value(&details.candidates[0]), new_channel_value_sat);
+ assert_eq!(details.candidates[0].contribution, Some(contribution.clone()));
+ assert_eq!(details.confirmed_candidate, None);
+ assert_eq!(details.received_splice_locked_txid, None);
+
+ // The acceptor did not contribute to the splice.
+ let details = splice_details(&nodes[1]).unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. }));
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(details.candidates[0].contribution, None);
+
+ // Initiate an RBF attempt at a higher feerate.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25;
+ let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate);
+
+ // The RBF contribution is queued behind the still-pending original candidate until quiescence
+ // is re-reached; until then it surfaces as a second candidate awaiting negotiation, alongside the
+ // original candidate.
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnQuiescence);
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone()));
+
+ // Reaching quiescence turns the queued RBF contribution into a negotiation. The initiator sends
+ // tx_init_rbf and awaits tx_ack_rbf, so the RBF round is reported as AwaitingAck alongside the
+ // still-pending original candidate.
+ let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_init);
+ let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
+
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingAck {
+ is_initiator: true,
+ funding_feerate_sat_per_1000_weight: rbf_feerate_sat_per_kwu as u32,
+ },
+ );
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone()));
+
+ let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+ let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0);
+ nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf);
+
+ // The RBF negotiation then moves to constructing the transaction, still alongside the original
+ // candidate.
+ let rbf_channel_value_sat =
+ (initial_channel_value_sat as i64 + rbf_contribution.net_value().to_sat()) as u64;
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(details.candidates[0].contribution, Some(contribution.clone()));
+ assert_eq!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::ConstructingTransaction {
+ is_initiator: true,
+ funding_feerate_sat_per_1000_weight: rbf_feerate_sat_per_kwu as u32,
+ new_channel_value_satoshis: rbf_channel_value_sat,
+ },
+ );
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone()));
+
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution.clone(),
+ new_funding_script,
+ );
+ let (rbf_tx, splice_locked) = sign_interactive_funding_tx(
+ SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).replacing(splice_tx.compute_txid()),
+ );
+ assert!(splice_locked.is_none());
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ // The acceptor did not contribute, so it gets no `SpliceNegotiated` event.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+
+ // Both the original splice and its RBF replacement are candidates, in negotiation order.
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(details
+ .candidates
+ .iter()
+ .all(|c| matches!(c.status, SpliceCandidateStatus::Negotiated { .. })));
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(details.candidates[0].contribution, Some(contribution.clone()));
+ assert_eq!(candidate_txid(&details.candidates[1]), rbf_tx.compute_txid());
+ assert_eq!(candidate_value(&details.candidates[1]), rbf_channel_value_sat);
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone()));
+
+ let details = splice_details(&nodes[1]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(details.candidates[1].contribution, None);
+
+ // Pending splice state, including per-candidate contributions, survives a restart.
+ let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode();
+ reload_node!(
+ nodes[0],
+ &nodes[0].node.encode(),
+ &[&encoded_monitor_0],
+ persister_0,
+ chain_monitor_0,
+ node_0
+ );
+ let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode();
+ reload_node!(
+ nodes[1],
+ &nodes[1].node.encode(),
+ &[&encoded_monitor_1],
+ persister_1,
+ chain_monitor_1,
+ node_1
+ );
+
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(details.candidates[0].contribution, Some(contribution));
+ assert_eq!(candidate_txid(&details.candidates[1]), rbf_tx.compute_txid());
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution));
+
+ let details = splice_details(&nodes[1]).unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(details.candidates.iter().all(|candidate| candidate.contribution.is_none()));
+
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_nodes(reconnect_args);
+
+ // Mine the RBF transaction; only its candidate confirms, identified by its index.
+ mine_transaction(&nodes[0], &rbf_tx);
+ mine_transaction(&nodes[1], &rbf_tx);
+
+ let details = splice_details(&nodes[0]).unwrap();
+ let confirmed = details.confirmed_candidate.unwrap();
+ assert_eq!(confirmed.txid, rbf_tx.compute_txid());
+ assert_eq!(confirmed.confirmations, 1);
+ assert_eq!(confirmed.confirmations_required, 6);
+ // Not yet at the required depth, so we have not sent `splice_locked` for it.
+ assert!(!confirmed.splice_locked_sent);
+
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
+
+ // Once sufficiently confirmed, the splice_locked we sent is reflected in the details until
+ // the counterparty's splice_locked is received and the splice is promoted.
+ let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.received_splice_locked_txid, None);
+ let confirmed = details.confirmed_candidate.unwrap();
+ assert_eq!(confirmed.txid, rbf_tx.compute_txid());
+ assert!(confirmed.splice_locked_sent);
+ assert_eq!(confirmed.confirmations, ANTI_REORG_DELAY);
+
+ lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[splice_tx.compute_txid()]);
+
+ // The splice is no longer pending once promoted.
+ assert_eq!(splice_details(&nodes[0]), None);
+ assert_eq!(splice_details(&nodes[1]), None);
+}
+
+#[test]
+fn test_channel_details_first_contribution_on_rbf() {
+ // When the counterparty's splice did not include a contribution from us and our first
+ // contribution comes in an RBF round we initiate, the in-flight contribution must not be
+ // attributed to the negotiated counterparty-only candidate.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Splice initiated by node 1; node 0 does not contribute.
+ let contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
+ let (splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, contribution);
+
+ // Node 0 initiates an RBF, contributing for the first time.
+ let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let rbf_contribution = funding_template
+ .without_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(added_value)
+ .unwrap()
+ .build()
+ .unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, rbf_contribution.clone(), None)
+ .unwrap();
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendTxAddInput, node_id_1);
+
+ // While the RBF is being negotiated, node 0's contribution belongs to the negotiation, not
+ // to the negotiated counterparty-only candidate.
+ let channels = nodes[0].node.list_channels();
+ let details = channels[0].splice_details.as_ref().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert_eq!(details.candidates[0].contribution, None);
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::ConstructingTransaction { is_initiator: true, .. }
+ ));
+ assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone()));
+
+ // Node 1 adjusted its prior contribution for the RBF round; the negotiated candidate keeps
+ // its original contribution. Node 1 did not initiate this round, so `is_initiator` is
+ // `Some(false)` even though it carries a contribution into it.
+ let channels = nodes[1].node.list_channels();
+ let details = channels[0].splice_details.as_ref().unwrap();
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. }
+ ));
+ assert!(details.candidates[1].contribution.is_some());
+ assert!(details.candidates[0].contribution.is_some());
+
+ // Abort the negotiation via disconnect.
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+
+ expect_splice_failed_events(
+ &nodes[0],
+ &channel_id,
+ rbf_contribution,
+ NegotiationFailureReason::PeerDisconnected,
+ );
+ // Node 1's contribution to the RBF round (the prior round's contribution adjusted to the new
+ // feerate) has no inputs or outputs unique from the prior round, so nothing is discarded, but
+ // it still gets a `SpliceNegotiationFailed` so the wallet can resume funding.
+ let _ = get_event!(&nodes[1], Event::SpliceNegotiationFailed);
+
+ // After the reset, the contribution alignment is restored on both nodes.
+ let channels = nodes[0].node.list_channels();
+ let details = channels[0].splice_details.as_ref().unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert_eq!(details.candidates[0].contribution, None);
+ let channels = nodes[1].node.list_channels();
+ let details = channels[0].splice_details.as_ref().unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ assert!(details.candidates[0].contribution.is_some());
+}
+
+#[test]
+fn test_channel_details_zero_conf_splice() {
+ // On a zero-conf channel the splice is locked (we send `splice_locked`) before it has any
+ // confirmations, so `ChannelDetails::splice_details` must still report the locked candidate as
+ // the confirmed candidate at zero confirmations. Once both sides exchange `splice_locked` the
+ // splice is promoted to the channel funding and is no longer reported as pending.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let mut config = test_default_channel_config();
+ config.channel_handshake_limits.trust_own_funding_0conf = true;
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ // Leave the original funding unconfirmed -- a zero-conf channel is usable without it -- so the
+ // test stays focused on the zero-conf splice.
+ let (funding_tx, channel_id) =
+ open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution,
+ new_funding_script,
+ );
+
+ // Sign the splice. The original funding is still unconfirmed, so signing also (re-)broadcasts it
+ // alongside the splice; the helper asserts that and returns the splice transaction. We leave node 0
+ // without the counterparty's `splice_locked`, so the splice stays pending on node 0.
+ let (splice_tx, splice_locked) = sign_interactive_funding_tx(
+ SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])
+ .zero_conf()
+ .with_unconfirmed_funding(funding_tx.compute_txid()),
+ );
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ // The acceptor did not contribute, so it gets no `SpliceNegotiated` event.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+
+ // Node 0 has sent `splice_locked` but has not yet received the counterparty's, so the splice is
+ // still pending. The candidate we locked is reported as the confirmed candidate even though it
+ // has zero confirmations.
+ let details = nodes[0]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ let confirmed =
+ details.confirmed_candidate.expect("the locked zero-conf candidate should be reported");
+ assert_eq!(confirmed.txid, splice_tx.compute_txid());
+ assert_eq!(confirmed.confirmations, 0);
+ assert_eq!(confirmed.confirmations_required, 0);
+ assert!(confirmed.splice_locked_sent);
+ assert_eq!(details.received_splice_locked_txid, None);
+
+ // Exchange both sides' `splice_locked` to lock the splice in. Node 0 sent its at signing (above);
+ // `lock_splice` delivers it to node 1 and brings node 1's back, promoting the splice to the
+ // channel funding on both sides.
+ let (splice_locked_for_node_1, _) =
+ splice_locked.expect("a zero-conf splice sends splice_locked at signing");
+ lock_splice(&nodes[0], &nodes[1], &splice_locked_for_node_1, true, &[]);
+
+ // With the splice promoted, it is no longer reported as a pending splice.
+ let splice_details = |node: &Node<'_, '_, '_>| {
+ node.node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ };
+ assert_eq!(splice_details(&nodes[0]), None);
+ assert_eq!(splice_details(&nodes[1]), None);
+}
+
+#[test]
+fn test_channel_details_waiting_on_lock_zero_conf() {
+ // On a zero-conf channel a committed contribution can never RBF the pending candidate (RBF is
+ // incompatible with zero-conf), so it is reported as `WaitingOnLock` — waiting for the candidate
+ // to lock before it can be spliced.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let mut config = test_default_channel_config();
+ config.channel_handshake_limits.trust_own_funding_0conf = true;
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (funding_tx, channel_id) =
+ open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Complete a first splice; on a zero-conf channel node 0 sends `splice_locked` at signing, but the
+ // splice stays pending until the counterparty's `splice_locked` arrives.
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution,
+ new_funding_script,
+ );
+ let _ = sign_interactive_funding_tx(
+ SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])
+ .zero_conf()
+ .with_unconfirmed_funding(funding_tx.compute_txid()),
+ );
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ // The acceptor did not contribute, so it gets no `SpliceNegotiated` event.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ nodes[0].node.get_and_clear_pending_msg_events();
+
+ // Commit a further contribution; it cannot RBF the pending candidate, so no `stfu` is sent and it
+ // is reported as awaiting the lock.
+ let queued = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ let details = nodes[0]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnLock);
+ assert_eq!(details.candidates[1].contribution, Some(queued));
+
+ // This test does not lock the splice in; drain the un-exchanged `splice_locked` messages so the
+ // nodes tear down cleanly.
+ nodes[0].node.get_and_clear_pending_msg_events();
+ nodes[1].node.get_and_clear_pending_msg_events();
+}
+
+#[test]
+fn test_channel_details_received_splice_locked() {
+ // `received_splice_locked_txid` reports the candidate the counterparty considers locked. Confirm
+ // the splice on only one node so it sends `splice_locked` while the other has not confirmed: the
+ // recipient records the received txid while the splice is still pending and unconfirmed for it.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ // Confirm the splice on node 0 only, so it sends `splice_locked` while node 1 has not confirmed.
+ mine_transaction(&nodes[0], &splice_tx);
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+
+ nodes[1].node.handle_splice_locked(node_id_0, &splice_locked);
+
+ // Node 1 records the counterparty's locked candidate, but has not confirmed it itself, so it has
+ // no confirmed candidate of its own and the splice remains pending.
+ let details = nodes[1]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ assert_eq!(details.received_splice_locked_txid, Some(splice_tx.compute_txid()));
+ assert_eq!(details.confirmed_candidate, None);
+ assert_eq!(details.candidates.len(), 1);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+
+ // Committing a further contribution while the candidate is locking (we received its
+ // `splice_locked`) cannot RBF that candidate, so the queued contribution waits for the lock. This
+ // holds even though its feerate would satisfy the RBF minimum: the locking check takes priority.
+ nodes[1].node.get_and_clear_pending_msg_events();
+ let queued = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, Amount::from_sat(25_000));
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ let details = nodes[1]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnLock);
+ assert_eq!(details.candidates[1].contribution, Some(queued));
+}
+
+#[test]
+fn test_channel_details_splice_reorg_clears_confirmed_candidate() {
+ // A confirmed splice candidate we have locked is reported as the confirmed candidate; a reorg
+ // that unconfirms it clears the confirmed candidate, including the splice_locked we sent.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ let splice_details = |node: &Node<'_, '_, '_>| {
+ node.node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ };
+
+ // Confirm the splice on node 0 so it sends splice_locked and reports the confirmed candidate.
+ mine_transaction(&nodes[0], &splice_tx);
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+
+ let confirmed = splice_details(&nodes[0]).unwrap().confirmed_candidate.unwrap();
+ assert_eq!(confirmed.txid, splice_tx.compute_txid());
+ assert!(confirmed.splice_locked_sent);
+
+ // Reorg out the blocks that confirmed the splice. The confirmed candidate is cleared, along with
+ // the splice_locked we sent for it; the candidate itself remains pending.
+ disconnect_blocks(&nodes[0], ANTI_REORG_DELAY);
+
+ let details = splice_details(&nodes[0]).unwrap();
+ assert_eq!(details.confirmed_candidate, None);
+ assert_eq!(details.candidates.len(), 1);
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+}
+
+#[test]
+fn test_channel_details_received_splice_locked_diverges_from_confirmed() {
+ // `confirmed_candidate` and `received_splice_locked_txid` can name different candidates: across a
+ // reorg the two sides may each see a different RBF candidate confirm. Here node 0 confirms (and
+ // locks) the RBF candidate while node 1 confirms (and locks) the original, so node 0 ends up with
+ // a `received_splice_locked_txid` that differs from its own `confirmed_candidate`.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (original_tx, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ // RBF the splice, producing a second candidate that double-spends the original.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate);
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution,
+ new_funding_script,
+ );
+ let (rbf_tx, _) = sign_interactive_funding_tx(
+ SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1])
+ .replacing(original_tx.compute_txid()),
+ );
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ // The acceptor did not contribute, so it gets no `SpliceNegotiated` event.
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+
+ let splice_details = |node: &Node<'_, '_, '_>| {
+ node.node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ };
+
+ // Node 0's chain confirms the RBF candidate, so it sends `splice_locked` for it.
+ mine_transaction(&nodes[0], &rbf_tx);
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ let _ = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+
+ // Node 1's chain instead confirms the original candidate, so it sends `splice_locked` for that.
+ mine_transaction(&nodes[1], &original_tx);
+ connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
+ let splice_locked_from_1 =
+ get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_0);
+
+ // Node 0 records the counterparty's locked candidate (the original), which differs from the RBF
+ // candidate node 0 itself confirmed. The splice is not promoted, as the two sides disagree.
+ nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_from_1);
+
+ let details = splice_details(&nodes[0]).unwrap();
+ let confirmed = details.confirmed_candidate.unwrap();
+ assert_eq!(confirmed.txid, rbf_tx.compute_txid());
+ assert!(confirmed.splice_locked_sent);
+ assert_eq!(details.received_splice_locked_txid, Some(original_tx.compute_txid()));
+ assert_ne!(Some(confirmed.txid), details.received_splice_locked_txid);
+}
+
+#[test]
+fn test_channel_details_acceptor_contribution_with_queued_rbf() {
+ // An acceptor that contributes to the counterparty's round (its committed contribution merging
+ // into that round via the quiescence tie-break) can also queue a further contribution for a
+ // future RBF. Both surface together as candidates: the in-flight counterparty round carries our
+ // part of it, alongside a separate candidate for the contribution we queued for the next round.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000));
+
+ // Both nodes commit a contribution and propose a splice. The tie-break makes node 0 (the funder)
+ // the initiator; node 1 becomes the acceptor and its contribution merges into node 0's round.
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let contribution_0 = nodes[0]
+ .node
+ .splice_channel(&channel_id, &node_id_1)
+ .unwrap()
+ .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0)
+ .unwrap();
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution_0, None).unwrap();
+
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let contribution_1 = nodes[1]
+ .node
+ .splice_channel(&channel_id, &node_id_0)
+ .unwrap()
+ .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1)
+ .unwrap();
+ nodes[1].node.funding_contributed(&channel_id, &node_id_0, contribution_1, None).unwrap();
+
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ assert_ne!(
+ splice_ack.funding_contribution_satoshis, 0,
+ "the acceptor should contribute to the counterparty's round",
+ );
+
+ // Node 1 queues a further contribution for a future RBF while node 0's round is still in flight.
+ let rbf_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap();
+ let rbf_feerate = rbf_template.min_rbf_feerate().unwrap();
+ let queued = rbf_template
+ .splice_in_sync(Amount::from_sat(25_000), rbf_feerate, FeeRate::MAX, &wallet_1)
+ .unwrap();
+ nodes[1].node.funding_contributed(&channel_id, &node_id_0, queued.clone(), None).unwrap();
+
+ // Node 1's view: it contributed to node 0's (counterparty) round AND has its own RBF queued.
+ let details = nodes[1]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ // Our part of node 0's in-flight round, which we did not initiate.
+ assert!(matches!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. }
+ ));
+ assert!(details.candidates[0].contribution.is_some());
+ // Our further contribution, queued to RBF that round once it completes.
+ assert_eq!(details.candidates[1].status, SpliceCandidateStatus::WaitingOnQuiescence);
+ assert_eq!(details.candidates[1].contribution, Some(queued));
+}
+
+#[test]
+fn test_channel_details_acceptor_contribution_reaches_signing() {
+ // An acceptor that contributes to a counterparty-initiated round is reported with
+ // `is_initiator: false` and its own contribution present, through the awaiting-signatures stage
+ // and into the negotiated candidate.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000));
+
+ // Both nodes commit a contribution at the same feerate; node 0 (the funder) wins the tie-break
+ // and initiates, node 1 becomes the acceptor and its contribution merges into node 0's round.
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let contribution_0 = nodes[0]
+ .node
+ .splice_channel(&channel_id, &node_id_1)
+ .unwrap()
+ .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0)
+ .unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, contribution_0.clone(), None)
+ .unwrap();
+
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let contribution_1 = nodes[1]
+ .node
+ .splice_channel(&channel_id, &node_id_0)
+ .unwrap()
+ .splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1)
+ .unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_id_0, contribution_1.clone(), None)
+ .unwrap();
+
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ assert_ne!(
+ splice_ack.funding_contribution_satoshis, 0,
+ "the acceptor should contribute to the counterparty's round",
+ );
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution_0,
+ Some(contribution_1),
+ splice_ack.funding_contribution_satoshis,
+ new_funding_script,
+ );
+
+ let splice_details = |node: &Node<'_, '_, '_>| {
+ node.node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap()
+ };
+
+ // The acceptor's in-flight round awaits signatures, carrying its own (adjusted) contribution.
+ let details = splice_details(&nodes[1]);
+ assert_eq!(details.candidates.len(), 1);
+ assert!(matches!(
+ details.candidates[0].status,
+ SpliceCandidateStatus::AwaitingSignatures { is_initiator: false, .. }
+ ));
+ assert!(details.candidates[0].contribution.is_some());
+
+ let (_splice_tx, splice_locked) = sign_interactive_funding_tx(
+ SignInteractiveFundingTxArgs::new(&nodes[0], &nodes[1]).with_acceptor_contribution(),
+ );
+ assert!(splice_locked.is_none());
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ // Once signed, the acceptor's negotiated candidate still carries its contribution.
+ let details = splice_details(&nodes[1]);
+ assert_eq!(details.candidates.len(), 1);
+ assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. }));
+ assert!(details.candidates[0].contribution.is_some());
+}
+
+#[test]
+fn test_channel_details_waiting_on_lock_below_rbf_feerate() {
+ // A committed contribution whose feerate is below the RBF minimum of the round currently in
+ // flight cannot replace it, so it is reported as `WaitingOnLock`. This exercises the feerate
+ // branch of the classification (the zero-conf and locking checks do not apply here) and produces
+ // the full negotiated -> in-flight -> queued three-candidate ordering.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+
+ // Complete a first splice at the floor feerate, leaving a negotiated candidate.
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ // The counterparty (node 1) initiates an RBF at a much higher feerate; we drive it in flight on
+ // node 0 (node 1 wins quiescence, as node 0 has nothing of its own queued yet).
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+ let high_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 * 4);
+ // Node 1 did not contribute to the original splice, so it RBFs with a first contribution.
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let rbf_contribution = nodes[1]
+ .node
+ .splice_channel(&channel_id, &node_id_0)
+ .unwrap()
+ .without_prior_contribution(high_feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet_1)
+ .add_value(added_value)
+ .unwrap()
+ .build()
+ .unwrap();
+ nodes[1].node.funding_contributed(&channel_id, &node_id_0, rbf_contribution, None).unwrap();
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ let tx_init_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxInitRbf, node_id_0);
+ nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf);
+ let _tx_ack_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxAckRbf, node_id_1);
+
+ // Node 0 commits its own contribution at the floor RBF feerate. That is enough to replace the
+ // original candidate, but not the higher-feerate round now in flight, so it waits for the lock.
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+ let queued = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ let details = nodes[0]
+ .node
+ .list_channels()
+ .iter()
+ .find(|channel| channel.channel_id == channel_id)
+ .unwrap()
+ .splice_details
+ .clone()
+ .unwrap();
+ // Negotiated original, the counterparty's in-flight higher-feerate RBF, then our queued
+ // contribution awaiting the lock.
+ assert_eq!(details.candidates.len(), 3);
+ assert!(matches!(details.candidates[0].status, SpliceCandidateStatus::Negotiated { .. }));
+ assert_eq!(candidate_txid(&details.candidates[0]), splice_tx.compute_txid());
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::ConstructingTransaction { is_initiator: false, .. }
+ ));
+ assert_eq!(details.candidates[2].status, SpliceCandidateStatus::WaitingOnLock);
+ assert_eq!(details.candidates[2].contribution, Some(queued));
+
+ // This test leaves an RBF round in flight; drain the un-exchanged messages for a clean teardown.
+ nodes[0].node.get_and_clear_pending_msg_events();
+ nodes[1].node.get_and_clear_pending_msg_events();
+}
diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs
index 44926cf..936d35e 100644
--- a/lightning/src/routing/router.rs
+++ b/lightning/src/routing/router.rs
@@ -4239,6 +4239,7 @@ mod tests {
pending_inbound_htlcs: Vec::new(),
pending_outbound_htlcs: Vec::new(),
current_dust_exposure_msat: None,
+ splice_details: None,
}
}
@@ -9809,6 +9810,7 @@ pub(crate) mod bench_utils {
pending_inbound_htlcs: Vec::new(),
pending_outbound_htlcs: Vec::new(),
current_dust_exposure_msat: None,
+ splice_details: None,
}
}
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.