@@ -17,13 +17,13 @@ use bitcoin::transaction::Version;
1717use bitcoin:: { OutPoint , ScriptBuf , Transaction , TxIn , TxOut , Txid } ;
1818use lightning:: chain:: chaininterface:: FundingCandidate ;
1919use lightning:: chain:: transaction:: OutPoint as LdkOutPoint ;
20- use lightning:: ln:: channel_state:: { ChannelDetails , SpliceCandidateDetails } ;
20+ use lightning:: ln:: channel_state:: { ChannelDetails , SpliceCandidateDetails , SpliceCandidateStatus } ;
2121use lightning:: ln:: channelmanager:: PaymentId ;
2222use lightning:: ln:: funding:: FundingContribution ;
2323use lightning:: ln:: types:: ChannelId ;
2424
2525use crate :: data_store:: StorableObject ;
26- use crate :: logger:: { log_error, LdkLogger , Logger } ;
26+ use crate :: logger:: { log_error, log_info , LdkLogger , Logger } ;
2727use crate :: payment:: pending_payment_store:: {
2828 PendingPaymentDetails , PendingPaymentDetailsUpdate , SpliceIntent , SpliceKind ,
2929} ;
@@ -56,21 +56,23 @@ pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -
5656///
5757/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the
5858/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its
59- /// channel closes. The record exists for recovery, not retry: a splice still recorded at the
60- /// next startup identifies one that was in flight when the node stopped, so anything it reserved
61- /// can be released, and events about the splice can be described in terms of the original
62- /// request. Each splice has a record of its own — a channel may carry several, a pending splice
63- /// and the splices queued behind it — so that each is recognized and described whatever became
64- /// of the others; only a fee bump joins the record of the round it replaces.
59+ /// channel closes. The record exists for recovery, not retry: a splice still recorded at the next
60+ /// startup identifies one that was in flight when the node stopped, so [`Self::reconcile`] can
61+ /// release what it still reserves where nothing else will, and events about the splice can be
62+ /// described in terms of the original request. Each splice has a record of its own — a channel may
63+ /// carry several, a pending splice and the splices queued behind it — so that each is recognized
64+ /// and described whatever became of the others; only a fee bump joins the record of the round it
65+ /// replaces.
6566pub ( crate ) struct SpliceTracker {
6667 channel_manager : Arc < ChannelManager > ,
6768 wallet : Arc < Wallet > ,
6869 pending_payment_store : Arc < PendingPaymentStore > ,
6970 /// Serializes everything that reads or settles a channel's intent records against
7071 /// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by
7172 /// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and
72- /// [`Self::on_channel_closed`], and the funding record [`Self::on_funding_ready_for_signing`]
73- /// files under an intent's id. Without it, the failure event of a synchronously rejected
73+ /// [`Self::on_channel_closed`], the funding record [`Self::on_funding_ready_for_signing`]
74+ /// files under an intent's id, and the startup pass of [`Self::reconcile`]. Without it, the
75+ /// failure event of a synchronously rejected
7476 /// hand-off could settle the just-written intent while `submit` is still deciding whether to
7577 /// keep it, and a lock event handled between `submit`'s funding read and its persist could
7678 /// leave the new intent anchored at a funding the channel has moved past, which nothing would
@@ -96,6 +98,127 @@ impl SpliceTracker {
9698 }
9799 }
98100
101+ /// Reconciles the persisted splice intents against live channel state, releasing whatever the
102+ /// wallet still holds for a splice that did not survive the restart and nothing else will
103+ /// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a
104+ /// splice lost earlier leaves no trace in LDK's channel state — the intent record is what
105+ /// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or
106+ /// failed it as it was last written — the failure is replayed at startup — and returns what it
107+ /// reserved through `DiscardFunding`; a round of a channel that closed meanwhile is watched by
108+ /// the channel's monitor until the close matures. Such rounds are left to those events. Run
109+ /// once at startup, before background chain syncing and event processing start, so nothing can
110+ /// act on the stale reservations first. Holds the submit lock throughout, as the event handlers
111+ /// do.
112+ ///
113+ /// Recovery fabricates no failure event for a splice lost this way: the initiating call
114+ /// already returned and the channel simply shows no pending splice anymore. LDK itself may
115+ /// report the loss — a contribution it was still queueing or negotiating when it was last
116+ /// persisted is failed as it is written, and the failure replayed at startup. That replay
117+ /// runs after this reconciliation, so the report carries the splice's parameters only if
118+ /// `decide_reconcile` kept the intent: a splice queued behind a pending one of ours, or a fee
119+ /// bump of one, is reported with its parameters; a channel's only splice, whose intent
120+ /// settled here, without them.
121+ pub ( crate ) async fn reconcile ( & self ) {
122+ let guard = self . submit_lock . lock ( ) . await ;
123+ let records = self . pending_payment_store . list_filter ( |p| p. splice_intent ( ) . is_some ( ) ) . await ;
124+ for record in records {
125+ let payment_id = record. id ( ) ;
126+ let Some ( intent) = record. splice_intent ( ) . cloned ( ) else {
127+ continue ;
128+ } ;
129+
130+ let channel = self . channel ( intent. counterparty_node_id , intent. channel_id ) ;
131+ let Some ( channel) = channel else {
132+ // The channel is gone; there is nothing to splice anymore. What the wallet holds
133+ // for the intent is released only while no recorded round exists: a round the
134+ // closed channel's monitor watches is either spent by the close or returned through
135+ // the `DiscardFunding` event the monitor queues once the close matures, and a
136+ // recorded round the monitor never watched — the counterparty's `commitment_signed`
137+ // never arrived before the node stopped — is released by neither, as at
138+ // `ChannelClosed`. A bare intent has no such round — LDK never wrote the splice —
139+ // so nothing else would release it.
140+ log_info ! (
141+ self . logger,
142+ "Dropping the recorded splice of closed channel {} with counterparty {}" ,
143+ intent. channel_id,
144+ intent. counterparty_node_id,
145+ ) ;
146+ if record. candidates ( ) . is_empty ( ) {
147+ self . release_contribution ( intent. channel_id , & intent. contribution , & [ ] , None )
148+ . await ;
149+ }
150+ // TODO(#1037): once inputs are locked at coin selection, the parts of the
151+ // contribution no recorded round uses stay locked with no record to release them
152+ // from after the intent is cleared here: release them before clearing. And
153+ // `release_contribution` swallows a failed release, which then leaves locks no
154+ // record names either: keep the intent when the release fails. The same holds for a
155+ // recorded round the monitor never watched: nothing releases its inputs once the
156+ // intent is cleared here.
157+ self . clear_persisted_intent ( payment_id, |i| * i == intent) . await ;
158+ continue ;
159+ } ;
160+
161+ if channel. funding_txo != Some ( intent. pre_splice_funding_txo ) {
162+ // The funding moved on while the node was down: the recorded splice, a
163+ // replacement, or a counterparty splice locked — the same situation a live lock
164+ // event resolves, so resolve it the same way.
165+ if let Some ( funding_txo) = channel. funding_txo {
166+ self . settle_superseded_intents_locked (
167+ & guard,
168+ intent. counterparty_node_id ,
169+ intent. channel_id ,
170+ funding_txo. into_bitcoin_outpoint ( ) ,
171+ Some ( & channel) ,
172+ )
173+ . await ;
174+ }
175+ continue ;
176+ }
177+
178+ let candidates = channel
179+ . splice_details
180+ . as_ref ( )
181+ . map ( |details| details. candidates . as_slice ( ) )
182+ . unwrap_or ( & [ ] ) ;
183+ match decide_reconcile ( candidates) {
184+ ReconcileDecision :: Keep => {
185+ // A kept record may still reserve more than LDK's surviving rounds use —
186+ // extras a fee bump lost with the restart had reserved. Release the
187+ // difference.
188+ let extras = unclaimed_inputs ( & intent. contribution , candidates) ;
189+ if let Err ( e) = self . wallet . unlock_outpoints ( & extras) . await {
190+ log_error ! (
191+ self . logger,
192+ "Failed to release unused splice inputs on channel {}: {}" ,
193+ intent. channel_id,
194+ e,
195+ ) ;
196+ }
197+ } ,
198+ ReconcileDecision :: Lost => {
199+ log_info ! (
200+ self . logger,
201+ "Dropping a splice on channel {} with counterparty {} that did not survive \
202+ the restart",
203+ intent. channel_id,
204+ intent. counterparty_node_id,
205+ ) ;
206+ self . release_contribution (
207+ intent. channel_id ,
208+ & intent. contribution ,
209+ candidates,
210+ None ,
211+ )
212+ . await ;
213+ // TODO(#1037): `release_contribution` swallows a failed release. Once inputs
214+ // are locked at coin selection, a failure here leaves locks no record names
215+ // after the intent is cleared: keep the intent when the release fails.
216+ self . clear_persisted_intent ( payment_id, |i| * i == intent) . await ;
217+ } ,
218+ }
219+ }
220+ }
221+
99222 /// Persists a user-initiated splice as an intent and hands its contribution to
100223 /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the
101224 /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a
@@ -478,8 +601,10 @@ impl SpliceTracker {
478601 }
479602
480603 /// Settles any persisted intent made obsolete by the channel's funding having moved on to
481- /// `funding_txo`: the funding a `ChannelReady` event reports as locked, or the one a new
482- /// splice builds on ([`Self::submit`]). Each of the channel's intents is decided on its own
604+ /// `funding_txo`: the funding a `ChannelReady` event reports as locked, the one a new splice
605+ /// builds on ([`Self::submit`]), or the one [`Self::reconcile`] finds the channel at after a
606+ /// funding moved while the node was down — the same situation, minus the event. Each of the
607+ /// channel's intents is decided on its own
483608 /// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`,
484609 /// as the caller listed it): one whose pre-splice outpoint is that funding was created after
485610 /// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the
@@ -890,6 +1015,52 @@ fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option<Pendin
8901015 }
8911016}
8921017
1018+ /// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding
1019+ /// are unchanged, decided from the splice rounds LDK reports on the channel.
1020+ #[ derive( Debug , PartialEq , Eq ) ]
1021+ enum ReconcileDecision {
1022+ /// LDK still holds a splice of ours; leave the intent in place until the splice settles.
1023+ Keep ,
1024+ /// LDK holds no splice of ours: the recorded splice died with the restart, so whatever was
1025+ /// reserved for it is released and the intent dropped.
1026+ Lost ,
1027+ }
1028+
1029+ /// Decides the startup action for a persisted intent from the channel's [`SpliceDetails`]
1030+ /// candidates.
1031+ ///
1032+ /// [`SpliceDetails`]: lightning::ln::channel_state::SpliceDetails
1033+ fn decide_reconcile ( candidates : & [ SpliceCandidateDetails ] ) -> ReconcileDecision {
1034+ // A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures`
1035+ // survives a restart, and LDK resumes the signature exchange itself on reconnect.
1036+ let in_flight = candidates
1037+ . iter ( )
1038+ . any ( |candidate| !matches ! ( candidate. status, SpliceCandidateStatus :: Negotiated { .. } ) ) ;
1039+ if in_flight {
1040+ return ReconcileDecision :: Keep ;
1041+ }
1042+
1043+ // LDK persists a splice once negotiated, so a negotiated candidate carrying a local
1044+ // contribution is a splice of ours LDK sees through to lock — even one negotiated at a
1045+ // different feerate than a recorded fee bump asked for. Without one, only counterparty
1046+ // rounds (or nothing) survived: the recorded splice is gone.
1047+ if candidates. iter ( ) . any ( |candidate| candidate. contribution . is_some ( ) ) {
1048+ ReconcileDecision :: Keep
1049+ } else {
1050+ ReconcileDecision :: Lost
1051+ }
1052+ }
1053+
1054+ /// The inputs `contribution` reserved that no candidate's own contribution still claims — extras
1055+ /// a splice attempt lost with the restart had reserved. A counterparty-only round carries no
1056+ /// contribution and claims nothing.
1057+ fn unclaimed_inputs (
1058+ contribution : & FundingContribution , candidates : & [ SpliceCandidateDetails ] ,
1059+ ) -> Vec < OutPoint > {
1060+ let claimants = candidates. iter ( ) . filter_map ( |candidate| candidate. contribution . as_ref ( ) ) ;
1061+ unclaimed_parts ( contribution, claimants) . 0
1062+ }
1063+
8931064#[ cfg( test) ]
8941065mod tests {
8951066 use std:: str:: FromStr ;
@@ -900,8 +1071,8 @@ mod tests {
9001071 use super :: * ;
9011072 use crate :: payment:: pending_payment_store:: {
9021073 test_funding_contribution, test_funding_contribution_with_feerate,
903- test_funding_contribution_with_outputs , test_funding_contribution_with_parts ,
904- FundingTxCandidate ,
1074+ test_funding_contribution_with_inputs , test_funding_contribution_with_outputs ,
1075+ test_funding_contribution_with_parts , FundingTxCandidate ,
9051076 } ;
9061077 use crate :: payment:: store:: { ConfirmationStatus , PaymentDetails , PaymentKind } ;
9071078 use crate :: payment:: { PaymentDirection , PaymentStatus } ;
@@ -1333,4 +1504,77 @@ mod tests {
13331504 ( prevtxs. iter( ) . map( outpoint) . collect( ) , vec![ splice_out, change( 20_000 ) ] )
13341505 ) ;
13351506 }
1507+
1508+ /// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays
1509+ /// in place until the splice settles.
1510+ #[ test]
1511+ fn reconcile_keeps_the_intent_while_ldk_drives_a_round ( ) {
1512+ let in_flight = SpliceCandidateDetails {
1513+ contribution : Some ( test_funding_contribution ( ) ) ,
1514+ status : SpliceCandidateStatus :: AwaitingSignatures {
1515+ is_initiator : true ,
1516+ funding_feerate_sat_per_1000_weight : 253 ,
1517+ new_channel_value_satoshis : 100_000 ,
1518+ txid : Txid :: from_byte_array ( [ 9u8 ; 32 ] ) ,
1519+ } ,
1520+ } ;
1521+ assert_eq ! ( decide_reconcile( & [ in_flight] ) , ReconcileDecision :: Keep ) ;
1522+ }
1523+
1524+ /// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock;
1525+ /// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding
1526+ /// outpoint has not moved on yet.
1527+ #[ test]
1528+ fn reconcile_trusts_a_negotiated_contribution ( ) {
1529+ let negotiated = [ negotiated_candidate ( Some ( test_funding_contribution ( ) ) ) ] ;
1530+ assert_eq ! ( decide_reconcile( & negotiated) , ReconcileDecision :: Keep ) ;
1531+ }
1532+
1533+ /// A fee bump that only survives as a candidate negotiated at a lower feerate than requested
1534+ /// is not lost: the recorded bump is moot, but the splice lives on and locks. The old
1535+ /// higher-feerate attempt's extra reservations are released through the input difference, not
1536+ /// by dropping the record.
1537+ #[ test]
1538+ fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate ( ) {
1539+ let lower = [ negotiated_candidate ( Some ( test_funding_contribution_with_feerate ( 253 ) ) ) ] ;
1540+ assert_eq ! ( decide_reconcile( & lower) , ReconcileDecision :: Keep ) ;
1541+ }
1542+
1543+ /// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the
1544+ /// recorded splice died with the restart.
1545+ #[ test]
1546+ fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution ( ) {
1547+ assert_eq ! ( decide_reconcile( & [ ] ) , ReconcileDecision :: Lost ) ;
1548+ let counterparty_only = [ negotiated_candidate ( None ) ] ;
1549+ assert_eq ! ( decide_reconcile( & counterparty_only) , ReconcileDecision :: Lost ) ;
1550+ }
1551+
1552+ /// The inputs a kept record reserves beyond what LDK's candidates still claim are identified
1553+ /// for release; a counterparty-only round claims nothing and must not suppress the
1554+ /// difference.
1555+ #[ test]
1556+ fn unclaimed_inputs_are_those_no_candidate_contribution_uses ( ) {
1557+ let prevtxs: Vec < Transaction > = ( 1u8 ..=3 ) . map ( test_prevtx) . collect ( ) ;
1558+ let outpoint = |tx : & Transaction | OutPoint { txid : tx. compute_txid ( ) , vout : 0 } ;
1559+ let recorded = test_funding_contribution_with_inputs ( 253 , & prevtxs) ;
1560+
1561+ // Every input still claimed by a surviving candidate: nothing to release.
1562+ let all =
1563+ [ negotiated_candidate ( Some ( test_funding_contribution_with_inputs ( 253 , & prevtxs) ) ) ] ;
1564+ assert ! ( unclaimed_inputs( & recorded, & all) . is_empty( ) ) ;
1565+
1566+ // A candidate claiming two of the three inputs: the third is released, even with a
1567+ // counterparty-only round alongside.
1568+ let partial = [
1569+ negotiated_candidate ( None ) ,
1570+ negotiated_candidate ( Some ( test_funding_contribution_with_inputs ( 253 , & prevtxs[ ..2 ] ) ) ) ,
1571+ ] ;
1572+ assert_eq ! ( unclaimed_inputs( & recorded, & partial) , vec![ outpoint( & prevtxs[ 2 ] ) ] ) ;
1573+
1574+ // No candidates at all: everything is released.
1575+ assert_eq ! (
1576+ unclaimed_inputs( & recorded, & [ ] ) ,
1577+ prevtxs. iter( ) . map( outpoint) . collect:: <Vec <_>>( )
1578+ ) ;
1579+ }
13361580}
0 commit comments