Skip to content

Commit 1653bed

Browse files
jkczyzclaude
andcommitted
Persist and resubmit user-initiated splices across restarts
LDK abandons an in-progress splice negotiation whenever the peer disconnects -- which includes stopping the node -- and only durably records a splice once its negotiation reaches signing. A splice dropped before then, after splice_in, splice_out, or bump_channel_funding_fee returned Ok, is therefore silently lost across a restart or an ill-timed disconnect. Persist a splice intent before handing the contribution to LDK, keyed by a PaymentId generated at splice time and reusing the channel's existing intent record when one is present, so a splice and its fee bumps share one id and at most one intent exists per channel. At startup a reconciler probes each intent against LDK's live channel state and resubmits any LDK dropped -- including those lost to a crash before LDK persisted anything -- surfacing SpliceNegotiationFailed only when the channel is gone, a fee bump has nothing left to replace, or the resubmission budget is exhausted. Resubmitting does not require the peer to be connected: LDK holds the contribution and initiates quiescence on reconnect. Wallet sync can see the splice transaction before the broadcast-time classification records it -- the counterparty broadcasts it too -- but a pre-broadcast intent record carries no txids for the usual lookup to match. Teach sync to recognize such a transaction by the funding outpoint it spends and adopt the splice-time id, so both writers converge on one record instead of sync minting a txid-derived duplicate. A payment-tracking merge (e.g. from wallet sync) must leave a live intent untouched. The splice tests now locate a funding payment by its candidate txid, since its id is generated rather than derived from the funding txid. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f6bcf4f commit 1653bed

6 files changed

Lines changed: 406 additions & 27 deletions

File tree

src/builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2333,6 +2333,7 @@ fn build_with_store_internal(
23332333
scorer,
23342334
peer_store,
23352335
payment_store,
2336+
pending_payment_store,
23362337
lnurl_auth,
23372338
is_running,
23382339
node_metrics,

src/channel/mod.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// This file is Copyright its original authors, visible in version control history.
2+
//
3+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
6+
// accordance with one or both of these licenses.
7+
8+
//! Retrying user-initiated splices that LDK dropped before durably recording them.
9+
10+
use std::ops::Deref;
11+
use std::sync::Arc;
12+
13+
use bitcoin::secp256k1::PublicKey;
14+
use lightning::ln::channelmanager::PaymentId;
15+
use lightning::ln::types::ChannelId;
16+
17+
use crate::data_store::StorableObject;
18+
use crate::event::{Event, EventQueue};
19+
use crate::logger::{log_error, log_info, LdkLogger};
20+
use crate::payment::pending_payment_store::{
21+
PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind, MAX_SPLICE_ATTEMPTS,
22+
};
23+
use crate::types::{ChannelManager, PendingPaymentStore};
24+
use crate::Error;
25+
26+
/// Resubmits user-initiated splices that LDK dropped before durably recording them.
27+
///
28+
/// LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, and it abandons an
29+
/// earlier negotiation whenever the peer disconnects (which includes restarting the node). The
30+
/// splice entry points persist a [`SpliceIntent`] before handing the contribution to LDK; this type
31+
/// drives that intent back into [`ChannelManager::funding_contributed`] until the splice either
32+
/// locks (clearing the intent) or fails for a reason retrying cannot address.
33+
///
34+
/// Resubmitting does not require the peer to be connected: LDK holds on to the contribution and
35+
/// initiates quiescence once the peer reconnects.
36+
///
37+
/// [`ChannelManager::funding_contributed`]: lightning::ln::channelmanager::ChannelManager::funding_contributed
38+
pub(crate) struct SpliceRetrier<L: Deref + Clone>
39+
where
40+
L::Target: LdkLogger,
41+
{
42+
channel_manager: Arc<ChannelManager>,
43+
pending_payment_store: Arc<PendingPaymentStore>,
44+
event_queue: Arc<EventQueue<L>>,
45+
logger: L,
46+
}
47+
48+
impl<L: Deref + Clone> SpliceRetrier<L>
49+
where
50+
L::Target: LdkLogger,
51+
{
52+
pub(crate) fn new(
53+
channel_manager: Arc<ChannelManager>, pending_payment_store: Arc<PendingPaymentStore>,
54+
event_queue: Arc<EventQueue<L>>, logger: L,
55+
) -> Self {
56+
Self { channel_manager, pending_payment_store, event_queue, logger }
57+
}
58+
59+
/// Reconciles persisted splice intents against live channel state. Run once at startup to pick
60+
/// up splices LDK dropped before durably recording them — including those lost to a crash before
61+
/// LDK persisted anything.
62+
pub(crate) async fn reconcile(&self) {
63+
let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some());
64+
for record in records {
65+
let id = record.id();
66+
let has_payment = record.details().is_some();
67+
let Some(intent) = record.splice_intent().cloned() else {
68+
continue;
69+
};
70+
71+
let channel = self
72+
.channel_manager
73+
.list_channels_with_counterparty(&intent.counterparty_node_id)
74+
.into_iter()
75+
.find(|c| c.user_channel_id == intent.user_channel_id.0);
76+
let channel = match channel {
77+
Some(channel) => channel,
78+
None => {
79+
// The channel is gone; there is nothing to splice anymore.
80+
self.clear_intent(id, has_payment).await;
81+
continue;
82+
},
83+
};
84+
85+
if channel.funding_txo != Some(intent.pre_splice_funding_txo) {
86+
// The funding moved on, so the splice (or a replacement) locked.
87+
self.clear_intent(id, has_payment).await;
88+
continue;
89+
}
90+
91+
// `splice_channel` is a read-only probe of LDK's splice state. It fails when we already
92+
// have a splice in flight (a held contribution, an in-progress negotiation, or one
93+
// awaiting signatures), all of which LDK drives to completion on its own.
94+
let template = match self
95+
.channel_manager
96+
.splice_channel(&channel.channel_id, &intent.counterparty_node_id)
97+
{
98+
Ok(template) => template,
99+
Err(_) => continue,
100+
};
101+
102+
// LDK persists a splice once negotiated, so a prior contribution means the intent was
103+
// carried out — unless the intent was a fee bump at a higher feerate than negotiated.
104+
let should_retry = match (&intent.kind, template.prior_contribution()) {
105+
(SpliceKind::Rbf {}, Some(prior)) => {
106+
prior.feerate() < intent.contribution.feerate()
107+
},
108+
(SpliceKind::Rbf {}, None) => {
109+
// The splice to bump is gone entirely; surface rather than guess.
110+
self.abandon(id, has_payment, &intent).await;
111+
continue;
112+
},
113+
(_, Some(_)) => false,
114+
(_, None) => true,
115+
};
116+
if !should_retry {
117+
continue;
118+
}
119+
120+
if intent.attempts >= MAX_SPLICE_ATTEMPTS {
121+
self.abandon(id, has_payment, &intent).await;
122+
continue;
123+
}
124+
125+
log_info!(
126+
self.logger,
127+
"Resubmitting splice for channel {} with counterparty {}",
128+
channel.channel_id,
129+
intent.counterparty_node_id,
130+
);
131+
let counterparty_node_id = intent.counterparty_node_id;
132+
let _ = self.submit(id, &channel.channel_id, &counterparty_node_id, intent).await;
133+
}
134+
}
135+
136+
/// Persists the incremented attempt count and hands the contribution back to LDK. The count is
137+
/// persisted first so that a crash mid-submission cannot lead to unbounded retries.
138+
async fn submit(
139+
&self, id: PaymentId, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
140+
mut intent: SpliceIntent,
141+
) -> Result<(), Error> {
142+
intent.attempts += 1;
143+
let contribution = intent.contribution.clone();
144+
let update = PendingPaymentDetailsUpdate {
145+
id,
146+
payment_update: None,
147+
conflicting_txids: None,
148+
candidates: Vec::new(),
149+
splice_intent: Some(Some(intent)),
150+
};
151+
self.pending_payment_store.update(update).await?;
152+
153+
self.channel_manager
154+
.funding_contributed(channel_id, counterparty_node_id, contribution, None)
155+
.map_err(|e| {
156+
log_error!(
157+
self.logger,
158+
"Failed to resubmit splice for channel {} with counterparty {}: {:?}",
159+
channel_id,
160+
counterparty_node_id,
161+
e,
162+
);
163+
Error::ChannelSplicingFailed
164+
})
165+
}
166+
167+
/// Drops a splice intent: removes a pre-broadcast record entirely, or clears just the intent on
168+
/// a record that already carries a classified funding payment so the payment keeps graduating.
169+
async fn clear_intent(&self, id: PaymentId, has_payment: bool) {
170+
if has_payment {
171+
let update = PendingPaymentDetailsUpdate {
172+
id,
173+
payment_update: None,
174+
conflicting_txids: None,
175+
candidates: Vec::new(),
176+
splice_intent: Some(None),
177+
};
178+
let _ = self.pending_payment_store.update(update).await;
179+
} else {
180+
let _ = self.pending_payment_store.remove(&id).await;
181+
}
182+
}
183+
184+
/// Gives up on a splice intent and surfaces the failure to the user.
185+
async fn abandon(&self, id: PaymentId, has_payment: bool, intent: &SpliceIntent) {
186+
log_error!(
187+
self.logger,
188+
"Abandoning splice for channel {} with counterparty {}",
189+
intent.channel_id,
190+
intent.counterparty_node_id,
191+
);
192+
self.clear_intent(id, has_payment).await;
193+
let event = Event::SpliceNegotiationFailed {
194+
channel_id: intent.channel_id,
195+
user_channel_id: intent.user_channel_id,
196+
counterparty_node_id: intent.counterparty_node_id,
197+
};
198+
if let Err(e) = self.event_queue.add_event(event).await {
199+
log_error!(self.logger, "Failed to push to event queue: {}", e);
200+
}
201+
}
202+
}

0 commit comments

Comments
 (0)