@@ -15,12 +15,13 @@ use bitcoin::secp256k1::PublicKey;
1515use bitcoin:: transaction:: Version ;
1616use bitcoin:: { OutPoint , Transaction , TxIn } ;
1717use lightning:: chain:: transaction:: OutPoint as LdkOutPoint ;
18+ use lightning:: ln:: channel_state:: { SpliceCandidateDetails , SpliceCandidateStatus } ;
1819use lightning:: ln:: channelmanager:: PaymentId ;
1920use lightning:: ln:: funding:: FundingContribution ;
2021use lightning:: ln:: types:: ChannelId ;
2122
2223use crate :: data_store:: StorableObject ;
23- use crate :: logger:: { log_error, LdkLogger , Logger } ;
24+ use crate :: logger:: { log_error, log_info , LdkLogger , Logger } ;
2425use crate :: payment:: pending_payment_store:: {
2526 PendingPaymentDetails , PendingPaymentDetailsUpdate , SpliceIntent , SpliceKind ,
2627} ;
@@ -55,9 +56,9 @@ fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -> bool {
5556/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the
5657/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its
5758/// channel closes. The record exists for recovery, not retry: a splice still recorded at the
58- /// next startup identifies one that was in flight when the node stopped, so anything it reserved
59- /// can be released , and events about the splice can be described in terms of the original
60- /// request.
59+ /// next startup identifies one that was in flight when the node stopped, so [`Self::reconcile`]
60+ /// can release anything it still reserves , and events about the splice can be described in
61+ /// terms of the original request.
6162pub ( crate ) struct SpliceTracker {
6263 channel_manager : Arc < ChannelManager > ,
6364 wallet : Arc < Wallet > ,
@@ -87,6 +88,89 @@ impl SpliceTracker {
8788 }
8889 }
8990
91+ /// Reconciles the persisted splice intents against live channel state, releasing whatever the
92+ /// wallet still holds for splices that did not survive the restart. LDK only persists a
93+ /// splice once its negotiation reaches `AwaitingSignatures`, so a splice lost earlier leaves
94+ /// no trace in LDK — the intent record is what recognizes the loss. Run once at startup,
95+ /// before background chain syncing and event processing start, so nothing can act on the
96+ /// stale reservations first.
97+ ///
98+ /// Recovery is silent: no failure event is fabricated for a splice lost this way, since the
99+ /// initiating call already returned and the channel simply shows no pending splice anymore.
100+ pub ( crate ) async fn reconcile ( & self ) {
101+ let records = self . pending_payment_store . list_filter ( |p| p. splice_intent ( ) . is_some ( ) ) . await ;
102+ for record in records {
103+ let payment_id = record. id ( ) ;
104+ let Some ( intent) = record. splice_intent ( ) . cloned ( ) else {
105+ continue ;
106+ } ;
107+
108+ let channel = self
109+ . channel_manager
110+ . list_channels_with_counterparty ( & intent. counterparty_node_id )
111+ . into_iter ( )
112+ . find ( |c| c. channel_id == intent. channel_id ) ;
113+ let Some ( channel) = channel else {
114+ // The channel is gone; there is nothing to splice anymore.
115+ log_info ! (
116+ self . logger,
117+ "Dropping the recorded splice of closed channel {} with counterparty {}" ,
118+ intent. channel_id,
119+ intent. counterparty_node_id,
120+ ) ;
121+ self . release_contribution ( intent. channel_id , & intent. contribution ) . await ;
122+ self . clear_persisted_intent ( payment_id, |i| * i == intent) . await ;
123+ continue ;
124+ } ;
125+
126+ if channel. funding_txo != Some ( intent. pre_splice_funding_txo ) {
127+ // The funding moved on while the node was down: the recorded splice, a
128+ // replacement, or a counterparty splice locked — the same situation a live lock
129+ // event resolves, so resolve it the same way.
130+ self . on_channel_ready (
131+ intent. counterparty_node_id ,
132+ intent. channel_id ,
133+ channel. funding_txo . map ( |txo| txo. into_bitcoin_outpoint ( ) ) ,
134+ )
135+ . await ;
136+ continue ;
137+ }
138+
139+ let candidates = channel
140+ . splice_details
141+ . as_ref ( )
142+ . map ( |details| details. candidates . as_slice ( ) )
143+ . unwrap_or ( & [ ] ) ;
144+ match decide_reconcile ( candidates) {
145+ ReconcileDecision :: Keep => {
146+ // A kept record may still reserve more than LDK's surviving rounds use —
147+ // extras a fee bump lost with the restart had reserved. Release the
148+ // difference.
149+ let extras = unclaimed_inputs ( & intent. contribution , candidates) ;
150+ if let Err ( e) = self . wallet . unlock_outpoints ( & extras) . await {
151+ log_error ! (
152+ self . logger,
153+ "Failed to release unused splice inputs on channel {}: {}" ,
154+ intent. channel_id,
155+ e,
156+ ) ;
157+ }
158+ } ,
159+ ReconcileDecision :: Lost => {
160+ log_info ! (
161+ self . logger,
162+ "Dropping a splice on channel {} with counterparty {} that did not survive \
163+ the restart",
164+ intent. channel_id,
165+ intent. counterparty_node_id,
166+ ) ;
167+ self . release_contribution ( intent. channel_id , & intent. contribution ) . await ;
168+ self . clear_persisted_intent ( payment_id, |i| * i == intent) . await ;
169+ } ,
170+ }
171+ }
172+ }
173+
90174 /// Persists a user-initiated splice as an intent and hands its contribution to
91175 /// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the
92176 /// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a
@@ -367,7 +451,8 @@ impl SpliceTracker {
367451 /// Settles any persisted intent made obsolete by a newly locked funding transaction. An
368452 /// intent whose pre-splice outpoint is the newly locked funding was created after the lock
369453 /// and stays; one LDK still holds as a queued splice candidate is refreshed to the new
370- /// funding rather than settled.
454+ /// funding rather than settled. Also resolves [`Self::reconcile`]'s case of a funding that
455+ /// moved while the node was down — the same situation, minus the event.
371456 pub ( crate ) async fn on_channel_ready (
372457 & self , counterparty_node_id : PublicKey , channel_id : ChannelId ,
373458 funding_txo : Option < OutPoint > ,
@@ -546,6 +631,61 @@ fn record_with_intent_cleared(
546631 }
547632}
548633
634+ /// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding
635+ /// are unchanged, decided from the splice rounds LDK reports on the channel.
636+ #[ derive( Debug , PartialEq , Eq ) ]
637+ enum ReconcileDecision {
638+ /// LDK still holds a splice of ours; leave the intent in place until the splice settles.
639+ Keep ,
640+ /// LDK holds no splice of ours: the recorded splice died with the restart, so whatever was
641+ /// reserved for it is released and the intent dropped.
642+ Lost ,
643+ }
644+
645+ /// Decides the startup action for a persisted intent from the channel's [`SpliceDetails`]
646+ /// candidates.
647+ ///
648+ /// [`SpliceDetails`]: lightning::ln::channel_state::SpliceDetails
649+ fn decide_reconcile ( candidates : & [ SpliceCandidateDetails ] ) -> ReconcileDecision {
650+ // A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures`
651+ // survives a restart, and LDK resumes the signature exchange itself on reconnect.
652+ let in_flight = candidates
653+ . iter ( )
654+ . any ( |candidate| !matches ! ( candidate. status, SpliceCandidateStatus :: Negotiated { .. } ) ) ;
655+ if in_flight {
656+ return ReconcileDecision :: Keep ;
657+ }
658+
659+ // LDK persists a splice once negotiated, so a negotiated candidate carrying a local
660+ // contribution is a splice of ours LDK sees through to lock — even one negotiated at a
661+ // different feerate than a recorded fee bump asked for. Without one, only counterparty
662+ // rounds (or nothing) survived: the recorded splice is gone.
663+ if candidates. iter ( ) . any ( |candidate| candidate. contribution . is_some ( ) ) {
664+ ReconcileDecision :: Keep
665+ } else {
666+ ReconcileDecision :: Lost
667+ }
668+ }
669+
670+ /// The inputs `contribution` reserved that no candidate's own contribution still claims — extras
671+ /// a splice attempt lost with the restart had reserved. A counterparty-only round carries no
672+ /// contribution and claims nothing.
673+ fn unclaimed_inputs (
674+ contribution : & FundingContribution , candidates : & [ SpliceCandidateDetails ] ,
675+ ) -> Vec < OutPoint > {
676+ let claimed: Vec < OutPoint > = candidates
677+ . iter ( )
678+ . filter_map ( |candidate| candidate. contribution . as_ref ( ) )
679+ . flat_map ( |contribution| contribution. inputs ( ) . iter ( ) . map ( |input| input. outpoint ( ) ) )
680+ . collect ( ) ;
681+ contribution
682+ . inputs ( )
683+ . iter ( )
684+ . map ( |input| input. outpoint ( ) )
685+ . filter ( |outpoint| !claimed. contains ( outpoint) )
686+ . collect ( )
687+ }
688+
549689#[ cfg( test) ]
550690mod tests {
551691 use std:: str:: FromStr ;
@@ -556,7 +696,7 @@ mod tests {
556696 use super :: * ;
557697 use crate :: payment:: pending_payment_store:: {
558698 test_funding_contribution, test_funding_contribution_with_feerate,
559- test_funding_contribution_with_outputs,
699+ test_funding_contribution_with_inputs , test_funding_contribution_with_outputs,
560700 } ;
561701 use crate :: payment:: store:: { ConfirmationStatus , PaymentKind } ;
562702 use crate :: payment:: PaymentDirection ;
@@ -661,4 +801,103 @@ mod tests {
661801 & test_funding_contribution_with_feerate( 500 )
662802 ) ) ;
663803 }
804+
805+ fn negotiated_candidate ( contribution : Option < FundingContribution > ) -> SpliceCandidateDetails {
806+ SpliceCandidateDetails {
807+ contribution,
808+ status : SpliceCandidateStatus :: Negotiated {
809+ txid : Txid :: from_byte_array ( [ 9u8 ; 32 ] ) ,
810+ new_channel_value_satoshis : 100_000 ,
811+ } ,
812+ }
813+ }
814+
815+ /// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend;
816+ /// `seed` varies the output script, and with it the txid.
817+ fn test_prevtx ( seed : u8 ) -> Transaction {
818+ use bitcoin:: { ScriptBuf , TxOut , WPubkeyHash } ;
819+
820+ Transaction {
821+ version : Version :: TWO ,
822+ lock_time : LockTime :: ZERO ,
823+ input : vec ! [ TxIn :: default ( ) ] ,
824+ output : vec ! [ TxOut {
825+ value: Amount :: from_sat( 10_000 ) ,
826+ script_pubkey: ScriptBuf :: new_p2wpkh( & WPubkeyHash :: from_byte_array( [ seed; 20 ] ) ) ,
827+ } ] ,
828+ }
829+ }
830+
831+ /// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays
832+ /// in place until the splice settles.
833+ #[ test]
834+ fn reconcile_keeps_the_intent_while_ldk_drives_a_round ( ) {
835+ let in_flight = SpliceCandidateDetails {
836+ contribution : Some ( test_funding_contribution ( ) ) ,
837+ status : SpliceCandidateStatus :: AwaitingSignatures {
838+ is_initiator : true ,
839+ funding_feerate_sat_per_1000_weight : 253 ,
840+ new_channel_value_satoshis : 100_000 ,
841+ txid : Txid :: from_byte_array ( [ 9u8 ; 32 ] ) ,
842+ } ,
843+ } ;
844+ assert_eq ! ( decide_reconcile( & [ in_flight] ) , ReconcileDecision :: Keep ) ;
845+ }
846+
847+ /// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock;
848+ /// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding
849+ /// outpoint has not moved on yet.
850+ #[ test]
851+ fn reconcile_trusts_a_negotiated_contribution ( ) {
852+ let negotiated = [ negotiated_candidate ( Some ( test_funding_contribution ( ) ) ) ] ;
853+ assert_eq ! ( decide_reconcile( & negotiated) , ReconcileDecision :: Keep ) ;
854+ }
855+
856+ /// A fee bump that only survives as a candidate negotiated at a lower feerate than requested
857+ /// is not lost: the recorded bump is moot, but the splice lives on and locks. The old
858+ /// higher-feerate attempt's extra reservations are released through the input difference, not
859+ /// by dropping the record.
860+ #[ test]
861+ fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate ( ) {
862+ let lower = [ negotiated_candidate ( Some ( test_funding_contribution_with_feerate ( 253 ) ) ) ] ;
863+ assert_eq ! ( decide_reconcile( & lower) , ReconcileDecision :: Keep ) ;
864+ }
865+
866+ /// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the
867+ /// recorded splice died with the restart.
868+ #[ test]
869+ fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution ( ) {
870+ assert_eq ! ( decide_reconcile( & [ ] ) , ReconcileDecision :: Lost ) ;
871+ let counterparty_only = [ negotiated_candidate ( None ) ] ;
872+ assert_eq ! ( decide_reconcile( & counterparty_only) , ReconcileDecision :: Lost ) ;
873+ }
874+
875+ /// The inputs a kept record reserves beyond what LDK's candidates still claim are identified
876+ /// for release; a counterparty-only round claims nothing and must not suppress the
877+ /// difference.
878+ #[ test]
879+ fn unclaimed_inputs_are_those_no_candidate_contribution_uses ( ) {
880+ let prevtxs: Vec < Transaction > = ( 1u8 ..=3 ) . map ( test_prevtx) . collect ( ) ;
881+ let outpoint = |tx : & Transaction | OutPoint { txid : tx. compute_txid ( ) , vout : 0 } ;
882+ let recorded = test_funding_contribution_with_inputs ( 253 , & prevtxs) ;
883+
884+ // Every input still claimed by a surviving candidate: nothing to release.
885+ let all =
886+ [ negotiated_candidate ( Some ( test_funding_contribution_with_inputs ( 253 , & prevtxs) ) ) ] ;
887+ assert ! ( unclaimed_inputs( & recorded, & all) . is_empty( ) ) ;
888+
889+ // A candidate claiming two of the three inputs: the third is released, even with a
890+ // counterparty-only round alongside.
891+ let partial = [
892+ negotiated_candidate ( None ) ,
893+ negotiated_candidate ( Some ( test_funding_contribution_with_inputs ( 253 , & prevtxs[ ..2 ] ) ) ) ,
894+ ] ;
895+ assert_eq ! ( unclaimed_inputs( & recorded, & partial) , vec![ outpoint( & prevtxs[ 2 ] ) ] ) ;
896+
897+ // No candidates at all: everything is released.
898+ assert_eq ! (
899+ unclaimed_inputs( & recorded, & [ ] ) ,
900+ prevtxs. iter( ) . map( outpoint) . collect:: <Vec <_>>( )
901+ ) ;
902+ }
664903}
0 commit comments