Skip to content

Commit 725aa4e

Browse files
jkczyzclaude
andcommitted
Unlock lost splice inputs at startup
LDK only persists a splice once its negotiation reaches AwaitingSignatures, so a splice in flight when the node stops can leave no trace in LDK's channel state, and no event of LDK's ever returns what the wallet reserved for it — today the addresses its outputs pay; once #1037 locks a contribution's inputs in the wallet, those too, forever. At startup, reconcile each persisted splice intent against live channel state: release the reservations of a splice LDK no longer holds and drop its record, re-anchor a queued splice whose predecessor locked while the node was down, and keep — minus any inputs no surviving round still claims — those LDK resumes on its own. A splice whose channel closed meanwhile is released only if no round of it reached signing: a signed round is one the channel's monitor watches until the close matures, and what it reserved is spent by it or returned through DiscardFunding then. Reconciliation holds the lock that serializes splice submissions, as the event handlers settling intents do. Recovery fabricates no failure event for a splice lost this way: the initiating call already returned, and the channel simply no longer shows a pending splice. LDK itself reports the loss of a contribution it was still queueing or negotiating when it was last persisted — it fails the contribution as it is written and replays the failure at startup. The replay runs after reconciliation, so that report carries the splice's parameters only where reconciliation kept the intent: for a splice queued behind a pending one of ours, or a fee bump of one, but not for a channel's only splice, whose intent reconciliation settled. Reconciliation runs before background syncing and broadcasting start, so nothing can act on the stale reservations first. Events LDK replays from its last persisted state (e.g. a DiscardFunding for a splice that died before the node stopped) are likewise consumed before the node is running, so they cannot act on state a new user operation set up since. Developed with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 912b6ee commit 725aa4e

4 files changed

Lines changed: 309 additions & 20 deletions

File tree

‎src/channel/mod.rs‎

Lines changed: 258 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ use bitcoin::transaction::Version;
1717
use bitcoin::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid};
1818
use lightning::chain::chaininterface::FundingCandidate;
1919
use lightning::chain::transaction::OutPoint as LdkOutPoint;
20-
use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails};
20+
use lightning::ln::channel_state::{ChannelDetails, SpliceCandidateDetails, SpliceCandidateStatus};
2121
use lightning::ln::channelmanager::PaymentId;
2222
use lightning::ln::funding::FundingContribution;
2323
use lightning::ln::types::ChannelId;
2424

2525
use crate::data_store::StorableObject;
26-
use crate::logger::{log_error, LdkLogger, Logger};
26+
use crate::logger::{log_error, log_info, LdkLogger, Logger};
2727
use crate::payment::pending_payment_store::{
2828
PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind,
2929
};
@@ -56,21 +56,23 @@ pub(crate) fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -
5656
///
5757
/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the
5858
/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its
59-
/// channel closes. The record exists for recovery, not retry: a splice still recorded at the
60-
/// next startup identifies one that was in flight when the node stopped, so anything it reserved
61-
/// can be released, and events about the splice can be described in terms of the original
62-
/// request. Each splice has a record of its own — a channel may carry several, a pending splice
63-
/// and the splices queued behind it — so that each is recognized and described whatever became
64-
/// of the others; only a fee bump joins the record of the round it replaces.
59+
/// channel closes. The record exists for recovery, not retry: a splice still recorded at the next
60+
/// startup identifies one that was in flight when the node stopped, so [`Self::reconcile`] can
61+
/// release what it still reserves where nothing else will, and events about the splice can be
62+
/// described in terms of the original request. Each splice has a record of its own — a channel may
63+
/// carry several, a pending splice and the splices queued behind it — so that each is recognized
64+
/// and described whatever became of the others; only a fee bump joins the record of the round it
65+
/// replaces.
6566
pub(crate) struct SpliceTracker {
6667
channel_manager: Arc<ChannelManager>,
6768
wallet: Arc<Wallet>,
6869
pending_payment_store: Arc<PendingPaymentStore>,
6970
/// Serializes everything that reads or settles a channel's intent records against
7071
/// [`Self::submit`]'s read-funding, persist and hand-off sequence: the settling of intents by
7172
/// [`Self::on_negotiation_failed`], [`Self::on_channel_ready`] and
72-
/// [`Self::on_channel_closed`], and the funding record [`Self::on_funding_ready_for_signing`]
73-
/// files under an intent's id. Without it, the failure event of a synchronously rejected
73+
/// [`Self::on_channel_closed`], the funding record [`Self::on_funding_ready_for_signing`]
74+
/// files under an intent's id, and the startup pass of [`Self::reconcile`]. Without it, the
75+
/// failure event of a synchronously rejected
7476
/// hand-off could settle the just-written intent while `submit` is still deciding whether to
7577
/// keep it, and a lock event handled between `submit`'s funding read and its persist could
7678
/// leave the new intent anchored at a funding the channel has moved past, which nothing would
@@ -96,6 +98,127 @@ impl SpliceTracker {
9698
}
9799
}
98100

101+
/// Reconciles the persisted splice intents against live channel state, releasing whatever the
102+
/// wallet still holds for a splice that did not survive the restart and nothing else will
103+
/// release. LDK only persists a splice once its negotiation reaches `AwaitingSignatures`, so a
104+
/// splice lost earlier leaves no trace in LDK's channel state — the intent record is what
105+
/// recognizes the loss. A round LDK did write is another matter: LDK either still holds it, or
106+
/// failed it as it was last written — the failure is replayed at startup — and returns what it
107+
/// reserved through `DiscardFunding`; a round of a channel that closed meanwhile is watched by
108+
/// the channel's monitor until the close matures. Such rounds are left to those events. Run
109+
/// once at startup, before background chain syncing and event processing start, so nothing can
110+
/// act on the stale reservations first. Holds the submit lock throughout, as the event handlers
111+
/// do.
112+
///
113+
/// Recovery fabricates no failure event for a splice lost this way: the initiating call
114+
/// already returned and the channel simply shows no pending splice anymore. LDK itself may
115+
/// report the loss — a contribution it was still queueing or negotiating when it was last
116+
/// persisted is failed as it is written, and the failure replayed at startup. That replay
117+
/// runs after this reconciliation, so the report carries the splice's parameters only if
118+
/// `decide_reconcile` kept the intent: a splice queued behind a pending one of ours, or a fee
119+
/// bump of one, is reported with its parameters; a channel's only splice, whose intent
120+
/// settled here, without them.
121+
pub(crate) async fn reconcile(&self) {
122+
let guard = self.submit_lock.lock().await;
123+
let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some()).await;
124+
for record in records {
125+
let payment_id = record.id();
126+
let Some(intent) = record.splice_intent().cloned() else {
127+
continue;
128+
};
129+
130+
let channel = self.channel(intent.counterparty_node_id, intent.channel_id);
131+
let Some(channel) = channel else {
132+
// The channel is gone; there is nothing to splice anymore. What the wallet holds
133+
// for the intent is released only while no recorded round exists: a round the
134+
// closed channel's monitor watches is either spent by the close or returned through
135+
// the `DiscardFunding` event the monitor queues once the close matures, and a
136+
// recorded round the monitor never watched — the counterparty's `commitment_signed`
137+
// never arrived before the node stopped — is released by neither, as at
138+
// `ChannelClosed`. A bare intent has no such round — LDK never wrote the splice —
139+
// so nothing else would release it.
140+
log_info!(
141+
self.logger,
142+
"Dropping the recorded splice of closed channel {} with counterparty {}",
143+
intent.channel_id,
144+
intent.counterparty_node_id,
145+
);
146+
if record.candidates().is_empty() {
147+
self.release_contribution(intent.channel_id, &intent.contribution, &[], None)
148+
.await;
149+
}
150+
// TODO(#1037): once inputs are locked at coin selection, the parts of the
151+
// contribution no recorded round uses stay locked with no record to release them
152+
// from after the intent is cleared here: release them before clearing. And
153+
// `release_contribution` swallows a failed release, which then leaves locks no
154+
// record names either: keep the intent when the release fails. The same holds for a
155+
// recorded round the monitor never watched: nothing releases its inputs once the
156+
// intent is cleared here.
157+
self.clear_persisted_intent(payment_id, |i| *i == intent).await;
158+
continue;
159+
};
160+
161+
if channel.funding_txo != Some(intent.pre_splice_funding_txo) {
162+
// The funding moved on while the node was down: the recorded splice, a
163+
// replacement, or a counterparty splice locked — the same situation a live lock
164+
// event resolves, so resolve it the same way.
165+
if let Some(funding_txo) = channel.funding_txo {
166+
self.settle_superseded_intents_locked(
167+
&guard,
168+
intent.counterparty_node_id,
169+
intent.channel_id,
170+
funding_txo.into_bitcoin_outpoint(),
171+
Some(&channel),
172+
)
173+
.await;
174+
}
175+
continue;
176+
}
177+
178+
let candidates = channel
179+
.splice_details
180+
.as_ref()
181+
.map(|details| details.candidates.as_slice())
182+
.unwrap_or(&[]);
183+
match decide_reconcile(candidates) {
184+
ReconcileDecision::Keep => {
185+
// A kept record may still reserve more than LDK's surviving rounds use —
186+
// extras a fee bump lost with the restart had reserved. Release the
187+
// difference.
188+
let extras = unclaimed_inputs(&intent.contribution, candidates);
189+
if let Err(e) = self.wallet.unlock_outpoints(&extras).await {
190+
log_error!(
191+
self.logger,
192+
"Failed to release unused splice inputs on channel {}: {}",
193+
intent.channel_id,
194+
e,
195+
);
196+
}
197+
},
198+
ReconcileDecision::Lost => {
199+
log_info!(
200+
self.logger,
201+
"Dropping a splice on channel {} with counterparty {} that did not survive \
202+
the restart",
203+
intent.channel_id,
204+
intent.counterparty_node_id,
205+
);
206+
self.release_contribution(
207+
intent.channel_id,
208+
&intent.contribution,
209+
candidates,
210+
None,
211+
)
212+
.await;
213+
// TODO(#1037): `release_contribution` swallows a failed release. Once inputs
214+
// are locked at coin selection, a failure here leaves locks no record names
215+
// after the intent is cleared: keep the intent when the release fails.
216+
self.clear_persisted_intent(payment_id, |i| *i == intent).await;
217+
},
218+
}
219+
}
220+
}
221+
99222
/// Persists a user-initiated splice as an intent and hands its contribution to
100223
/// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the
101224
/// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a
@@ -478,8 +601,10 @@ impl SpliceTracker {
478601
}
479602

480603
/// Settles any persisted intent made obsolete by the channel's funding having moved on to
481-
/// `funding_txo`: the funding a `ChannelReady` event reports as locked, or the one a new
482-
/// splice builds on ([`Self::submit`]). Each of the channel's intents is decided on its own
604+
/// `funding_txo`: the funding a `ChannelReady` event reports as locked, the one a new splice
605+
/// builds on ([`Self::submit`]), or the one [`Self::reconcile`] finds the channel at after a
606+
/// funding moved while the node was down — the same situation, minus the event. Each of the
607+
/// channel's intents is decided on its own
483608
/// ([`decide_on_lock`]), against the splice candidates LDK holds for the channel (`channel`,
484609
/// as the caller listed it): one whose pre-splice outpoint is that funding was created after
485610
/// the lock and stays; one LDK still holds as a queued splice candidate is re-anchored to the
@@ -890,6 +1015,52 @@ fn record_with_intent_cleared(existing: &PendingPaymentDetails) -> Option<Pendin
8901015
}
8911016
}
8921017

1018+
/// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding
1019+
/// are unchanged, decided from the splice rounds LDK reports on the channel.
1020+
#[derive(Debug, PartialEq, Eq)]
1021+
enum ReconcileDecision {
1022+
/// LDK still holds a splice of ours; leave the intent in place until the splice settles.
1023+
Keep,
1024+
/// LDK holds no splice of ours: the recorded splice died with the restart, so whatever was
1025+
/// reserved for it is released and the intent dropped.
1026+
Lost,
1027+
}
1028+
1029+
/// Decides the startup action for a persisted intent from the channel's [`SpliceDetails`]
1030+
/// candidates.
1031+
///
1032+
/// [`SpliceDetails`]: lightning::ln::channel_state::SpliceDetails
1033+
fn decide_reconcile(candidates: &[SpliceCandidateDetails]) -> ReconcileDecision {
1034+
// A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures`
1035+
// survives a restart, and LDK resumes the signature exchange itself on reconnect.
1036+
let in_flight = candidates
1037+
.iter()
1038+
.any(|candidate| !matches!(candidate.status, SpliceCandidateStatus::Negotiated { .. }));
1039+
if in_flight {
1040+
return ReconcileDecision::Keep;
1041+
}
1042+
1043+
// LDK persists a splice once negotiated, so a negotiated candidate carrying a local
1044+
// contribution is a splice of ours LDK sees through to lock — even one negotiated at a
1045+
// different feerate than a recorded fee bump asked for. Without one, only counterparty
1046+
// rounds (or nothing) survived: the recorded splice is gone.
1047+
if candidates.iter().any(|candidate| candidate.contribution.is_some()) {
1048+
ReconcileDecision::Keep
1049+
} else {
1050+
ReconcileDecision::Lost
1051+
}
1052+
}
1053+
1054+
/// The inputs `contribution` reserved that no candidate's own contribution still claims — extras
1055+
/// a splice attempt lost with the restart had reserved. A counterparty-only round carries no
1056+
/// contribution and claims nothing.
1057+
fn unclaimed_inputs(
1058+
contribution: &FundingContribution, candidates: &[SpliceCandidateDetails],
1059+
) -> Vec<OutPoint> {
1060+
let claimants = candidates.iter().filter_map(|candidate| candidate.contribution.as_ref());
1061+
unclaimed_parts(contribution, claimants).0
1062+
}
1063+
8931064
#[cfg(test)]
8941065
mod tests {
8951066
use std::str::FromStr;
@@ -900,8 +1071,8 @@ mod tests {
9001071
use super::*;
9011072
use crate::payment::pending_payment_store::{
9021073
test_funding_contribution, test_funding_contribution_with_feerate,
903-
test_funding_contribution_with_outputs, test_funding_contribution_with_parts,
904-
FundingTxCandidate,
1074+
test_funding_contribution_with_inputs, test_funding_contribution_with_outputs,
1075+
test_funding_contribution_with_parts, FundingTxCandidate,
9051076
};
9061077
use crate::payment::store::{ConfirmationStatus, PaymentDetails, PaymentKind};
9071078
use crate::payment::{PaymentDirection, PaymentStatus};
@@ -1333,4 +1504,77 @@ mod tests {
13331504
(prevtxs.iter().map(outpoint).collect(), vec![splice_out, change(20_000)])
13341505
);
13351506
}
1507+
1508+
/// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays
1509+
/// in place until the splice settles.
1510+
#[test]
1511+
fn reconcile_keeps_the_intent_while_ldk_drives_a_round() {
1512+
let in_flight = SpliceCandidateDetails {
1513+
contribution: Some(test_funding_contribution()),
1514+
status: SpliceCandidateStatus::AwaitingSignatures {
1515+
is_initiator: true,
1516+
funding_feerate_sat_per_1000_weight: 253,
1517+
new_channel_value_satoshis: 100_000,
1518+
txid: Txid::from_byte_array([9u8; 32]),
1519+
},
1520+
};
1521+
assert_eq!(decide_reconcile(&[in_flight]), ReconcileDecision::Keep);
1522+
}
1523+
1524+
/// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock;
1525+
/// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding
1526+
/// outpoint has not moved on yet.
1527+
#[test]
1528+
fn reconcile_trusts_a_negotiated_contribution() {
1529+
let negotiated = [negotiated_candidate(Some(test_funding_contribution()))];
1530+
assert_eq!(decide_reconcile(&negotiated), ReconcileDecision::Keep);
1531+
}
1532+
1533+
/// A fee bump that only survives as a candidate negotiated at a lower feerate than requested
1534+
/// is not lost: the recorded bump is moot, but the splice lives on and locks. The old
1535+
/// higher-feerate attempt's extra reservations are released through the input difference, not
1536+
/// by dropping the record.
1537+
#[test]
1538+
fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate() {
1539+
let lower = [negotiated_candidate(Some(test_funding_contribution_with_feerate(253)))];
1540+
assert_eq!(decide_reconcile(&lower), ReconcileDecision::Keep);
1541+
}
1542+
1543+
/// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the
1544+
/// recorded splice died with the restart.
1545+
#[test]
1546+
fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution() {
1547+
assert_eq!(decide_reconcile(&[]), ReconcileDecision::Lost);
1548+
let counterparty_only = [negotiated_candidate(None)];
1549+
assert_eq!(decide_reconcile(&counterparty_only), ReconcileDecision::Lost);
1550+
}
1551+
1552+
/// The inputs a kept record reserves beyond what LDK's candidates still claim are identified
1553+
/// for release; a counterparty-only round claims nothing and must not suppress the
1554+
/// difference.
1555+
#[test]
1556+
fn unclaimed_inputs_are_those_no_candidate_contribution_uses() {
1557+
let prevtxs: Vec<Transaction> = (1u8..=3).map(test_prevtx).collect();
1558+
let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 };
1559+
let recorded = test_funding_contribution_with_inputs(253, &prevtxs);
1560+
1561+
// Every input still claimed by a surviving candidate: nothing to release.
1562+
let all =
1563+
[negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs)))];
1564+
assert!(unclaimed_inputs(&recorded, &all).is_empty());
1565+
1566+
// A candidate claiming two of the three inputs: the third is released, even with a
1567+
// counterparty-only round alongside.
1568+
let partial = [
1569+
negotiated_candidate(None),
1570+
negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs[..2]))),
1571+
];
1572+
assert_eq!(unclaimed_inputs(&recorded, &partial), vec![outpoint(&prevtxs[2])]);
1573+
1574+
// No candidates at all: everything is released.
1575+
assert_eq!(
1576+
unclaimed_inputs(&recorded, &[]),
1577+
prevtxs.iter().map(outpoint).collect::<Vec<_>>()
1578+
);
1579+
}
13361580
}

0 commit comments

Comments
 (0)