Skip to content

Commit a5d4641

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 — and, once splice contributions lock wallet inputs, whatever the wallet reserved for such a splice would stay reserved forever. At startup, reconcile each persisted splice intent against live channel state: release the reservations of any 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. Recovery is silent: the initiating call already returned, and the channel simply no longer shows a pending splice, so no failure event is fabricated for a splice lost this way. 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>
1 parent 934336c commit a5d4641

4 files changed

Lines changed: 340 additions & 10 deletions

File tree

‎src/channel/mod.rs‎

Lines changed: 245 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ use bitcoin::secp256k1::PublicKey;
1515
use bitcoin::transaction::Version;
1616
use bitcoin::{OutPoint, Transaction, TxIn};
1717
use lightning::chain::transaction::OutPoint as LdkOutPoint;
18+
use lightning::ln::channel_state::{SpliceCandidateDetails, SpliceCandidateStatus};
1819
use lightning::ln::channelmanager::PaymentId;
1920
use lightning::ln::funding::FundingContribution;
2021
use lightning::ln::types::ChannelId;
2122

2223
use crate::data_store::StorableObject;
23-
use crate::logger::{log_error, LdkLogger, Logger};
24+
use crate::logger::{log_error, log_info, LdkLogger, Logger};
2425
use crate::payment::pending_payment_store::{
2526
PendingPaymentDetails, PendingPaymentDetailsUpdate, SpliceIntent, SpliceKind,
2627
};
@@ -55,9 +56,9 @@ fn is_same_splice(a: &FundingContribution, b: &FundingContribution) -> bool {
5556
/// The intent is written before the contribution is handed to LDK, undone when LDK rejects the
5657
/// hand-off synchronously, and cleared once the splice locks, its failure is surfaced, or its
5758
/// channel closes. The record exists for recovery, not retry: a splice still recorded at the
58-
/// next startup identifies one that was in flight when the node stopped, so anything it reserved
59-
/// can be released, and events about the splice can be described in terms of the original
60-
/// request.
59+
/// next startup identifies one that was in flight when the node stopped, so [`Self::reconcile`]
60+
/// can release anything it still reserves, and events about the splice can be described in
61+
/// terms of the original request.
6162
pub(crate) struct SpliceTracker {
6263
channel_manager: Arc<ChannelManager>,
6364
wallet: Arc<Wallet>,
@@ -87,6 +88,89 @@ impl SpliceTracker {
8788
}
8889
}
8990

91+
/// Reconciles the persisted splice intents against live channel state, releasing whatever the
92+
/// wallet still holds for splices that did not survive the restart. LDK only persists a
93+
/// splice once its negotiation reaches `AwaitingSignatures`, so a splice lost earlier leaves
94+
/// no trace in LDK — the intent record is what recognizes the loss. Run once at startup,
95+
/// before background chain syncing and event processing start, so nothing can act on the
96+
/// stale reservations first.
97+
///
98+
/// Recovery is silent: no failure event is fabricated for a splice lost this way, since the
99+
/// initiating call already returned and the channel simply shows no pending splice anymore.
100+
pub(crate) async fn reconcile(&self) {
101+
let records = self.pending_payment_store.list_filter(|p| p.splice_intent().is_some()).await;
102+
for record in records {
103+
let payment_id = record.id();
104+
let Some(intent) = record.splice_intent().cloned() else {
105+
continue;
106+
};
107+
108+
let channel = self
109+
.channel_manager
110+
.list_channels_with_counterparty(&intent.counterparty_node_id)
111+
.into_iter()
112+
.find(|c| c.channel_id == intent.channel_id);
113+
let Some(channel) = channel else {
114+
// The channel is gone; there is nothing to splice anymore.
115+
log_info!(
116+
self.logger,
117+
"Dropping the recorded splice of closed channel {} with counterparty {}",
118+
intent.channel_id,
119+
intent.counterparty_node_id,
120+
);
121+
self.release_contribution(intent.channel_id, &intent.contribution).await;
122+
self.clear_persisted_intent(payment_id, |i| *i == intent).await;
123+
continue;
124+
};
125+
126+
if channel.funding_txo != Some(intent.pre_splice_funding_txo) {
127+
// The funding moved on while the node was down: the recorded splice, a
128+
// replacement, or a counterparty splice locked — the same situation a live lock
129+
// event resolves, so resolve it the same way.
130+
self.on_channel_ready(
131+
intent.counterparty_node_id,
132+
intent.channel_id,
133+
channel.funding_txo.map(|txo| txo.into_bitcoin_outpoint()),
134+
)
135+
.await;
136+
continue;
137+
}
138+
139+
let candidates = channel
140+
.splice_details
141+
.as_ref()
142+
.map(|details| details.candidates.as_slice())
143+
.unwrap_or(&[]);
144+
match decide_reconcile(candidates) {
145+
ReconcileDecision::Keep => {
146+
// A kept record may still reserve more than LDK's surviving rounds use —
147+
// extras a fee bump lost with the restart had reserved. Release the
148+
// difference.
149+
let extras = unclaimed_inputs(&intent.contribution, candidates);
150+
if let Err(e) = self.wallet.unlock_outpoints(&extras).await {
151+
log_error!(
152+
self.logger,
153+
"Failed to release unused splice inputs on channel {}: {}",
154+
intent.channel_id,
155+
e,
156+
);
157+
}
158+
},
159+
ReconcileDecision::Lost => {
160+
log_info!(
161+
self.logger,
162+
"Dropping a splice on channel {} with counterparty {} that did not survive \
163+
the restart",
164+
intent.channel_id,
165+
intent.counterparty_node_id,
166+
);
167+
self.release_contribution(intent.channel_id, &intent.contribution).await;
168+
self.clear_persisted_intent(payment_id, |i| *i == intent).await;
169+
},
170+
}
171+
}
172+
}
173+
90174
/// Persists a user-initiated splice as an intent and hands its contribution to
91175
/// [`ChannelManager::funding_contributed`]. The intent — and any wallet state staged on the
92176
/// splice's behalf — is durable before the hand-off, so no splice is ever in flight without a
@@ -367,7 +451,8 @@ impl SpliceTracker {
367451
/// Settles any persisted intent made obsolete by a newly locked funding transaction. An
368452
/// intent whose pre-splice outpoint is the newly locked funding was created after the lock
369453
/// and stays; one LDK still holds as a queued splice candidate is refreshed to the new
370-
/// funding rather than settled.
454+
/// funding rather than settled. Also resolves [`Self::reconcile`]'s case of a funding that
455+
/// moved while the node was down — the same situation, minus the event.
371456
pub(crate) async fn on_channel_ready(
372457
&self, counterparty_node_id: PublicKey, channel_id: ChannelId,
373458
funding_txo: Option<OutPoint>,
@@ -546,6 +631,61 @@ fn record_with_intent_cleared(
546631
}
547632
}
548633

634+
/// What [`SpliceTracker::reconcile`] should do with a persisted intent whose channel and funding
635+
/// are unchanged, decided from the splice rounds LDK reports on the channel.
636+
#[derive(Debug, PartialEq, Eq)]
637+
enum ReconcileDecision {
638+
/// LDK still holds a splice of ours; leave the intent in place until the splice settles.
639+
Keep,
640+
/// LDK holds no splice of ours: the recorded splice died with the restart, so whatever was
641+
/// reserved for it is released and the intent dropped.
642+
Lost,
643+
}
644+
645+
/// Decides the startup action for a persisted intent from the channel's [`SpliceDetails`]
646+
/// candidates.
647+
///
648+
/// [`SpliceDetails`]: lightning::ln::channel_state::SpliceDetails
649+
fn decide_reconcile(candidates: &[SpliceCandidateDetails]) -> ReconcileDecision {
650+
// A round short of `Negotiated` is one LDK still drives on its own: only `AwaitingSignatures`
651+
// survives a restart, and LDK resumes the signature exchange itself on reconnect.
652+
let in_flight = candidates
653+
.iter()
654+
.any(|candidate| !matches!(candidate.status, SpliceCandidateStatus::Negotiated { .. }));
655+
if in_flight {
656+
return ReconcileDecision::Keep;
657+
}
658+
659+
// LDK persists a splice once negotiated, so a negotiated candidate carrying a local
660+
// contribution is a splice of ours LDK sees through to lock — even one negotiated at a
661+
// different feerate than a recorded fee bump asked for. Without one, only counterparty
662+
// rounds (or nothing) survived: the recorded splice is gone.
663+
if candidates.iter().any(|candidate| candidate.contribution.is_some()) {
664+
ReconcileDecision::Keep
665+
} else {
666+
ReconcileDecision::Lost
667+
}
668+
}
669+
670+
/// The inputs `contribution` reserved that no candidate's own contribution still claims — extras
671+
/// a splice attempt lost with the restart had reserved. A counterparty-only round carries no
672+
/// contribution and claims nothing.
673+
fn unclaimed_inputs(
674+
contribution: &FundingContribution, candidates: &[SpliceCandidateDetails],
675+
) -> Vec<OutPoint> {
676+
let claimed: Vec<OutPoint> = candidates
677+
.iter()
678+
.filter_map(|candidate| candidate.contribution.as_ref())
679+
.flat_map(|contribution| contribution.inputs().iter().map(|input| input.outpoint()))
680+
.collect();
681+
contribution
682+
.inputs()
683+
.iter()
684+
.map(|input| input.outpoint())
685+
.filter(|outpoint| !claimed.contains(outpoint))
686+
.collect()
687+
}
688+
549689
#[cfg(test)]
550690
mod tests {
551691
use std::str::FromStr;
@@ -556,7 +696,7 @@ mod tests {
556696
use super::*;
557697
use crate::payment::pending_payment_store::{
558698
test_funding_contribution, test_funding_contribution_with_feerate,
559-
test_funding_contribution_with_outputs,
699+
test_funding_contribution_with_inputs, test_funding_contribution_with_outputs,
560700
};
561701
use crate::payment::store::{ConfirmationStatus, PaymentKind};
562702
use crate::payment::PaymentDirection;
@@ -661,4 +801,103 @@ mod tests {
661801
&test_funding_contribution_with_feerate(500)
662802
));
663803
}
804+
805+
fn negotiated_candidate(contribution: Option<FundingContribution>) -> SpliceCandidateDetails {
806+
SpliceCandidateDetails {
807+
contribution,
808+
status: SpliceCandidateStatus::Negotiated {
809+
txid: Txid::from_byte_array([9u8; 32]),
810+
new_channel_value_satoshis: 100_000,
811+
},
812+
}
813+
}
814+
815+
/// A previous transaction with a P2WPKH output at index 0 for a contribution input to spend;
816+
/// `seed` varies the output script, and with it the txid.
817+
fn test_prevtx(seed: u8) -> Transaction {
818+
use bitcoin::{ScriptBuf, TxOut, WPubkeyHash};
819+
820+
Transaction {
821+
version: Version::TWO,
822+
lock_time: LockTime::ZERO,
823+
input: vec![TxIn::default()],
824+
output: vec![TxOut {
825+
value: Amount::from_sat(10_000),
826+
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([seed; 20])),
827+
}],
828+
}
829+
}
830+
831+
/// While any round is short of `Negotiated`, LDK drives the splice itself; the intent stays
832+
/// in place until the splice settles.
833+
#[test]
834+
fn reconcile_keeps_the_intent_while_ldk_drives_a_round() {
835+
let in_flight = SpliceCandidateDetails {
836+
contribution: Some(test_funding_contribution()),
837+
status: SpliceCandidateStatus::AwaitingSignatures {
838+
is_initiator: true,
839+
funding_feerate_sat_per_1000_weight: 253,
840+
new_channel_value_satoshis: 100_000,
841+
txid: Txid::from_byte_array([9u8; 32]),
842+
},
843+
};
844+
assert_eq!(decide_reconcile(&[in_flight]), ReconcileDecision::Keep);
845+
}
846+
847+
/// A negotiated candidate carrying a local contribution is a splice LDK sees through to lock;
848+
/// nothing was lost. This holds on zero-conf channels too, where the pre-splice funding
849+
/// outpoint has not moved on yet.
850+
#[test]
851+
fn reconcile_trusts_a_negotiated_contribution() {
852+
let negotiated = [negotiated_candidate(Some(test_funding_contribution()))];
853+
assert_eq!(decide_reconcile(&negotiated), ReconcileDecision::Keep);
854+
}
855+
856+
/// A fee bump that only survives as a candidate negotiated at a lower feerate than requested
857+
/// is not lost: the recorded bump is moot, but the splice lives on and locks. The old
858+
/// higher-feerate attempt's extra reservations are released through the input difference, not
859+
/// by dropping the record.
860+
#[test]
861+
fn reconcile_keeps_a_bump_negotiated_at_a_lower_feerate() {
862+
let lower = [negotiated_candidate(Some(test_funding_contribution_with_feerate(253)))];
863+
assert_eq!(decide_reconcile(&lower), ReconcileDecision::Keep);
864+
}
865+
866+
/// With no contribution of ours in LDK — no splice at all, or only a counterparty round — the
867+
/// recorded splice died with the restart.
868+
#[test]
869+
fn reconcile_finds_the_splice_lost_when_ldk_holds_no_contribution() {
870+
assert_eq!(decide_reconcile(&[]), ReconcileDecision::Lost);
871+
let counterparty_only = [negotiated_candidate(None)];
872+
assert_eq!(decide_reconcile(&counterparty_only), ReconcileDecision::Lost);
873+
}
874+
875+
/// The inputs a kept record reserves beyond what LDK's candidates still claim are identified
876+
/// for release; a counterparty-only round claims nothing and must not suppress the
877+
/// difference.
878+
#[test]
879+
fn unclaimed_inputs_are_those_no_candidate_contribution_uses() {
880+
let prevtxs: Vec<Transaction> = (1u8..=3).map(test_prevtx).collect();
881+
let outpoint = |tx: &Transaction| OutPoint { txid: tx.compute_txid(), vout: 0 };
882+
let recorded = test_funding_contribution_with_inputs(253, &prevtxs);
883+
884+
// Every input still claimed by a surviving candidate: nothing to release.
885+
let all =
886+
[negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs)))];
887+
assert!(unclaimed_inputs(&recorded, &all).is_empty());
888+
889+
// A candidate claiming two of the three inputs: the third is released, even with a
890+
// counterparty-only round alongside.
891+
let partial = [
892+
negotiated_candidate(None),
893+
negotiated_candidate(Some(test_funding_contribution_with_inputs(253, &prevtxs[..2]))),
894+
];
895+
assert_eq!(unclaimed_inputs(&recorded, &partial), vec![outpoint(&prevtxs[2])]);
896+
897+
// No candidates at all: everything is released.
898+
assert_eq!(
899+
unclaimed_inputs(&recorded, &[]),
900+
prevtxs.iter().map(outpoint).collect::<Vec<_>>()
901+
);
902+
}
664903
}

‎src/lib.rs‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ impl Node {
366366
)
367367
})?;
368368

369+
// Release whatever the wallet still holds for splices that did not survive the restart —
370+
// before background syncing and broadcasting start below, so nothing can act on the stale
371+
// reservations first.
372+
self.runtime.block_on(self.splice_tracker.reconcile());
373+
369374
// Spawn background task continuously syncing onchain, lightning, and fee rate cache.
370375
let stop_sync_receiver = self.stop_sender.subscribe();
371376
let chain_source = Arc::clone(&self.chain_source);
@@ -701,6 +706,15 @@ impl Node {
701706
});
702707
}
703708

709+
// Consume any events LDK replays from its last persisted state (e.g. a `DiscardFunding`
710+
// for a splice that died before the node stopped) before the node is running: a replayed
711+
// event describes pre-restart state and must act before new user operations build on it.
712+
let replay_handler = &event_handler;
713+
self.runtime.block_on(
714+
self.channel_manager
715+
.process_pending_events_async(|event| replay_handler.handle_event(event)),
716+
);
717+
704718
// Setup background processing
705719
let background_persister = Arc::clone(&self.kv_store);
706720
let background_event_handler = Arc::clone(&event_handler);
@@ -1828,7 +1842,9 @@ impl Node {
18281842
///
18291843
/// A splice that fails during negotiation (e.g. because the peer disconnected) is reported
18301844
/// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice
1831-
/// may be initiated once the cause of the failure is addressed.
1845+
/// may be initiated once the cause of the failure is addressed. A splice still pending when
1846+
/// the node stops is resumed by LDK when possible; otherwise it is dropped at the next
1847+
/// startup — releasing anything reserved for it — without a failure event.
18321848
///
18331849
/// # Experimental API
18341850
///
@@ -1856,7 +1872,9 @@ impl Node {
18561872
///
18571873
/// A splice that fails during negotiation (e.g. because the peer disconnected) is reported
18581874
/// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice
1859-
/// may be initiated once the cause of the failure is addressed.
1875+
/// may be initiated once the cause of the failure is addressed. A splice still pending when
1876+
/// the node stops is resumed by LDK when possible; otherwise it is dropped at the next
1877+
/// startup — releasing anything reserved for it — without a failure event.
18601878
///
18611879
/// # Experimental API
18621880
///
@@ -1876,7 +1894,9 @@ impl Node {
18761894
///
18771895
/// A splice that fails during negotiation (e.g. because the peer disconnected) is reported
18781896
/// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; a new splice
1879-
/// may be initiated once the cause of the failure is addressed.
1897+
/// may be initiated once the cause of the failure is addressed. A splice still pending when
1898+
/// the node stops is resumed by LDK when possible; otherwise it is dropped at the next
1899+
/// startup — releasing anything reserved for it — without a failure event.
18801900
///
18811901
/// # Experimental API
18821902
///
@@ -1978,7 +1998,9 @@ impl Node {
19781998
///
19791999
/// A fee bump that fails during negotiation (e.g. because the peer disconnected) is reported
19802000
/// through [`Event::SpliceNegotiationFailed`] and is not retried automatically; the fee may
1981-
/// be bumped again once the cause of the failure is addressed.
2001+
/// be bumped again once the cause of the failure is addressed. A fee bump still pending when
2002+
/// the node stops is resumed by LDK when possible; otherwise it is dropped at the next
2003+
/// startup — releasing anything reserved for it — without a failure event.
19822004
pub fn bump_channel_funding_fee(
19832005
&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
19842006
) -> Result<(), Error> {

0 commit comments

Comments
 (0)