Skip to content

Commit cc4cc39

Browse files
committed
lsps2: Add prune_channels API to remove completed JIT channel state
Add `LSPS2ServiceHandler::prune_channels` that lets the LSP operator remove all channels in the `PaymentForwarded` terminal state whose `created_at` timestamp is at least `max_age` old. Passing `Duration::ZERO` prunes all terminal channels regardless of age. All associated state is cleaned up atomically: - per-peer `intercept_scid_by_channel_id` and `intercept_scid_by_user_channel_id` - handler-level `peer_by_intercept_scid` and `peer_by_channel_id` A new `PeerState::prune_terminal_channels` helper handles the intra-peer map cleanup and returns the removed `(scid, channel_id)` pairs for the handler to clean up the outer maps. Integration tests cover: non-terminal channels not pruned, unknown counterparty errors, age filtering, and successful bulk prune.
1 parent 057fdc3 commit cc4cc39

2 files changed

Lines changed: 513 additions & 21 deletions

File tree

lightning-liquidity/src/lsps2/service.rs

Lines changed: 212 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,37 @@ impl PeerState {
664664
// Return whether the entire state is empty.
665665
self.pending_requests.is_empty() && self.outbound_channels_by_intercept_scid.is_empty()
666666
}
667+
668+
/// Removes all channels in the [`PaymentForwarded`] terminal state whose `created_at`
669+
/// timestamp is at least `max_age` old. Passing [`Duration::ZERO`] removes all terminal
670+
/// channels regardless of age.
671+
///
672+
/// Cleans up the intra-peer auxiliary maps for each removed channel and returns the
673+
/// `(intercept_scid, channel_id)` pairs so the caller can remove them from the
674+
/// handler-level peer lookup maps.
675+
///
676+
/// [`PaymentForwarded`]: OutboundJITChannelState::PaymentForwarded
677+
/// [`Duration::ZERO`]: core::time::Duration::ZERO
678+
fn prune_terminal_channels(
679+
&mut self, now: &LSPSDateTime, max_age: Duration,
680+
) -> Vec<(u64, ChannelId)> {
681+
let mut removed = Vec::new();
682+
self.outbound_channels_by_intercept_scid.retain(|scid, channel| {
683+
if let OutboundJITChannelState::PaymentForwarded { channel_id } = &channel.state {
684+
let should_prune = max_age == Duration::ZERO
685+
|| now.duration_since(&channel.created_at) >= max_age;
686+
if should_prune {
687+
removed.push((*scid, *channel_id));
688+
self.intercept_scid_by_channel_id.retain(|_, iscid| iscid != scid);
689+
self.intercept_scid_by_user_channel_id.retain(|_, iscid| iscid != scid);
690+
self.needs_persist = true;
691+
return false;
692+
}
693+
}
694+
true
695+
});
696+
removed
697+
}
667698
}
668699

669700
impl_writeable_tlv_based!(PeerState, {
@@ -1267,6 +1298,66 @@ where
12671298
Ok(())
12681299
}
12691300

1301+
/// Prunes completed JIT channels from state for a given peer, freeing memory.
1302+
///
1303+
/// Removes all channels in the [`OutboundJITChannelState::PaymentForwarded`] terminal state
1304+
/// whose `created_at` timestamp is at least `max_age` old. Pass [`Duration::ZERO`] to prune
1305+
/// all terminal channels regardless of age.
1306+
///
1307+
/// All associated state is cleaned up for each removed channel, including the per-peer
1308+
/// `intercept_scid_by_channel_id` and `intercept_scid_by_user_channel_id` maps as well as
1309+
/// the handler-level `peer_by_intercept_scid` and `peer_by_channel_id` lookups.
1310+
///
1311+
/// Returns the number of channels pruned, or an [`APIError::APIMisuseError`] if the
1312+
/// counterparty has no state.
1313+
///
1314+
/// [`Duration::ZERO`]: core::time::Duration::ZERO
1315+
pub async fn prune_channels(
1316+
&self, counterparty_node_id: PublicKey, max_age: Duration,
1317+
) -> Result<usize, APIError> {
1318+
let now = LSPSDateTime::new_from_duration_since_epoch(
1319+
self.time_provider.duration_since_epoch(),
1320+
);
1321+
1322+
let removed = {
1323+
let outer_state_lock = self.per_peer_state.read().unwrap();
1324+
let inner_state_lock =
1325+
outer_state_lock.get(&counterparty_node_id).ok_or_else(|| {
1326+
APIError::APIMisuseError {
1327+
err: format!(
1328+
"No existing state with counterparty {}",
1329+
counterparty_node_id
1330+
),
1331+
}
1332+
})?;
1333+
let mut peer_state = inner_state_lock.lock().unwrap();
1334+
peer_state.prune_terminal_channels(&now, max_age)
1335+
};
1336+
1337+
let pruned = removed.len();
1338+
if pruned > 0 {
1339+
let mut peer_by_intercept_scid = self.peer_by_intercept_scid.write().unwrap();
1340+
let mut peer_by_channel_id = self.peer_by_channel_id.write().unwrap();
1341+
for (scid, channel_id) in &removed {
1342+
peer_by_intercept_scid.remove(scid);
1343+
peer_by_channel_id.remove(channel_id);
1344+
}
1345+
drop(peer_by_intercept_scid);
1346+
drop(peer_by_channel_id);
1347+
1348+
self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
1349+
APIError::APIMisuseError {
1350+
err: format!(
1351+
"Failed to persist peer state for {}: {}",
1352+
counterparty_node_id, e
1353+
),
1354+
}
1355+
})?;
1356+
}
1357+
1358+
Ok(pruned)
1359+
}
1360+
12701361
/// Abandons a pending JIT‐open flow for `user_channel_id`, removing all local state.
12711362
///
12721363
/// This removes the intercept SCID, any outbound channel state, and associated
@@ -2349,6 +2440,24 @@ where
23492440
}
23502441
}
23512442

2443+
/// Prunes completed JIT channels from state for a given peer, freeing memory.
2444+
///
2445+
/// Wraps [`LSPS2ServiceHandler::prune_channels`].
2446+
pub fn prune_channels(
2447+
&self, counterparty_node_id: PublicKey, max_age: Duration,
2448+
) -> Result<usize, APIError> {
2449+
let mut fut = pin!(self.inner.prune_channels(counterparty_node_id, max_age));
2450+
2451+
let mut waker = dummy_waker();
2452+
let mut ctx = task::Context::from_waker(&mut waker);
2453+
match fut.as_mut().poll(&mut ctx) {
2454+
task::Poll::Ready(result) => result,
2455+
task::Poll::Pending => {
2456+
unreachable!("Should not be pending in a sync context");
2457+
},
2458+
}
2459+
}
2460+
23522461
/// Forward [`Event::ChannelReady`] event parameters into this function.
23532462
///
23542463
/// Wraps [`LSPS2ServiceHandler::channel_ready`].
@@ -2885,46 +2994,128 @@ mod tests {
28852994
);
28862995
}
28872996

2888-
#[test]
2889-
fn test_outbound_jit_channel_created_at_stored() {
2890-
let opening_fee_params = LSPS2OpeningFeeParams {
2891-
min_fee_msat: 1_000,
2892-
proportional: 0,
2893-
valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(),
2997+
fn make_test_opening_fee_params() -> LSPS2OpeningFeeParams {
2998+
LSPS2OpeningFeeParams {
2999+
min_fee_msat: 1000,
3000+
proportional: 100,
3001+
valid_until: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(),
28943002
min_lifetime: 144,
28953003
max_client_to_self_delay: 128,
28963004
min_payment_size_msat: 1,
28973005
max_payment_size_msat: 10_000_000_000,
28983006
promise: "ignore".to_string(),
2899-
};
3007+
}
3008+
}
3009+
3010+
#[test]
3011+
fn test_outbound_jit_channel_created_at_stored() {
29003012
let created_at = LSPSDateTime::from_str("2024-06-15T12:00:00Z").unwrap();
2901-
let channel =
2902-
OutboundJITChannel::new(Some(1_000_000), opening_fee_params, 1u128, true, created_at);
3013+
let channel = OutboundJITChannel::new(
3014+
Some(1_000_000),
3015+
make_test_opening_fee_params(),
3016+
1u128,
3017+
true,
3018+
created_at,
3019+
);
29033020
assert_eq!(channel.created_at, created_at);
29043021
}
29053022

29063023
#[test]
29073024
fn test_outbound_jit_channel_created_at_round_trips() {
29083025
use lightning::util::ser::{Readable, Writeable};
29093026

2910-
let opening_fee_params = LSPS2OpeningFeeParams {
2911-
min_fee_msat: 1_000,
2912-
proportional: 0,
2913-
valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(),
2914-
min_lifetime: 144,
2915-
max_client_to_self_delay: 128,
2916-
min_payment_size_msat: 1,
2917-
max_payment_size_msat: 10_000_000_000,
2918-
promise: "ignore".to_string(),
2919-
};
29203027
let created_at = LSPSDateTime::from_str("2024-06-15T12:00:00Z").unwrap();
2921-
let channel =
2922-
OutboundJITChannel::new(Some(1_000_000), opening_fee_params, 1u128, true, created_at);
3028+
let channel = OutboundJITChannel::new(
3029+
Some(1_000_000),
3030+
make_test_opening_fee_params(),
3031+
1u128,
3032+
true,
3033+
created_at,
3034+
);
29233035

29243036
let mut buf = Vec::new();
29253037
channel.write(&mut buf).unwrap();
29263038

29273039
let decoded = <OutboundJITChannel as Readable>::read(&mut &buf[..]).unwrap();
29283040
assert_eq!(decoded.created_at, created_at);
29293041
}
3042+
3043+
// Verify that a PeerState entry in PaymentForwarded state is correctly removed along with
3044+
// all auxiliary lookup maps when prune_channel logic is exercised manually.
3045+
#[test]
3046+
fn test_peer_state_prune_payment_forwarded_channel() {
3047+
let intercept_scid = 42u64;
3048+
let user_channel_id = 1u128;
3049+
let channel_id = ChannelId([1; 32]);
3050+
let created_at = LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap();
3051+
3052+
let mut jit_channel = OutboundJITChannel::new(
3053+
Some(1_000_000),
3054+
make_test_opening_fee_params(),
3055+
user_channel_id,
3056+
false,
3057+
created_at,
3058+
);
3059+
3060+
// Drive the channel through to PaymentForwarded state.
3061+
let htlc = InterceptedHTLC {
3062+
intercept_id: InterceptId([0; 32]),
3063+
expected_outbound_amount_msat: 1_000_000,
3064+
payment_hash: PaymentHash([1; 32]),
3065+
};
3066+
let action = jit_channel.htlc_intercepted(htlc).unwrap();
3067+
assert!(matches!(action, Some(HTLCInterceptedAction::OpenChannel(_))));
3068+
jit_channel.channel_ready(channel_id).unwrap();
3069+
// Provide enough fee to transition to PaymentForwarded.
3070+
jit_channel.payment_forwarded(1_000).unwrap();
3071+
3072+
// Build a minimal PeerState with the channel and auxiliary maps.
3073+
let mut peer_state = PeerState::new();
3074+
peer_state.outbound_channels_by_intercept_scid.insert(intercept_scid, jit_channel);
3075+
peer_state.intercept_scid_by_user_channel_id.insert(user_channel_id, intercept_scid);
3076+
peer_state.intercept_scid_by_channel_id.insert(channel_id, intercept_scid);
3077+
3078+
// Confirm the channel is in PaymentForwarded state.
3079+
assert!(matches!(
3080+
peer_state.outbound_channels_by_intercept_scid.get(&intercept_scid).unwrap().state,
3081+
OutboundJITChannelState::PaymentForwarded { .. }
3082+
));
3083+
3084+
// Simulate what prune_channel does internally.
3085+
peer_state.outbound_channels_by_intercept_scid.remove(&intercept_scid);
3086+
peer_state.intercept_scid_by_channel_id.retain(|_, iscid| *iscid != intercept_scid);
3087+
peer_state.intercept_scid_by_user_channel_id.retain(|_, iscid| *iscid != intercept_scid);
3088+
peer_state.needs_persist = true;
3089+
3090+
// All maps must be empty after pruning.
3091+
assert!(peer_state.outbound_channels_by_intercept_scid.is_empty());
3092+
assert!(peer_state.intercept_scid_by_channel_id.is_empty());
3093+
assert!(peer_state.intercept_scid_by_user_channel_id.is_empty());
3094+
assert!(peer_state.needs_persist);
3095+
}
3096+
3097+
// Verify that prune_channel rejects non-terminal states.
3098+
#[test]
3099+
fn test_peer_state_prune_channel_non_terminal_rejected() {
3100+
let intercept_scid = 99u64;
3101+
let user_channel_id = 2u128;
3102+
let created_at = LSPSDateTime::from_str("2024-01-01T00:00:00Z").unwrap();
3103+
let jit_channel = OutboundJITChannel::new(
3104+
Some(500_000),
3105+
make_test_opening_fee_params(),
3106+
user_channel_id,
3107+
false,
3108+
created_at,
3109+
);
3110+
3111+
// Channel is in PendingInitialPayment — a non-terminal state.
3112+
assert!(matches!(jit_channel.state, OutboundJITChannelState::PendingInitialPayment { .. }));
3113+
3114+
// Verify the guard logic that prune_channel uses.
3115+
let is_prunable =
3116+
matches!(jit_channel.state, OutboundJITChannelState::PaymentForwarded { .. });
3117+
assert!(!is_prunable, "PendingInitialPayment must not be considered prunable");
3118+
3119+
let _ = intercept_scid; // silence unused warning
3120+
}
29303121
}

0 commit comments

Comments
 (0)