Skip to content

Commit d3bbdc8

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 1653bed commit d3bbdc8

3 files changed

Lines changed: 258 additions & 8 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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum};
3434
use lightning_liquidity::lsps2::utils::compute_opening_fee;
3535
use lightning_types::payment::{PaymentHash, PaymentPreimage};
3636

37+
use crate::channel::SpliceRetrier;
3738
use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL};
3839
use crate::connection::ConnectionManager;
3940
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,
@@ -541,6 +546,7 @@ where
541546
onion_messenger: Arc<OnionMessenger>,
542547
om_mailbox: Option<Arc<OnionMessageMailbox>>,
543548
prober: Option<Arc<Prober>>,
549+
splice_retrier: Arc<SpliceRetrier<L>>,
544550
runtime: Arc<Runtime>,
545551
logger: L,
546552
config: Arc<Config>,
@@ -559,7 +565,8 @@ where
559565
peer_store: Arc<PeerStore<L>>, keys_manager: Arc<KeysManager>,
560566
static_invoice_store: Option<StaticInvoiceStore>, onion_messenger: Arc<OnionMessenger>,
561567
om_mailbox: Option<Arc<OnionMessageMailbox>>, prober: Option<Arc<Prober>>,
562-
runtime: Arc<Runtime>, logger: L, config: Arc<Config>,
568+
splice_retrier: Arc<SpliceRetrier<L>>, runtime: Arc<Runtime>, logger: L,
569+
config: Arc<Config>,
563570
) -> Self {
564571
Self {
565572
event_queue,
@@ -577,6 +584,7 @@ where
577584
onion_messenger,
578585
om_mailbox,
579586
prober,
587+
splice_retrier,
580588
runtime,
581589
logger,
582590
config,
@@ -1649,6 +1657,10 @@ where
16491657
.handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id)
16501658
.await;
16511659

1660+
self.splice_retrier
1661+
.on_channel_ready(UserChannelId(user_channel_id), funding_txo)
1662+
.await;
1663+
16521664
let event = Event::ChannelReady {
16531665
channel_id,
16541666
user_channel_id: UserChannelId(user_channel_id),
@@ -1672,6 +1684,8 @@ where
16721684
} => {
16731685
log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason);
16741686

1687+
self.splice_retrier.on_channel_closed(UserChannelId(user_channel_id)).await;
1688+
16751689
// `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117.
16761690
let counterparty_node_id = counterparty_node_id
16771691
.expect("counterparty_node_id is always set since LDK 0.0.117");
@@ -1983,15 +1997,27 @@ where
19831997
channel_id,
19841998
user_channel_id,
19851999
counterparty_node_id,
1986-
..
2000+
reason,
2001+
contribution,
19872002
} => {
19882003
log_info!(
19892004
self.logger,
1990-
"Channel {} with counterparty {} splice negotiation failed",
2005+
"Channel {} with counterparty {} splice negotiation failed: {}",
19912006
channel_id,
19922007
counterparty_node_id,
2008+
reason,
19932009
);
19942010

2011+
// A user-initiated splice is retried automatically, including across restarts;
2012+
// surface the failure only once it is given up on.
2013+
let surface = self
2014+
.splice_retrier
2015+
.on_negotiation_failed(UserChannelId(user_channel_id), reason, contribution)
2016+
.await;
2017+
if !surface {
2018+
return Ok(());
2019+
}
2020+
19952021
let event = Event::SpliceNegotiationFailed {
19962022
channel_id,
19972023
user_channel_id: UserChannelId(user_channel_id),

src/lib.rs

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

662662
let splice_retrier = Arc::new(SpliceRetrier::new(
663663
Arc::clone(&self.channel_manager),
664+
Arc::clone(&self.wallet),
665+
Arc::clone(&self.fee_estimator),
664666
Arc::clone(&self.pending_payment_store),
665667
Arc::clone(&self.event_queue),
666668
Arc::clone(&self.logger),
@@ -682,6 +684,7 @@ impl Node {
682684
Arc::clone(&self.onion_messenger),
683685
self.om_mailbox.clone(),
684686
self.prober.clone(),
687+
Arc::clone(&splice_retrier),
685688
Arc::clone(&self.runtime),
686689
Arc::clone(&self.logger),
687690
Arc::clone(&self.config),

0 commit comments

Comments
 (0)