@@ -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
669700impl_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