Skip to content

Commit f6c56da

Browse files
feat(lsps4): add splice-in support for capacity management
Instead of always opening new channels when outbound capacity is insufficient, prefer splicing into the largest existing usable channel. Falls back to new channel open when no usable channels exist. Key changes: - Add SpliceChannel event variant to LSPS4ServiceEvent - Add per-peer liquidity_cooldown to prevent 1Hz retry loops - Modify calculate_htlc_actions_for_peer to prefer splice over new channel - Add cooldown checks in execute_htlc_actions and process_pending_htlcs - Clear cooldown on channel_ready (covers both splice-lock and new channel)
1 parent 55f7619 commit f6c56da

2 files changed

Lines changed: 170 additions & 37 deletions

File tree

lightning-liquidity/src/lsps4/event.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@
1212
use crate::lsps0::ser::LSPSRequestId;
1313
use std::string::String;
1414
use std::vec::Vec;
15+
use lightning::ln::types::ChannelId;
1516
use lightning_types::payment::PaymentHash;
1617

17-
18-
1918
use bitcoin::secp256k1::PublicKey;
2019

2120
/// An event which an LSPS4 client should take some action in response to.
@@ -63,4 +62,22 @@ pub enum LSPS4ServiceEvent {
6362
/// The number of channels currently open with the peer when the request is made.
6463
channel_count: usize,
6564
},
65+
/// You should splice into an existing channel to add capacity.
66+
///
67+
/// If the splice fails, fall back to opening a new channel using the same sizing policy
68+
/// as [`OpenChannel`].
69+
///
70+
/// [`OpenChannel`]: Self::OpenChannel
71+
SpliceChannel {
72+
/// The node whose channel to splice.
73+
their_network_key: PublicKey,
74+
/// The channel to splice into (the largest usable channel with this peer).
75+
channel_id: ChannelId,
76+
/// The user channel id, for diagnostics.
77+
user_channel_id: u128,
78+
/// Additional capacity needed (msat). The splice-in amount should be at least this.
79+
amt_to_forward_msat: u64,
80+
/// The number of channels currently open with the peer.
81+
channel_count: usize,
82+
},
6683
}

lightning-liquidity/src/lsps4/service.rs

Lines changed: 151 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ use std::vec::Vec;
5151

5252
const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 45;
5353

54+
/// Cooldown duration for liquidity actions. After a splice or channel open is emitted,
55+
/// suppress further liquidity actions for this peer until the cooldown expires.
56+
/// Set below HTLC_EXPIRY_THRESHOLD_SECS so at most 1 wasted retry occurs before expiry.
57+
const LIQUIDITY_COOLDOWN_SECS: u64 = 30;
58+
5459
/// Action to forward a specific HTLC through a channel
5560
#[derive(Debug)]
5661
pub(crate) struct HtlcForwardAction {
@@ -60,17 +65,32 @@ pub(crate) struct HtlcForwardAction {
6065
pub skimmed_fee_msat: u64,
6166
}
6267

68+
/// Action to splice into an existing channel to add capacity.
69+
#[derive(Debug)]
70+
pub(crate) struct SpliceAction {
71+
pub channel_id: ChannelId,
72+
pub user_channel_id: u128,
73+
pub amt_to_forward_msat: u64,
74+
}
75+
6376
/// Actions to take for processing HTLCs for a peer
6477
#[derive(Debug)]
6578
pub(crate) struct HtlcProcessingActions {
6679
pub forwards: Vec<HtlcForwardAction>,
6780
pub new_channel_needed_msat: Option<u64>,
81+
pub splice_needed: Option<SpliceAction>,
6882
pub channel_count: usize,
6983
}
7084

7185
impl HtlcProcessingActions {
7286
pub fn is_empty(&self) -> bool {
73-
self.forwards.is_empty() && self.new_channel_needed_msat.is_none()
87+
self.forwards.is_empty()
88+
&& self.new_channel_needed_msat.is_none()
89+
&& self.splice_needed.is_none()
90+
}
91+
92+
pub fn needs_liquidity_action(&self) -> bool {
93+
self.new_channel_needed_msat.is_some() || self.splice_needed.is_some()
7494
}
7595

7696
pub fn total_forward_amount(&self) -> u64 {
@@ -102,6 +122,10 @@ where
102122
htlc_store: HTLCStore<L, K>,
103123
connected_peers: RwLock<HashSet<PublicKey>>,
104124
config: LSPS4ServiceConfig,
125+
/// Per-peer timestamped cooldown for liquidity actions (splice or channel open).
126+
/// Prevents 1Hz retry loops from process_pending_htlcs when actions fail.
127+
/// Auto-expires after LIQUIDITY_COOLDOWN_SECS.
128+
liquidity_cooldown: RwLock<HashMap<PublicKey, Instant>>,
105129
}
106130

107131
impl<CM: Deref + Clone, K: Deref + Clone, L: Deref + Clone> LSPS4ServiceHandler<CM, K, L>
@@ -128,6 +152,7 @@ where
128152
config,
129153
logger,
130154
connected_peers: RwLock::new(HashSet::new()),
155+
liquidity_cooldown: RwLock::new(new_hash_map()),
131156
})
132157
}
133158

@@ -236,7 +261,7 @@ where
236261
vec![htlc.clone()],
237262
);
238263

239-
if actions.new_channel_needed_msat.is_some() {
264+
if actions.needs_liquidity_action() {
240265
self.htlc_store.insert(htlc).unwrap();
241266
}
242267

@@ -275,6 +300,10 @@ where
275300
pub fn channel_ready(
276301
&self, counterparty_node_id: &PublicKey,
277302
) -> Result<(), APIError> {
303+
// Clear liquidity cooldown on channel_ready. This fires for both new channels
304+
// and splice completions (splice_locked), allowing immediate retry if needed.
305+
self.liquidity_cooldown.write().unwrap().remove(counterparty_node_id);
306+
278307
let is_connected = self.is_peer_connected(counterparty_node_id);
279308

280309
log_info!(
@@ -476,6 +505,35 @@ where
476505
.any(|c| c.is_usable)
477506
}
478507

508+
fn is_liquidity_cooling_down(&self, peer: &PublicKey) -> bool {
509+
self.liquidity_cooldown
510+
.read()
511+
.unwrap()
512+
.get(peer)
513+
.map(|t| t.elapsed().as_secs() < LIQUIDITY_COOLDOWN_SECS)
514+
.unwrap_or(false)
515+
}
516+
517+
/// Called when a liquidity action (splice or channel open) completes successfully.
518+
/// Clears the cooldown so new actions can be emitted immediately.
519+
pub fn liquidity_action_completed(&self, peer: &PublicKey) {
520+
self.liquidity_cooldown.write().unwrap().remove(peer);
521+
}
522+
523+
/// Called when a liquidity action fails asynchronously (e.g., ChannelClosed before ready).
524+
/// Clears the cooldown so the timer or next HTLC can retry immediately.
525+
pub fn liquidity_action_failed(&self, peer: &PublicKey) {
526+
self.liquidity_cooldown.write().unwrap().remove(peer);
527+
}
528+
529+
/// Called when a splice fails asynchronously (SpliceFailed event).
530+
/// Resets the cooldown timestamp instead of clearing it entirely.
531+
/// This allows retry after LIQUIDITY_COOLDOWN_SECS, not immediately,
532+
/// preventing 1Hz loops on persistent async splice failures.
533+
pub fn liquidity_action_reset_cooldown(&self, peer: &PublicKey) {
534+
self.liquidity_cooldown.write().unwrap().insert(*peer, Instant::now());
535+
}
536+
479537
/// Will update the set of connected peers
480538
pub fn peer_disconnected(&self, counterparty_node_id: &PublicKey) {
481539
let (was_present, remaining_count) = {
@@ -518,30 +576,21 @@ where
518576
self.connected_peers.read().unwrap().iter().copied().collect();
519577

520578
for node_id in connected_peers {
579+
// Skip peers with a liquidity action cooling down.
580+
// Prevents 1Hz retry loops. execute_htlc_actions also checks this
581+
// as a safety net for the htlc_intercepted path.
582+
if self.is_liquidity_cooling_down(&node_id) {
583+
continue;
584+
}
585+
521586
let htlcs = self.htlc_store.get_htlcs_by_node_id(&node_id);
522587
if htlcs.is_empty() {
523588
continue;
524589
}
525590

526591
if self.has_usable_channel(&node_id) {
527-
// Channel reestablish completed — forward the deferred HTLCs.
528-
log_info!(
529-
self.logger,
530-
"[LSPS4] process_pending_htlcs: forwarding {} HTLCs for peer {} (channel now usable)",
531-
htlcs.len(),
532-
node_id
533-
);
534592
let actions = self.calculate_htlc_actions_for_peer(node_id, htlcs);
535-
if actions.new_channel_needed_msat.is_some() {
536-
// A channel open is already in flight from htlc_intercepted or
537-
// peer_connected. Skip — channel_ready will handle forwarding
538-
// once the new channel is established.
539-
log_info!(
540-
self.logger,
541-
"[LSPS4] process_pending_htlcs: peer {} needs a new channel, \
542-
skipping (channel open already in flight)",
543-
node_id
544-
);
593+
if actions.is_empty() {
545594
continue;
546595
}
547596
self.execute_htlc_actions(actions, node_id);
@@ -727,29 +776,62 @@ where
727776
}
728777

729778
if !can_forward {
730-
// No existing channel has sufficient capacity, need to open a new channel
779+
// No existing channel has sufficient capacity.
731780
// Calculate total amount needed for remaining HTLCs (including current one)
732781
let total_remaining_amount = computed_htlcs
733782
.iter()
734783
.fold(required_amount, |acc, h| acc.saturating_add(h.amount_to_forward_msat));
735784

785+
// Prefer splicing into the largest usable channel over opening a new one.
786+
let splice_candidate = channels
787+
.iter()
788+
.filter(|c| c.is_usable)
789+
.max_by_key(|c| c.channel_value_satoshis);
790+
791+
if let Some(candidate) = splice_candidate {
792+
return HtlcProcessingActions {
793+
forwards,
794+
new_channel_needed_msat: None,
795+
splice_needed: Some(SpliceAction {
796+
channel_id: candidate.channel_id,
797+
user_channel_id: candidate.user_channel_id,
798+
amt_to_forward_msat: total_remaining_amount,
799+
}),
800+
channel_count,
801+
};
802+
}
803+
804+
// No usable channels to splice into - need a new channel.
736805
return HtlcProcessingActions {
737806
forwards,
738807
new_channel_needed_msat: Some(total_remaining_amount),
808+
splice_needed: None,
739809
channel_count,
740810
};
741811
}
742812
}
743813

744-
HtlcProcessingActions { forwards, new_channel_needed_msat: None, channel_count }
814+
HtlcProcessingActions {
815+
forwards,
816+
new_channel_needed_msat: None,
817+
splice_needed: None,
818+
channel_count,
819+
}
745820
}
746821

747822
/// Execute the actions calculated by calculate_htlc_actions_for_peer
748823
pub(crate) fn execute_htlc_actions(
749824
&self, actions: HtlcProcessingActions, their_node_id: PublicKey,
750825
) {
826+
let HtlcProcessingActions {
827+
forwards,
828+
new_channel_needed_msat,
829+
splice_needed,
830+
channel_count,
831+
} = actions;
832+
751833
// Execute forwards
752-
for forward_action in actions.forwards {
834+
for forward_action in forwards {
753835
// Re-check peer liveness right before forwarding to narrow the
754836
// TOCTOU window between the usability check and the actual forward.
755837
if !self.is_peer_connected(&their_node_id) {
@@ -826,21 +908,55 @@ where
826908
}
827909
}
828910

829-
// Handle new channel opening
830-
if let Some(channel_size_msat) = actions.new_channel_needed_msat {
831-
log_info!(
832-
self.logger,
833-
"Need a new channel with peer {} for {}msat to forward HTLCs",
834-
their_node_id,
835-
channel_size_msat
836-
);
911+
// Handle liquidity actions (splice or new channel)
912+
let needs_liquidity = new_channel_needed_msat.is_some() || splice_needed.is_some();
913+
if needs_liquidity {
914+
if self.is_liquidity_cooling_down(&their_node_id) {
915+
log_info!(
916+
self.logger,
917+
"[LSPS4] Liquidity action suppressed for peer {} (cooling down)",
918+
their_node_id
919+
);
920+
return;
921+
}
922+
923+
// Set cooldown BEFORE emitting to prevent re-entrant emit on next tick.
924+
self.liquidity_cooldown.write().unwrap().insert(their_node_id, Instant::now());
837925

838926
let mut event_queue_notifier = self.pending_events.notifier();
839-
event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::OpenChannel {
840-
their_network_key: their_node_id,
841-
amt_to_forward_msat: channel_size_msat,
842-
channel_count: actions.channel_count,
843-
}));
927+
928+
if let Some(splice_action) = splice_needed {
929+
log_info!(
930+
self.logger,
931+
"[LSPS4] Splicing into channel {} with peer {} for {}msat",
932+
splice_action.channel_id,
933+
their_node_id,
934+
splice_action.amt_to_forward_msat
935+
);
936+
event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(
937+
LSPS4ServiceEvent::SpliceChannel {
938+
their_network_key: their_node_id,
939+
channel_id: splice_action.channel_id,
940+
user_channel_id: splice_action.user_channel_id,
941+
amt_to_forward_msat: splice_action.amt_to_forward_msat,
942+
channel_count,
943+
},
944+
));
945+
} else if let Some(channel_size_msat) = new_channel_needed_msat {
946+
log_info!(
947+
self.logger,
948+
"[LSPS4] Need a new channel with peer {} for {}msat to forward HTLCs",
949+
their_node_id,
950+
channel_size_msat
951+
);
952+
event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(
953+
LSPS4ServiceEvent::OpenChannel {
954+
their_network_key: their_node_id,
955+
amt_to_forward_msat: channel_size_msat,
956+
channel_count,
957+
},
958+
));
959+
}
844960
}
845961
}
846962

0 commit comments

Comments
 (0)