Skip to content

Commit 7c5491d

Browse files
authored
Merge pull request #3975 from martinsaposnic/fix-lsps5-notification-cooldown-logic
LSPS5: Correct notification cooldown & reset logic
2 parents d704eb8 + bae3224 commit 7c5491d

File tree

6 files changed

+208
-41
lines changed

6 files changed

+208
-41
lines changed

lightning-liquidity/src/lsps5/event.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,13 @@ pub enum LSPS5ServiceEvent {
3131
/// The LSP should send an HTTP POST to the [`url`], using the
3232
/// JSON-serialized [`notification`] as the body and including the `headers`.
3333
/// If the HTTP request fails, the LSP may implement a retry policy according to its
34-
/// implementation preferences, but must respect rate-limiting as defined in
35-
/// [`notification_cooldown_hours`].
34+
/// implementation preferences.
3635
///
3736
/// The notification is signed using the LSP's node ID to ensure authenticity
3837
/// when received by the client. The client verifies this signature using
3938
/// [`validate`], which guards against replay attacks and tampering.
4039
///
4140
/// [`validate`]: super::validator::LSPS5Validator::validate
42-
/// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours
4341
/// [`url`]: super::msgs::LSPS5WebhookUrl
4442
/// [`notification`]: super::msgs::WebhookNotification
4543
SendWebhookNotification {

lightning-liquidity/src/lsps5/msgs.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ pub const LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010;
5151
pub const LSPS5_UNKNOWN_ERROR_CODE: i32 = 1000;
5252
/// An error occurred during serialization of LSPS5 webhook notification.
5353
pub const LSPS5_SERIALIZATION_ERROR_CODE: i32 = 1001;
54+
/// A notification was sent too frequently.
55+
pub const LSPS5_SLOW_DOWN_ERROR_CODE: i32 = 1002;
5456

5557
pub(crate) const LSPS5_SET_WEBHOOK_METHOD_NAME: &str = "lsps5.set_webhook";
5658
pub(crate) const LSPS5_LIST_WEBHOOKS_METHOD_NAME: &str = "lsps5.list_webhooks";
@@ -103,10 +105,18 @@ pub enum LSPS5ProtocolError {
103105

104106
/// Error during serialization of LSPS5 webhook notification.
105107
SerializationError,
108+
109+
/// A notification was sent too frequently.
110+
///
111+
/// This error indicates that the LSP is sending notifications
112+
/// too quickly, violating the notification cooldown [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`]
113+
///
114+
/// [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`]: super::service::DEFAULT_NOTIFICATION_COOLDOWN_HOURS
115+
SlowDownError,
106116
}
107117

108118
impl LSPS5ProtocolError {
109-
/// private code range so we never collide with the spec's codes
119+
/// The error code for the LSPS5 protocol error.
110120
pub fn code(&self) -> i32 {
111121
match self {
112122
LSPS5ProtocolError::AppNameTooLong | LSPS5ProtocolError::WebhookUrlTooLong => {
@@ -118,6 +128,7 @@ impl LSPS5ProtocolError {
118128
LSPS5ProtocolError::AppNameNotFound => LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE,
119129
LSPS5ProtocolError::UnknownError => LSPS5_UNKNOWN_ERROR_CODE,
120130
LSPS5ProtocolError::SerializationError => LSPS5_SERIALIZATION_ERROR_CODE,
131+
LSPS5ProtocolError::SlowDownError => LSPS5_SLOW_DOWN_ERROR_CODE,
121132
}
122133
}
123134
/// The error message for the LSPS5 protocol error.
@@ -133,6 +144,7 @@ impl LSPS5ProtocolError {
133144
LSPS5ProtocolError::SerializationError => {
134145
"Error serializing LSPS5 webhook notification"
135146
},
147+
LSPS5ProtocolError::SlowDownError => "Notification sent too frequently",
136148
}
137149
}
138150
}

lightning-liquidity/src/lsps5/service.rs

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ struct StoredWebhook {
5151
_app_name: LSPS5AppName,
5252
url: LSPS5WebhookUrl,
5353
_counterparty_node_id: PublicKey,
54+
// Timestamp used for tracking when the webhook was created / updated, or when the last notification was sent.
55+
// This is used to determine if the webhook is stale and should be pruned.
5456
last_used: LSPSDateTime,
57+
// Map of last notification sent timestamps for each notification method.
58+
// This is used to enforce notification cooldowns.
5559
last_notification_sent: HashMap<WebhookNotificationMethod, LSPSDateTime>,
5660
}
5761

@@ -60,8 +64,18 @@ struct StoredWebhook {
6064
pub struct LSPS5ServiceConfig {
6165
/// Maximum number of webhooks allowed per client.
6266
pub max_webhooks_per_client: u32,
63-
/// Minimum time between sending the same notification type in hours (default: 24)
64-
pub notification_cooldown_hours: Duration,
67+
}
68+
69+
/// Default maximum number of webhooks allowed per client.
70+
pub const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10;
71+
/// Default notification cooldown time in hours.
72+
pub const DEFAULT_NOTIFICATION_COOLDOWN_HOURS: Duration = Duration::from_secs(60 * 60); // 1 hour
73+
74+
// Default configuration for LSPS5 service.
75+
impl Default for LSPS5ServiceConfig {
76+
fn default() -> Self {
77+
Self { max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT }
78+
}
6579
}
6680

6781
/// Service-side handler for the [`bLIP-55 / LSPS5`] webhook registration protocol.
@@ -78,8 +92,6 @@ pub struct LSPS5ServiceConfig {
7892
/// - `lsps5.remove_webhook` -> delete a named webhook or return [`app_name_not_found`] error.
7993
/// - Prune stale webhooks after a client has no open channels and no activity for at least
8094
/// [`MIN_WEBHOOK_RETENTION_DAYS`].
81-
/// - Rate-limit repeat notifications of the same method to a client by
82-
/// [`notification_cooldown_hours`].
8395
/// - Sign and enqueue outgoing webhook notifications:
8496
/// - Construct JSON-RPC 2.0 Notification objects [`WebhookNotification`],
8597
/// - Timestamp and LN-style zbase32-sign each payload,
@@ -94,7 +106,6 @@ pub struct LSPS5ServiceConfig {
94106
/// [`bLIP-55 / LSPS5`]: https://github.com/lightning/blips/pull/55/files
95107
/// [`max_webhooks_per_client`]: super::service::LSPS5ServiceConfig::max_webhooks_per_client
96108
/// [`app_name_not_found`]: super::msgs::LSPS5ProtocolError::AppNameNotFound
97-
/// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours
98109
/// [`WebhookNotification`]: super::msgs::WebhookNotification
99110
/// [`LSPS5ServiceEvent::SendWebhookNotification`]: super::event::LSPS5ServiceEvent::SendWebhookNotification
100111
/// [`app_name`]: super::msgs::LSPS5AppName
@@ -318,10 +329,14 @@ where
318329
/// This builds a [`WebhookNotificationMethod::LSPS5PaymentIncoming`] webhook notification, signs it with your
319330
/// node key, and enqueues HTTP POSTs to all registered webhook URLs for that client.
320331
///
332+
/// This may fail if a similar notification was sent too recently,
333+
/// violating the notification cooldown period defined in [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`].
334+
///
321335
/// # Parameters
322336
/// - `client_id`: the client's node-ID whose webhooks should be invoked.
323337
///
324338
/// [`WebhookNotificationMethod::LSPS5PaymentIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5PaymentIncoming
339+
/// [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`]: super::service::DEFAULT_NOTIFICATION_COOLDOWN_HOURS
325340
pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LSPS5ProtocolError> {
326341
let notification = WebhookNotification::payment_incoming();
327342
self.send_notifications_to_client_webhooks(client_id, notification)
@@ -335,11 +350,15 @@ where
335350
/// the `timeout` block height, signs it, and enqueues HTTP POSTs to the client's
336351
/// registered webhooks.
337352
///
353+
/// This may fail if a similar notification was sent too recently,
354+
/// violating the notification cooldown period defined in [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`].
355+
///
338356
/// # Parameters
339357
/// - `client_id`: the client's node-ID whose webhooks should be invoked.
340358
/// - `timeout`: the block height at which the channel contract will expire.
341359
///
342360
/// [`WebhookNotificationMethod::LSPS5ExpirySoon`]: super::msgs::WebhookNotificationMethod::LSPS5ExpirySoon
361+
/// [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`]: super::service::DEFAULT_NOTIFICATION_COOLDOWN_HOURS
343362
pub fn notify_expiry_soon(
344363
&self, client_id: PublicKey, timeout: u32,
345364
) -> Result<(), LSPS5ProtocolError> {
@@ -353,10 +372,14 @@ where
353372
/// liquidity for `client_id`. Builds a [`WebhookNotificationMethod::LSPS5LiquidityManagementRequest`] notification,
354373
/// signs it, and sends it to all of the client's registered webhook URLs.
355374
///
375+
/// This may fail if a similar notification was sent too recently,
376+
/// violating the notification cooldown period defined in [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`].
377+
///
356378
/// # Parameters
357379
/// - `client_id`: the client's node-ID whose webhooks should be invoked.
358380
///
359381
/// [`WebhookNotificationMethod::LSPS5LiquidityManagementRequest`]: super::msgs::WebhookNotificationMethod::LSPS5LiquidityManagementRequest
382+
/// [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`]: super::service::DEFAULT_NOTIFICATION_COOLDOWN_HOURS
360383
pub fn notify_liquidity_management_request(
361384
&self, client_id: PublicKey,
362385
) -> Result<(), LSPS5ProtocolError> {
@@ -370,10 +393,14 @@ where
370393
/// for `client_id` while the client is offline. Builds a [`WebhookNotificationMethod::LSPS5OnionMessageIncoming`]
371394
/// notification, signs it, and enqueues HTTP POSTs to each registered webhook.
372395
///
396+
/// This may fail if a similar notification was sent too recently,
397+
/// violating the notification cooldown period defined in [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`].
398+
///
373399
/// # Parameters
374400
/// - `client_id`: the client's node-ID whose webhooks should be invoked.
375401
///
376402
/// [`WebhookNotificationMethod::LSPS5OnionMessageIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5OnionMessageIncoming
403+
/// [`DEFAULT_NOTIFICATION_COOLDOWN_HOURS`]: super::service::DEFAULT_NOTIFICATION_COOLDOWN_HOURS
377404
pub fn notify_onion_message_incoming(
378405
&self, client_id: PublicKey,
379406
) -> Result<(), LSPS5ProtocolError> {
@@ -394,24 +421,34 @@ where
394421
let now =
395422
LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch());
396423

397-
for (app_name, webhook) in client_webhooks.iter_mut() {
398-
if webhook
399-
.last_notification_sent
400-
.get(&notification.method)
401-
.map(|last_sent| now.clone().abs_diff(&last_sent))
402-
.map_or(true, |duration| {
403-
duration >= self.config.notification_cooldown_hours.as_secs()
404-
}) {
405-
webhook.last_notification_sent.insert(notification.method.clone(), now.clone());
406-
webhook.last_used = now.clone();
407-
self.send_notification(
408-
client_id,
409-
app_name.clone(),
410-
webhook.url.clone(),
411-
notification.clone(),
412-
)?;
424+
// We must avoid sending multiple notifications of the same method
425+
// (other than lsps5.webhook_registered) close in time.
426+
if notification.method != WebhookNotificationMethod::LSPS5WebhookRegistered {
427+
let rate_limit_applies = client_webhooks.iter().any(|(_, webhook)| {
428+
webhook
429+
.last_notification_sent
430+
.get(&notification.method)
431+
.map(|last_sent| now.abs_diff(&last_sent))
432+
.map_or(false, |duration| {
433+
duration < DEFAULT_NOTIFICATION_COOLDOWN_HOURS.as_secs()
434+
})
435+
});
436+
437+
if rate_limit_applies {
438+
return Err(LSPS5ProtocolError::SlowDownError);
413439
}
414440
}
441+
442+
for (app_name, webhook) in client_webhooks.iter_mut() {
443+
webhook.last_notification_sent.insert(notification.method.clone(), now.clone());
444+
webhook.last_used = now.clone();
445+
self.send_notification(
446+
client_id,
447+
app_name.clone(),
448+
webhook.url.clone(),
449+
notification.clone(),
450+
)?;
451+
}
415452
Ok(())
416453
}
417454

@@ -487,6 +524,15 @@ where
487524
.iter()
488525
.any(|c| c.is_usable && c.counterparty.node_id == *client_id)
489526
}
527+
528+
pub(crate) fn peer_connected(&self, counterparty_node_id: &PublicKey) {
529+
let mut webhooks = self.webhooks.lock().unwrap();
530+
if let Some(client_webhooks) = webhooks.get_mut(counterparty_node_id) {
531+
for webhook in client_webhooks.values_mut() {
532+
webhook.last_notification_sent.clear();
533+
}
534+
}
535+
}
490536
}
491537

492538
impl<CM: Deref, NS: Deref, TP: Deref> LSPSProtocolMessageHandler for LSPS5ServiceHandler<CM, NS, TP>

lightning-liquidity/src/manager.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,8 +714,13 @@ where
714714
}
715715
}
716716
fn peer_connected(
717-
&self, _: bitcoin::secp256k1::PublicKey, _: &lightning::ln::msgs::Init, _: bool,
717+
&self, counterparty_node_id: bitcoin::secp256k1::PublicKey, _: &lightning::ln::msgs::Init,
718+
_: bool,
718719
) -> Result<(), ()> {
720+
if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() {
721+
lsps5_service_handler.peer_connected(&counterparty_node_id);
722+
}
723+
719724
Ok(())
720725
}
721726
}

lightning-liquidity/tests/lsps0_integration_tests.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ use lightning::ln::functional_test_utils::{
2323
use lightning::ln::peer_handler::CustomMessageHandler;
2424

2525
use std::sync::Arc;
26-
use std::time::Duration;
2726

2827
#[test]
2928
fn list_protocols_integration_test() {
@@ -36,10 +35,7 @@ fn list_protocols_integration_test() {
3635
let lsps2_service_config = LSPS2ServiceConfig { promise_secret };
3736
#[cfg(lsps1_service)]
3837
let lsps1_service_config = LSPS1ServiceConfig { supported_options: None, token: None };
39-
let lsps5_service_config = LSPS5ServiceConfig {
40-
max_webhooks_per_client: 10,
41-
notification_cooldown_hours: Duration::from_secs(3600),
42-
};
38+
let lsps5_service_config = LSPS5ServiceConfig::default();
4339
let service_config = LiquidityServiceConfig {
4440
#[cfg(lsps1_service)]
4541
lsps1_service_config: Some(lsps1_service_config),

0 commit comments

Comments
 (0)