Skip to content

Commit 63fcc4f

Browse files
authored
Merge pull request #4656 from tnull/2026-06-lsps2-replayed-htlc-queue
LSPS2 service: Treat replayed `HTLCIntercepted` events idempotently
2 parents 0b81d1b + 8270c7c commit 63fcc4f

4 files changed

Lines changed: 221 additions & 8 deletions

File tree

lightning-liquidity/src/lsps2/payment_queue.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,29 @@ impl PaymentQueue {
2626
PaymentQueue { payments: Vec::new() }
2727
}
2828

29+
fn payment_status(entry: &PaymentQueueEntry) -> (u64, usize) {
30+
let total_expected_outbound_amount_msat =
31+
entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum();
32+
(total_expected_outbound_amount_msat, entry.htlcs.len())
33+
}
34+
2935
pub(crate) fn add_htlc(&mut self, new_htlc: InterceptedHTLC) -> (u64, usize) {
36+
if let Some(entry) = self
37+
.payments
38+
.iter()
39+
.find(|entry| entry.htlcs.iter().any(|htlc| htlc.intercept_id == new_htlc.intercept_id))
40+
{
41+
debug_assert_eq!(entry.payment_hash, new_htlc.payment_hash);
42+
return Self::payment_status(entry);
43+
}
44+
3045
let payment =
3146
self.payments.iter_mut().find(|entry| entry.payment_hash == new_htlc.payment_hash);
3247
if let Some(entry) = payment {
3348
// HTLCs within a payment should have the same payment hash.
3449
debug_assert!(entry.htlcs.iter().all(|htlc| htlc.payment_hash == entry.payment_hash));
35-
// The given HTLC should not already be present.
36-
debug_assert!(entry
37-
.htlcs
38-
.iter()
39-
.all(|htlc| htlc.intercept_id != new_htlc.intercept_id));
4050
entry.htlcs.push(new_htlc);
41-
let total_expected_outbound_amount_msat =
42-
entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum();
43-
(total_expected_outbound_amount_msat, entry.htlcs.len())
51+
Self::payment_status(entry)
4452
} else {
4553
let expected_outbound_amount_msat = new_htlc.expected_outbound_amount_msat;
4654
let entry =
@@ -127,6 +135,15 @@ mod tests {
127135
(500_000_000, 2),
128136
);
129137

138+
assert_eq!(
139+
payment_queue.add_htlc(InterceptedHTLC {
140+
intercept_id: InterceptId([2; 32]),
141+
expected_outbound_amount_msat: 300_000_000,
142+
payment_hash: PaymentHash([100; 32]),
143+
}),
144+
(500_000_000, 2),
145+
);
146+
130147
let expected_entry = PaymentQueueEntry {
131148
payment_hash: PaymentHash([100; 32]),
132149
htlcs: vec![

lightning-liquidity/src/lsps2/service.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,26 @@ impl PeerState {
644644
});
645645
}
646646

647+
fn remove_terminal_channel_state(&mut self, channel_id: ChannelId) -> Option<u64> {
648+
let intercept_scid = self.intercept_scid_by_channel_id.get(&channel_id).copied()?;
649+
let should_remove = self
650+
.outbound_channels_by_intercept_scid
651+
.get(&intercept_scid)
652+
.and_then(|entry| entry.get_channel_id())
653+
.is_some_and(|existing_channel_id| existing_channel_id == channel_id);
654+
655+
if !should_remove {
656+
return None;
657+
}
658+
659+
self.outbound_channels_by_intercept_scid.remove(&intercept_scid);
660+
self.intercept_scid_by_channel_id.remove(&channel_id);
661+
self.intercept_scid_by_user_channel_id.retain(|_, iscid| *iscid != intercept_scid);
662+
self.needs_persist = true;
663+
664+
Some(intercept_scid)
665+
}
666+
647667
fn pending_requests_and_channels(&self) -> usize {
648668
let pending_requests = self.pending_requests.len();
649669
let pending_outbound_channels = self
@@ -1252,6 +1272,45 @@ where
12521272
Ok(())
12531273
}
12541274

1275+
/// Forward [`Event::ChannelClosed`] event parameter into this function.
1276+
///
1277+
/// Will prune terminal JIT channel state once the corresponding channel has closed.
1278+
///
1279+
/// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed
1280+
pub async fn channel_closed(&self, channel_id: ChannelId) -> Result<(), APIError> {
1281+
let counterparty_node_id =
1282+
self.peer_by_channel_id.read().unwrap().get(&channel_id).copied();
1283+
let Some(counterparty_node_id) = counterparty_node_id else {
1284+
return Ok(());
1285+
};
1286+
1287+
let removed_intercept_scid = {
1288+
let outer_state_lock = self.per_peer_state.read().unwrap();
1289+
match outer_state_lock.get(&counterparty_node_id) {
1290+
Some(inner_state_lock) => {
1291+
let mut peer_state = inner_state_lock.lock().unwrap();
1292+
peer_state.remove_terminal_channel_state(channel_id)
1293+
},
1294+
None => None,
1295+
}
1296+
};
1297+
1298+
if let Some(intercept_scid) = removed_intercept_scid {
1299+
self.peer_by_intercept_scid.write().unwrap().remove(&intercept_scid);
1300+
self.peer_by_channel_id.write().unwrap().remove(&channel_id);
1301+
self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
1302+
APIError::APIMisuseError {
1303+
err: format!(
1304+
"Failed to persist peer state after channel {} closed: {}",
1305+
channel_id, e
1306+
),
1307+
}
1308+
})?;
1309+
}
1310+
1311+
Ok(())
1312+
}
1313+
12551314
/// Abandons a pending JIT‐open flow for `user_channel_id`, removing all local state.
12561315
///
12571316
/// This removes the intercept SCID, any outbound channel state, and associated
@@ -2269,6 +2328,25 @@ where
22692328
}
22702329
}
22712330

2331+
/// Forward [`Event::ChannelClosed`] event parameter into this function.
2332+
///
2333+
/// Wraps [`LSPS2ServiceHandler::channel_closed`].
2334+
///
2335+
/// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed
2336+
pub fn channel_closed(&self, channel_id: ChannelId) -> Result<(), APIError> {
2337+
let mut fut = pin!(self.inner.channel_closed(channel_id));
2338+
2339+
let mut waker = dummy_waker();
2340+
let mut ctx = task::Context::from_waker(&mut waker);
2341+
match fut.as_mut().poll(&mut ctx) {
2342+
task::Poll::Ready(result) => result,
2343+
task::Poll::Pending => {
2344+
// In a sync context, we can't wait for the future to complete.
2345+
unreachable!("Should not be pending in a sync context");
2346+
},
2347+
}
2348+
}
2349+
22722350
/// Wraps [`LSPS2ServiceHandler::channel_needs_manual_broadcast`].
22732351
pub fn channel_needs_manual_broadcast(
22742352
&self, user_channel_id: u128, counterparty_node_id: &PublicKey,
@@ -2360,6 +2438,8 @@ mod tests {
23602438

23612439
use bitcoin::{absolute::LockTime, transaction::Version};
23622440
use core::str::FromStr;
2441+
use lightning::io::Cursor;
2442+
use lightning::util::ser::{Readable, Writeable};
23632443

23642444
const MAX_VALUE_MSAT: u64 = 21_000_000_0000_0000_000;
23652445

@@ -2763,6 +2843,118 @@ mod tests {
27632843
}
27642844
}
27652845

2846+
#[test]
2847+
fn replayed_intercepted_htlc_after_persist_is_idempotent() {
2848+
let payment_size_msat = Some(500_000_000);
2849+
let opening_fee_params = LSPS2OpeningFeeParams {
2850+
min_fee_msat: 10_000_000,
2851+
proportional: 10_000,
2852+
valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(),
2853+
min_lifetime: 4032,
2854+
max_client_to_self_delay: 2016,
2855+
min_payment_size_msat: 10_000_000,
2856+
max_payment_size_msat: 1_000_000_000,
2857+
promise: "ignore".to_string(),
2858+
};
2859+
let intercept_scid = 42;
2860+
let user_channel_id = 43;
2861+
let htlc = InterceptedHTLC {
2862+
intercept_id: InterceptId([1; 32]),
2863+
expected_outbound_amount_msat: 500_000_000,
2864+
payment_hash: PaymentHash([2; 32]),
2865+
};
2866+
2867+
let mut jit_channel =
2868+
OutboundJITChannel::new(payment_size_msat, opening_fee_params, user_channel_id, false);
2869+
assert!(matches!(
2870+
jit_channel.htlc_intercepted(htlc).unwrap(),
2871+
Some(HTLCInterceptedAction::OpenChannel(_))
2872+
));
2873+
2874+
let mut peer_state = PeerState::new();
2875+
peer_state.intercept_scid_by_user_channel_id.insert(user_channel_id, intercept_scid);
2876+
peer_state.insert_outbound_channel(intercept_scid, jit_channel);
2877+
2878+
let encoded_peer_state = peer_state.encode();
2879+
let mut decoded_peer_state = PeerState::read(&mut Cursor::new(encoded_peer_state)).unwrap();
2880+
let decoded_jit_channel = decoded_peer_state
2881+
.outbound_channels_by_intercept_scid
2882+
.get_mut(&intercept_scid)
2883+
.unwrap();
2884+
2885+
assert!(decoded_jit_channel.htlc_intercepted(htlc).unwrap().is_none());
2886+
2887+
let ForwardPaymentAction(_, fee_payment) =
2888+
decoded_jit_channel.channel_ready(ChannelId([3; 32])).unwrap();
2889+
assert_eq!(fee_payment.htlcs, vec![htlc]);
2890+
}
2891+
2892+
#[test]
2893+
fn removes_terminal_state_for_closed_channel() {
2894+
let opening_fee_params = LSPS2OpeningFeeParams {
2895+
min_fee_msat: 10_000_000,
2896+
proportional: 10_000,
2897+
valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(),
2898+
min_lifetime: 4032,
2899+
max_client_to_self_delay: 2016,
2900+
min_payment_size_msat: 10_000_000,
2901+
max_payment_size_msat: 1_000_000_000,
2902+
promise: "ignore".to_string(),
2903+
};
2904+
let stale_intercept_scid = 42;
2905+
let stale_user_channel_id = 43;
2906+
let stale_channel_id = ChannelId([44; 32]);
2907+
let live_intercept_scid = 45;
2908+
let live_user_channel_id = 46;
2909+
let live_channel_id = ChannelId([47; 32]);
2910+
2911+
let mut stale_jit_channel =
2912+
OutboundJITChannel::new(None, opening_fee_params.clone(), stale_user_channel_id, false);
2913+
stale_jit_channel.state =
2914+
OutboundJITChannelState::PaymentForwarded { channel_id: stale_channel_id };
2915+
let mut live_jit_channel =
2916+
OutboundJITChannel::new(None, opening_fee_params, live_user_channel_id, false);
2917+
live_jit_channel.state =
2918+
OutboundJITChannelState::PaymentForwarded { channel_id: live_channel_id };
2919+
2920+
let mut peer_state = PeerState::new();
2921+
peer_state.insert_outbound_channel(stale_intercept_scid, stale_jit_channel);
2922+
peer_state.insert_outbound_channel(live_intercept_scid, live_jit_channel);
2923+
peer_state
2924+
.intercept_scid_by_user_channel_id
2925+
.insert(stale_user_channel_id, stale_intercept_scid);
2926+
peer_state
2927+
.intercept_scid_by_user_channel_id
2928+
.insert(live_user_channel_id, live_intercept_scid);
2929+
peer_state.intercept_scid_by_channel_id.insert(stale_channel_id, stale_intercept_scid);
2930+
peer_state.intercept_scid_by_channel_id.insert(live_channel_id, live_intercept_scid);
2931+
peer_state.needs_persist = false;
2932+
2933+
assert_eq!(
2934+
peer_state.remove_terminal_channel_state(stale_channel_id),
2935+
Some(stale_intercept_scid)
2936+
);
2937+
assert!(!peer_state
2938+
.outbound_channels_by_intercept_scid
2939+
.contains_key(&stale_intercept_scid));
2940+
assert!(peer_state.outbound_channels_by_intercept_scid.contains_key(&live_intercept_scid));
2941+
assert!(!peer_state.intercept_scid_by_user_channel_id.contains_key(&stale_user_channel_id));
2942+
assert_eq!(
2943+
peer_state.intercept_scid_by_user_channel_id.get(&live_user_channel_id),
2944+
Some(&live_intercept_scid)
2945+
);
2946+
assert!(!peer_state.intercept_scid_by_channel_id.contains_key(&stale_channel_id));
2947+
assert_eq!(
2948+
peer_state.intercept_scid_by_channel_id.get(&live_channel_id),
2949+
Some(&live_intercept_scid)
2950+
);
2951+
assert!(peer_state.needs_persist);
2952+
2953+
peer_state.needs_persist = false;
2954+
assert_eq!(peer_state.remove_terminal_channel_state(stale_channel_id), None);
2955+
assert!(!peer_state.needs_persist);
2956+
}
2957+
27662958
#[test]
27672959
fn broadcast_not_allowed_after_non_paying_fee_payment_claimed() {
27682960
let min_fee_msat: u64 = 12345;

lightning-liquidity/src/manager.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,15 @@ where
256256
/// - [`Event::ChannelReady`] to [`LSPS2ServiceHandler::channel_ready`]
257257
/// - [`Event::HTLCHandlingFailed`] to [`LSPS2ServiceHandler::htlc_handling_failed`]
258258
/// - [`Event::PaymentForwarded`] to [`LSPS2ServiceHandler::payment_forwarded`]
259+
/// - [`Event::ChannelClosed`] to [`LSPS2ServiceHandler::channel_closed`]
259260
///
260261
/// [`PeerManager`]: lightning::ln::peer_handler::PeerManager
261262
/// [`MessageHandler`]: lightning::ln::peer_handler::MessageHandler
262263
/// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted
263264
/// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady
264265
/// [`Event::HTLCHandlingFailed`]: lightning::events::Event::HTLCHandlingFailed
265266
/// [`Event::PaymentForwarded`]: lightning::events::Event::PaymentForwarded
267+
/// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed
266268
pub struct LiquidityManager<
267269
ES: EntropySource + Clone,
268270
NS: NodeSigner + Clone,

pending_changelog/4656.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
## API Updates
2+
* The `LSPS2ServiceHandler` now expects LDK's `ChannelClosed` events to be forwarded to the new `channel_closed` method. (#4656)

0 commit comments

Comments
 (0)