Skip to content

Commit b81d85e

Browse files
authored
Merge pull request lightningdevkit#4668 from tnull/2026-06-lsps5-webhook-cooldown
Throttle LSPS5 lifecycle cooldown resets
2 parents df3a0d3 + c6099a8 commit b81d85e

3 files changed

Lines changed: 142 additions & 11 deletions

File tree

lightning-liquidity/src/lsps0/ser.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,14 @@ impl LSPSDateTime {
256256
now_seconds_since_epoch > datetime_seconds_since_epoch
257257
}
258258

259-
/// Returns the absolute difference between two datetimes as a `Duration`.
259+
/// Returns the elapsed duration from `other` to `self`, or zero if `other` is later.
260260
pub fn duration_since(&self, other: &Self) -> Duration {
261-
let diff_secs = self.0.timestamp().abs_diff(other.0.timestamp());
262-
Duration::from_secs(diff_secs)
261+
let diff_secs = self.0.timestamp().saturating_sub(other.0.timestamp());
262+
if diff_secs <= 0 {
263+
Duration::ZERO
264+
} else {
265+
Duration::from_secs(diff_secs as u64)
266+
}
263267
}
264268

265269
/// Returns the time in seconds since the unix epoch.
@@ -971,6 +975,8 @@ pub(crate) mod u32_fee_rate {
971975
mod tests {
972976
use super::*;
973977

978+
use core::time::Duration;
979+
974980
use lightning::io::Cursor;
975981

976982
#[test]
@@ -981,4 +987,13 @@ mod tests {
981987
let decoded_datetime: LSPSDateTime = Readable::read(&mut Cursor::new(buf)).unwrap();
982988
assert_eq!(expected_datetime, decoded_datetime);
983989
}
990+
991+
#[test]
992+
fn datetime_duration_since_is_directional() {
993+
let earlier = LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(30));
994+
let later = LSPSDateTime::new_from_duration_since_epoch(Duration::from_secs(90));
995+
996+
assert_eq!(later.duration_since(&earlier), Duration::from_secs(60));
997+
assert_eq!(earlier.duration_since(&later), Duration::ZERO);
998+
}
984999
}

lightning-liquidity/src/lsps5/service.rs

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
8585
pub const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10;
8686
/// Default notification cooldown time in minutes.
8787
pub 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.
9096
impl 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)]
750762
pub(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

838864
impl_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+
}

lightning-liquidity/tests/lsps5_integration_tests.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,7 @@ fn test_notify_without_webhooks_does_nothing() {
13011301
}
13021302

13031303
#[test]
1304-
fn test_notifications_and_peer_connected_resets_cooldown() {
1304+
fn test_notifications_and_peer_connected_reset_is_throttled() {
13051305
let mock_time_provider = Arc::new(MockTimeProvider::new(1000));
13061306
let time_provider = Arc::<MockTimeProvider>::clone(&mock_time_provider);
13071307
let chanmon_cfgs = create_chanmon_cfgs(2);
@@ -1379,7 +1379,7 @@ fn test_notifications_and_peer_connected_resets_cooldown() {
13791379
"Should not emit event due to cooldown"
13801380
);
13811381

1382-
// 5. After peer_connected, notification should be sent again immediately
1382+
// 5. The first peer_connected reset should allow another notification immediately.
13831383
let init_msg = Init {
13841384
features: lightning_types::features::InitFeatures::empty(),
13851385
remote_network_address: None,
@@ -1397,6 +1397,31 @@ fn test_notifications_and_peer_connected_resets_cooldown() {
13971397
},
13981398
_ => panic!("Expected SendWebhookNotification event after peer_connected"),
13991399
}
1400+
1401+
// 6. A rapid peer lifecycle update should not clear the cooldown again.
1402+
service_node.liquidity_manager.peer_disconnected(client_node_id);
1403+
let result = service_handler.notify_payment_incoming(client_node_id);
1404+
let error = result.unwrap_err();
1405+
assert_eq!(error, LSPS5ProtocolError::SlowDownError);
1406+
assert!(
1407+
service_node.liquidity_manager.next_event().is_none(),
1408+
"Should not emit event after a rapid lifecycle reset"
1409+
);
1410+
1411+
// 7. Once the reset throttle has elapsed, peer_connected can reset the cooldown again.
1412+
mock_time_provider.advance_time(11);
1413+
service_node.liquidity_manager.peer_connected(client_node_id, &init_msg, false).unwrap();
1414+
let _ = service_handler.notify_payment_incoming(client_node_id);
1415+
let event = service_node.liquidity_manager.next_event().unwrap();
1416+
match event {
1417+
LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
1418+
notification,
1419+
..
1420+
}) => {
1421+
assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming);
1422+
},
1423+
_ => panic!("Expected SendWebhookNotification event after reset throttle elapsed"),
1424+
}
14001425
}
14011426

14021427
#[test]

0 commit comments

Comments
 (0)