Skip to content

Commit 71d689d

Browse files
jkczyzclaude
andcommitted
Retry recoverable splice failures and emit only final give-up
A user-initiated splice can fail mid-negotiation while the node is running -- the peer disconnects, or the contribution goes stale behind a competing negotiation -- and LDK reports each such round via SpliceNegotiationFailed. Drive those events through the splice retrier: resubmit the same contribution when the peer merely disconnected, rebuild a fresh one when it went stale, and give up (surfacing the failure) only for a non-retriable reason or once the resubmission budget is exhausted, using LDK's own is_retriable classification. Clear a splice's intent once the channel locks its new funding or the channel closes. Event::SpliceNegotiationFailed is now emitted only when a splice is finally abandoned, not for every failed negotiation round, since a recoverable failure is retried transparently. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5cb36b commit 71d689d

3 files changed

Lines changed: 258 additions & 9 deletions

File tree

src/channel/mod.rs

Lines changed: 225 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,54 @@ use std::ops::Deref;
1111
use std::sync::Arc;
1212

1313
use bitcoin::secp256k1::PublicKey;
14+
use bitcoin::{Amount, OutPoint};
15+
use lightning::events::NegotiationFailureReason;
1416
use lightning::ln::channelmanager::PaymentId;
17+
use lightning::ln::funding::FundingContribution;
1518
use lightning::ln::types::ChannelId;
1619

1720
use crate::data_store::StorableObject;
1821
use crate::event::{Event, EventQueue};
22+
use crate::fee_estimator::{
23+
max_funding_feerate, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator,
24+
};
1925
use crate::logger::{log_error, log_info, LdkLogger};
2026
use 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};
2431
use 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
@@ -40,6 +76,8 @@ where
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
}

src/event.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum};
3333
use lightning_liquidity::lsps2::utils::compute_opening_fee;
3434
use lightning_types::payment::{PaymentHash, PaymentPreimage};
3535

36+
use crate::channel::SpliceRetrier;
3637
use crate::config::{may_announce_channel, Config};
3738
use crate::connection::ConnectionManager;
3839
use crate::data_store::DataStoreUpdateResult;
@@ -283,7 +284,11 @@ pub enum Event {
283284
/// The outpoint of the channel's splice funding transaction.
284285
new_funding_txo: OutPoint,
285286
},
286-
/// A channel splice negotiation round has failed.
287+
/// A channel splice has failed and is no longer being pursued.
288+
///
289+
/// A recoverable failure of a user-initiated splice (e.g. the peer disconnecting
290+
/// mid-negotiation) is retried automatically, including across restarts; this event is emitted
291+
/// only once the splice is given up on.
287292
SpliceNegotiationFailed {
288293
/// The `channel_id` of the channel.
289294
channel_id: ChannelId,
@@ -543,6 +548,7 @@ where
543548
static_invoice_store: Option<StaticInvoiceStore>,
544549
onion_messenger: Arc<OnionMessenger>,
545550
om_mailbox: Option<Arc<OnionMessageMailbox>>,
551+
splice_retrier: Arc<SpliceRetrier<L>>,
546552
}
547553

548554
impl<L: Deref + Clone + Sync + Send + 'static> EventHandler<L>
@@ -557,8 +563,8 @@ where
557563
liquidity_source: Arc<LiquiditySource<Arc<Logger>>>, payment_store: Arc<PaymentStore>,
558564
peer_store: Arc<PeerStore<L>>, keys_manager: Arc<KeysManager>,
559565
static_invoice_store: Option<StaticInvoiceStore>, onion_messenger: Arc<OnionMessenger>,
560-
om_mailbox: Option<Arc<OnionMessageMailbox>>, runtime: Arc<Runtime>, logger: L,
561-
config: Arc<Config>,
566+
om_mailbox: Option<Arc<OnionMessageMailbox>>, splice_retrier: Arc<SpliceRetrier<L>>,
567+
runtime: Arc<Runtime>, logger: L, config: Arc<Config>,
562568
) -> Self {
563569
Self {
564570
event_queue,
@@ -578,6 +584,7 @@ where
578584
static_invoice_store,
579585
onion_messenger,
580586
om_mailbox,
587+
splice_retrier,
581588
}
582589
}
583590

@@ -1590,6 +1597,10 @@ where
15901597
.handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id)
15911598
.await;
15921599

1600+
self.splice_retrier
1601+
.on_channel_ready(UserChannelId(user_channel_id), funding_txo)
1602+
.await;
1603+
15931604
let event = Event::ChannelReady {
15941605
channel_id,
15951606
user_channel_id: UserChannelId(user_channel_id),
@@ -1613,6 +1624,8 @@ where
16131624
} => {
16141625
log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason);
16151626

1627+
self.splice_retrier.on_channel_closed(UserChannelId(user_channel_id)).await;
1628+
16161629
let event = Event::ChannelClosed {
16171630
channel_id,
16181631
user_channel_id: UserChannelId(user_channel_id),
@@ -1881,15 +1894,27 @@ where
18811894
channel_id,
18821895
user_channel_id,
18831896
counterparty_node_id,
1884-
..
1897+
reason,
1898+
contribution,
18851899
} => {
18861900
log_info!(
18871901
self.logger,
1888-
"Channel {} with counterparty {} splice negotiation failed",
1902+
"Channel {} with counterparty {} splice negotiation failed: {}",
18891903
channel_id,
18901904
counterparty_node_id,
1905+
reason,
18911906
);
18921907

1908+
// A user-initiated splice is retried automatically, including across restarts;
1909+
// surface the failure only once it is given up on.
1910+
let surface = self
1911+
.splice_retrier
1912+
.on_negotiation_failed(UserChannelId(user_channel_id), reason, contribution)
1913+
.await;
1914+
if !surface {
1915+
return Ok(());
1916+
}
1917+
18931918
let event = Event::SpliceNegotiationFailed {
18941919
channel_id,
18951920
user_channel_id: UserChannelId(user_channel_id),

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,8 @@ impl Node {
605605

606606
let splice_retrier = Arc::new(SpliceRetrier::new(
607607
Arc::clone(&self.channel_manager),
608+
Arc::clone(&self.wallet),
609+
Arc::clone(&self.fee_estimator),
608610
Arc::clone(&self.pending_payment_store),
609611
Arc::clone(&self.event_queue),
610612
Arc::clone(&self.logger),
@@ -625,6 +627,7 @@ impl Node {
625627
static_invoice_store,
626628
Arc::clone(&self.onion_messenger),
627629
self.om_mailbox.clone(),
630+
Arc::clone(&splice_retrier),
628631
Arc::clone(&self.runtime),
629632
Arc::clone(&self.logger),
630633
Arc::clone(&self.config),

0 commit comments

Comments
 (0)