@@ -61,8 +61,8 @@ struct Webhook {
6161 // Timestamp used for tracking when the webhook was created / updated, or when the last notification was sent.
6262 // This is used to determine if the webhook is stale and should be pruned.
6363 last_used : LSPSDateTime ,
64- // Timestamp when we last sent a notification to the client. This is used to enforce
65- // notification cooldowns .
64+ // Timestamp when we last sent a notification to the client. This enforces the notification
65+ // cooldown that protects the client from repeated spammy wake-ups .
6666 last_notification_sent : Option < LSPSDateTime > ,
6767}
6868
@@ -85,6 +85,12 @@ pub struct LSPS5ServiceConfig {
8585pub const DEFAULT_MAX_WEBHOOKS_PER_CLIENT : u32 = 10 ;
8686/// Default notification cooldown time in minutes.
8787pub const NOTIFICATION_COOLDOWN_TIME : Duration = Duration :: from_secs ( 60 ) ; // 1 minute
88+ /// Minimum time between peer lifecycle events that are allowed to reset notification cooldowns.
89+ ///
90+ /// This is distinct from [`NOTIFICATION_COOLDOWN_TIME`]: that cooldown protects the client from
91+ /// repeated spammy wake-ups, while this reset throttle protects registered notification URLs from
92+ /// amplification via rapid peer connect/disconnect churn.
93+ const NOTIFICATION_COOLDOWN_RESET_INTERVAL : Duration = Duration :: from_secs ( 10 ) ;
8894
8995// Default configuration for LSPS5 service.
9096impl Default for LSPS5ServiceConfig {
@@ -690,15 +696,21 @@ where
690696 pub ( crate ) fn peer_connected ( & self , counterparty_node_id : & PublicKey ) {
691697 let mut outer_state_lock = self . per_peer_state . write ( ) . unwrap ( ) ;
692698 if let Some ( peer_state) = outer_state_lock. get_mut ( counterparty_node_id) {
693- peer_state. reset_notification_cooldown ( ) ;
699+ let now = LSPSDateTime :: new_from_duration_since_epoch (
700+ self . time_provider . duration_since_epoch ( ) ,
701+ ) ;
702+ peer_state. reset_notification_cooldown ( now) ;
694703 }
695704 self . check_prune_stale_webhooks ( & mut outer_state_lock) ;
696705 }
697706
698707 pub ( crate ) fn peer_disconnected ( & self , counterparty_node_id : & PublicKey ) {
699708 let mut outer_state_lock = self . per_peer_state . write ( ) . unwrap ( ) ;
700709 if let Some ( peer_state) = outer_state_lock. get_mut ( counterparty_node_id) {
701- peer_state. reset_notification_cooldown ( ) ;
710+ let now = LSPSDateTime :: new_from_duration_since_epoch (
711+ self . time_provider . duration_since_epoch ( ) ,
712+ ) ;
713+ peer_state. reset_notification_cooldown ( now) ;
702714 }
703715 self . check_prune_stale_webhooks ( & mut outer_state_lock) ;
704716 }
@@ -749,6 +761,11 @@ where
749761#[ derive( Debug ) ]
750762pub ( crate ) struct PeerState {
751763 webhooks : Vec < ( LSPS5AppName , Webhook ) > ,
764+ // Timestamp of the last peer lifecycle event that was allowed to clear notification cooldowns.
765+ // This is not the notification cooldown itself: `last_notification_sent` protects clients from
766+ // repeated wake-ups, while this protects registered notification URLs from amplification via
767+ // rapid connection churn.
768+ last_notification_cooldown_reset : Option < LSPSDateTime > ,
752769 needs_persist : bool ,
753770}
754771
@@ -804,10 +821,18 @@ impl PeerState {
804821 removed
805822 }
806823
807- fn reset_notification_cooldown ( & mut self ) {
824+ fn reset_notification_cooldown ( & mut self , now : LSPSDateTime ) {
825+ let can_reset = self . last_notification_cooldown_reset . as_ref ( ) . map_or ( true , |last_reset| {
826+ now. duration_since ( last_reset) >= NOTIFICATION_COOLDOWN_RESET_INTERVAL
827+ } ) ;
828+ if !can_reset {
829+ return ;
830+ }
831+
808832 for ( _, h) in self . webhooks . iter_mut ( ) {
809833 h. last_notification_sent = None ;
810834 }
835+ self . last_notification_cooldown_reset = Some ( now) ;
811836 self . needs_persist |= true ;
812837 }
813838
@@ -831,11 +856,77 @@ impl Default for PeerState {
831856 fn default ( ) -> Self {
832857 let webhooks = Vec :: new ( ) ;
833858 let needs_persist = true ;
834- Self { webhooks, needs_persist }
859+ let last_notification_cooldown_reset = None ;
860+ Self { webhooks, last_notification_cooldown_reset, needs_persist }
835861 }
836862}
837863
838864impl_ser_tlv_based ! ( PeerState , {
839865 ( 0 , webhooks, required_vec) ,
866+ ( _unused, last_notification_cooldown_reset, ( static_value, None :: <LSPSDateTime >) ) ,
840867 ( _unused, needs_persist, ( static_value, false ) ) ,
841868} ) ;
869+
870+ #[ cfg( test) ]
871+ mod tests {
872+ use super :: * ;
873+
874+ use crate :: alloc:: string:: ToString ;
875+ use crate :: tests:: utils:: parse_pubkey;
876+
877+ fn lsps_datetime ( seconds : u64 ) -> LSPSDateTime {
878+ LSPSDateTime :: new_from_duration_since_epoch ( Duration :: from_secs ( seconds) )
879+ }
880+
881+ fn test_webhook ( last_notification_sent : Option < LSPSDateTime > ) -> ( LSPS5AppName , Webhook ) {
882+ let app_name = LSPS5AppName :: new ( "test_app" . to_string ( ) ) . unwrap ( ) ;
883+ let url = LSPS5WebhookUrl :: new ( "https://example.com/webhook" . to_string ( ) ) . unwrap ( ) ;
884+ let counterparty_node_id =
885+ parse_pubkey ( "02c0ded160a4a70d71058509b647949a938924d3a6e109c6eb6aee8e2bb27dc79c" )
886+ . unwrap ( ) ;
887+ let webhook = Webhook {
888+ _app_name : app_name. clone ( ) ,
889+ url,
890+ _counterparty_node_id : counterparty_node_id,
891+ last_used : lsps_datetime ( 1_000 ) ,
892+ last_notification_sent,
893+ } ;
894+ ( app_name, webhook)
895+ }
896+
897+ fn test_peer_state ( last_notification_sent : Option < LSPSDateTime > ) -> PeerState {
898+ PeerState {
899+ webhooks : vec ! [ test_webhook( last_notification_sent) ] ,
900+ last_notification_cooldown_reset : None ,
901+ needs_persist : false ,
902+ }
903+ }
904+
905+ #[ test]
906+ fn reset_notification_cooldown_is_throttled ( ) {
907+ let first_reset = lsps_datetime ( 2_000 ) ;
908+ let mut peer_state = test_peer_state ( Some ( first_reset) ) ;
909+
910+ peer_state. reset_notification_cooldown ( first_reset) ;
911+ assert_eq ! ( peer_state. webhooks( ) [ 0 ] . 1 . last_notification_sent, None ) ;
912+ assert_eq ! ( peer_state. last_notification_cooldown_reset, Some ( first_reset) ) ;
913+ assert ! ( peer_state. needs_persist) ;
914+
915+ peer_state. needs_persist = false ;
916+ let skipped_reset = lsps_datetime ( 2_009 ) ;
917+ let recent_notification = lsps_datetime ( 2_009 ) ;
918+ peer_state. webhooks_mut ( ) [ 0 ] . 1 . last_notification_sent = Some ( recent_notification) ;
919+ peer_state. needs_persist = false ;
920+
921+ peer_state. reset_notification_cooldown ( skipped_reset) ;
922+ assert_eq ! ( peer_state. webhooks( ) [ 0 ] . 1 . last_notification_sent, Some ( recent_notification) ) ;
923+ assert_eq ! ( peer_state. last_notification_cooldown_reset, Some ( first_reset) ) ;
924+ assert ! ( !peer_state. needs_persist) ;
925+
926+ let allowed_reset = lsps_datetime ( 2_010 ) ;
927+ peer_state. reset_notification_cooldown ( allowed_reset) ;
928+ assert_eq ! ( peer_state. webhooks( ) [ 0 ] . 1 . last_notification_sent, None ) ;
929+ assert_eq ! ( peer_state. last_notification_cooldown_reset, Some ( allowed_reset) ) ;
930+ assert ! ( peer_state. needs_persist) ;
931+ }
932+ }
0 commit comments