@@ -11,18 +11,54 @@ use std::ops::Deref;
1111use std:: sync:: Arc ;
1212
1313use bitcoin:: secp256k1:: PublicKey ;
14+ use bitcoin:: { Amount , OutPoint } ;
15+ use lightning:: events:: NegotiationFailureReason ;
1416use lightning:: ln:: channelmanager:: PaymentId ;
17+ use lightning:: ln:: funding:: FundingContribution ;
1518use lightning:: ln:: types:: ChannelId ;
1619
1720use crate :: data_store:: StorableObject ;
1821use crate :: event:: { Event , EventQueue } ;
22+ use crate :: fee_estimator:: {
23+ max_funding_feerate, ConfirmationTarget , FeeEstimator , OnchainFeeEstimator ,
24+ } ;
1925use crate :: logger:: { log_error, log_info, LdkLogger } ;
2026use crate :: payment:: pending_payment_store:: {
21- PendingPaymentDetailsUpdate , SpliceIntent , SpliceKind , MAX_SPLICE_ATTEMPTS ,
27+ PendingPaymentDetails , PendingPaymentDetailsUpdate , SpliceIntent , SpliceKind ,
28+ MAX_SPLICE_ATTEMPTS ,
2229} ;
23- use crate :: types:: { ChannelManager , PendingPaymentStore } ;
30+ use crate :: types:: { ChannelManager , PendingPaymentStore , UserChannelId , Wallet } ;
2431use crate :: Error ;
2532
33+ /// The action to take on a `SpliceNegotiationFailed` for a splice intent we track, decided purely
34+ /// from the failure `reason` and the intent's attempt count so the decision matrix can be
35+ /// unit-tested without a live channel. A failure for a splice we don't track is surfaced directly
36+ /// (see [`SpliceRetrier::on_negotiation_failed`]) and never reaches here.
37+ #[ derive( Debug , PartialEq , Eq ) ]
38+ enum RetryDecision {
39+ /// Give up: clear the intent and surface the failure to the user.
40+ Abandon ,
41+ /// Resubmit the stored contribution unchanged (a transient failure such as a disconnect).
42+ ResubmitStored ,
43+ /// Rebuild a fresh contribution from the original parameters (the stored one went stale).
44+ Rebuild ,
45+ }
46+
47+ fn decide_retry ( reason : & NegotiationFailureReason , attempts : u8 ) -> RetryDecision {
48+ if !reason. is_retriable ( ) || attempts >= MAX_SPLICE_ATTEMPTS {
49+ return RetryDecision :: Abandon ;
50+ }
51+ match reason {
52+ // The stored contribution is still valid after a transient failure.
53+ NegotiationFailureReason :: PeerDisconnected | NegotiationFailureReason :: Unknown => {
54+ RetryDecision :: ResubmitStored
55+ } ,
56+ // The remaining retriable reasons (`FeeRateTooLow`, `ContributionInvalid`) mean the stored
57+ // contribution went stale.
58+ _ => RetryDecision :: Rebuild ,
59+ }
60+ }
61+
2662/// Resubmits user-initiated splices that LDK dropped before durably recording them.
2763///
2864/// LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it abandons an
4076 L :: Target : LdkLogger ,
4177{
4278 channel_manager : Arc < ChannelManager > ,
79+ wallet : Arc < Wallet > ,
80+ fee_estimator : Arc < OnchainFeeEstimator > ,
4381 pending_payment_store : Arc < PendingPaymentStore > ,
4482 event_queue : Arc < EventQueue < L > > ,
4583 logger : L ,
@@ -50,10 +88,11 @@ where
5088 L :: Target : LdkLogger ,
5189{
5290 pub ( crate ) fn new (
53- channel_manager : Arc < ChannelManager > , pending_payment_store : Arc < PendingPaymentStore > ,
91+ channel_manager : Arc < ChannelManager > , wallet : Arc < Wallet > ,
92+ fee_estimator : Arc < OnchainFeeEstimator > , pending_payment_store : Arc < PendingPaymentStore > ,
5493 event_queue : Arc < EventQueue < L > > , logger : L ,
5594 ) -> Self {
56- Self { channel_manager, pending_payment_store, event_queue, logger }
95+ Self { channel_manager, wallet , fee_estimator , pending_payment_store, event_queue, logger }
5796 }
5897
5998 /// Reconciles persisted splice intents against live channel state. Run once at startup to pick
@@ -199,4 +238,186 @@ where
199238 log_error ! ( self . logger, "Failed to push to event queue: {}" , e) ;
200239 }
201240 }
241+
242+ /// Applies a `SpliceNegotiationFailed` to any matching splice intent, retrying recoverable
243+ /// failures. Returns whether the failure should be surfaced to the user (i.e. the splice is
244+ /// given up on).
245+ pub ( crate ) async fn on_negotiation_failed (
246+ & self , user_channel_id : UserChannelId , reason : NegotiationFailureReason ,
247+ contribution : Option < FundingContribution > ,
248+ ) -> bool {
249+ let Some ( record) = self . record_for_channel ( user_channel_id) else {
250+ return true ;
251+ } ;
252+ let id = record. id ( ) ;
253+ let has_payment = record. details ( ) . is_some ( ) ;
254+ let Some ( intent) = record. splice_intent ( ) . cloned ( ) else {
255+ return true ;
256+ } ;
257+
258+ // Only act on failures of the splice we are tracking. A mismatch means the failure concerns
259+ // some other attempt (e.g. a stale event replayed after a newer splice was initiated).
260+ if contribution. as_ref ( ) != Some ( & intent. contribution ) {
261+ return true ;
262+ }
263+
264+ let channel_id = intent. channel_id ;
265+ let counterparty_node_id = intent. counterparty_node_id ;
266+ match decide_retry ( & reason, intent. attempts ) {
267+ RetryDecision :: Abandon => {
268+ self . clear_intent ( id, has_payment) . await ;
269+ true
270+ } ,
271+ RetryDecision :: ResubmitStored => {
272+ // The same contribution remains valid; resubmit it. Skip if LDK already has a splice
273+ // in flight for this channel (e.g. the startup reconciler resubmitted first).
274+ if self . channel_manager . splice_channel ( & channel_id, & counterparty_node_id) . is_err ( )
275+ {
276+ return false ;
277+ }
278+ log_info ! (
279+ self . logger,
280+ "Resubmitting splice for channel {} with counterparty {} after a recoverable failure" ,
281+ channel_id,
282+ counterparty_node_id,
283+ ) ;
284+ let _ = self . submit ( id, & channel_id, & counterparty_node_id, intent) . await ;
285+ false
286+ } ,
287+ RetryDecision :: Rebuild => {
288+ // The stored contribution went stale; rebuild a fresh one from the original params.
289+ match self
290+ . rebuild_contribution ( & channel_id, & counterparty_node_id, & intent. kind )
291+ . await
292+ {
293+ Ok ( contribution) => {
294+ log_info ! (
295+ self . logger,
296+ "Resubmitting rebuilt splice for channel {} with counterparty {}" ,
297+ channel_id,
298+ counterparty_node_id,
299+ ) ;
300+ let mut intent = intent;
301+ intent. contribution = contribution;
302+ let _ = self . submit ( id, & channel_id, & counterparty_node_id, intent) . await ;
303+ false
304+ } ,
305+ Err ( e) => {
306+ log_error ! (
307+ self . logger,
308+ "Abandoning splice for channel {}: failed to rebuild contribution: {:?}" ,
309+ channel_id,
310+ e,
311+ ) ;
312+ self . clear_intent ( id, has_payment) . await ;
313+ true
314+ } ,
315+ }
316+ } ,
317+ }
318+ }
319+
320+ /// Clears any splice intent made obsolete by a newly locked funding transaction.
321+ pub ( crate ) async fn on_channel_ready (
322+ & self , user_channel_id : UserChannelId , funding_txo : Option < OutPoint > ,
323+ ) {
324+ let Some ( record) = self . record_for_channel ( user_channel_id) else {
325+ return ;
326+ } ;
327+ let id = record. id ( ) ;
328+ let has_payment = record. details ( ) . is_some ( ) ;
329+ let Some ( intent) = record. splice_intent ( ) else {
330+ return ;
331+ } ;
332+ // Only clear an intent that predates the locked funding. An intent whose pre-splice outpoint
333+ // still matches the newly locked funding was created after this lock and is still pending.
334+ let clear = match funding_txo {
335+ Some ( funding_txo) => {
336+ intent. pre_splice_funding_txo . into_bitcoin_outpoint ( ) != funding_txo
337+ } ,
338+ None => false ,
339+ } ;
340+ if clear {
341+ self . clear_intent ( id, has_payment) . await ;
342+ }
343+ }
344+
345+ /// Clears any splice intent for a closed channel, as there is nothing left to splice.
346+ pub ( crate ) async fn on_channel_closed ( & self , user_channel_id : UserChannelId ) {
347+ if let Some ( record) = self . record_for_channel ( user_channel_id) {
348+ self . clear_intent ( record. id ( ) , record. details ( ) . is_some ( ) ) . await ;
349+ }
350+ }
351+
352+ /// Returns the pending record carrying a splice intent for the given channel, if any.
353+ fn record_for_channel ( & self , user_channel_id : UserChannelId ) -> Option < PendingPaymentDetails > {
354+ self . pending_payment_store
355+ . list_filter ( |p| {
356+ p. splice_intent ( ) . is_some_and ( |i| i. user_channel_id == user_channel_id)
357+ } )
358+ . into_iter ( )
359+ . next ( )
360+ }
361+
362+ /// Builds a fresh contribution from the parameters of the originating API call, mirroring the
363+ /// corresponding [`Node`] method.
364+ ///
365+ /// [`Node`]: crate::Node
366+ async fn rebuild_contribution (
367+ & self , channel_id : & ChannelId , counterparty_node_id : & PublicKey , kind : & SpliceKind ,
368+ ) -> Result < FundingContribution , Error > {
369+ let template = self
370+ . channel_manager
371+ . splice_channel ( channel_id, counterparty_node_id)
372+ . map_err ( |_| Error :: ChannelSplicingFailed ) ?;
373+
374+ let est_feerate = self . fee_estimator . estimate_fee_rate ( ConfirmationTarget :: ChannelFunding ) ;
375+ let max_feerate = max_funding_feerate ( est_feerate) ;
376+ let feerate = match template. min_rbf_feerate ( ) {
377+ Some ( min_rbf_feerate) if min_rbf_feerate <= max_feerate => {
378+ est_feerate. max ( min_rbf_feerate)
379+ } ,
380+ _ => est_feerate,
381+ } ;
382+
383+ match kind {
384+ SpliceKind :: In { amount_sats } => template
385+ . splice_in (
386+ Amount :: from_sat ( * amount_sats) ,
387+ feerate,
388+ max_feerate,
389+ Arc :: clone ( & self . wallet ) ,
390+ )
391+ . await
392+ . map_err ( |_| Error :: ChannelSplicingFailed ) ,
393+ SpliceKind :: Out { outputs } => template
394+ . splice_out ( outputs. clone ( ) , feerate, max_feerate)
395+ . map_err ( |_| Error :: ChannelSplicingFailed ) ,
396+ SpliceKind :: Rbf { } => template
397+ . rbf_prior_contribution ( None , max_feerate, Arc :: clone ( & self . wallet ) )
398+ . await
399+ . map_err ( |_| Error :: ChannelSplicingFailed ) ,
400+ }
401+ }
402+ }
403+
404+ #[ cfg( test) ]
405+ mod tests {
406+ use super :: * ;
407+
408+ #[ test]
409+ fn decide_retry_matrix ( ) {
410+ use NegotiationFailureReason :: * ;
411+
412+ // A non-retriable reason gives up regardless of attempts.
413+ assert_eq ! ( decide_retry( & LocallyCanceled , 0 ) , RetryDecision :: Abandon ) ;
414+ // Retriable, but the resubmission budget is exhausted -> give up.
415+ assert_eq ! ( decide_retry( & PeerDisconnected , MAX_SPLICE_ATTEMPTS ) , RetryDecision :: Abandon ) ;
416+ // Transient failures resubmit the stored contribution.
417+ assert_eq ! ( decide_retry( & PeerDisconnected , 0 ) , RetryDecision :: ResubmitStored ) ;
418+ assert_eq ! ( decide_retry( & Unknown , MAX_SPLICE_ATTEMPTS - 1 ) , RetryDecision :: ResubmitStored ) ;
419+ // A stale contribution is rebuilt from the original parameters.
420+ assert_eq ! ( decide_retry( & FeeRateTooLow , 0 ) , RetryDecision :: Rebuild ) ;
421+ assert_eq ! ( decide_retry( & ContributionInvalid , 0 ) , RetryDecision :: Rebuild ) ;
422+ }
202423}
0 commit comments