Skip to content

Commit 89962a1

Browse files
LSPS5: Correct notification cooldown & reset logic
per method cooldown enforced correctly, and reset after peer_connected event
1 parent e01663a commit 89962a1

File tree

5 files changed

+149
-27
lines changed

5 files changed

+149
-27
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/service.rs

Lines changed: 27 additions & 8 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
@@ -399,9 +410,8 @@ where
399410
.last_notification_sent
400411
.get(&notification.method)
401412
.map(|last_sent| now.clone().abs_diff(&last_sent))
402-
.map_or(true, |duration| {
403-
duration >= self.config.notification_cooldown_hours.as_secs()
404-
}) {
413+
.map_or(true, |duration| duration >= DEFAULT_NOTIFICATION_COOLDOWN_HOURS.as_secs())
414+
{
405415
webhook.last_notification_sent.insert(notification.method.clone(), now.clone());
406416
webhook.last_used = now.clone();
407417
self.send_notification(
@@ -487,6 +497,15 @@ where
487497
.iter()
488498
.any(|c| c.is_usable && c.counterparty.node_id == *client_id)
489499
}
500+
501+
pub(crate) fn peer_connected(&self, counterparty_node_id: &PublicKey) {
502+
let mut webhooks = self.webhooks.lock().unwrap();
503+
if let Some(client_webhooks) = webhooks.get_mut(counterparty_node_id) {
504+
for webhook in client_webhooks.values_mut() {
505+
webhook.last_notification_sent.clear();
506+
}
507+
}
508+
}
490509
}
491510

492511
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
@@ -715,8 +715,13 @@ where
715715
}
716716
}
717717
fn peer_connected(
718-
&self, _: bitcoin::secp256k1::PublicKey, _: &lightning::ln::msgs::Init, _: bool,
718+
&self, counterparty_node_id: bitcoin::secp256k1::PublicKey, _: &lightning::ln::msgs::Init,
719+
_: bool,
719720
) -> Result<(), ()> {
721+
if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() {
722+
lsps5_service_handler.peer_connected(&counterparty_node_id);
723+
}
724+
720725
Ok(())
721726
}
722727
}

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),

lightning-liquidity/tests/lsps5_integration_tests.rs

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use common::{create_service_and_client_nodes, get_lsps_message, LSPSNodes};
77
use lightning::ln::functional_test_utils::{
88
create_chanmon_cfgs, create_network, create_node_cfgs, create_node_chanmgrs, Node,
99
};
10+
use lightning::ln::msgs::Init;
1011
use lightning::ln::peer_handler::CustomMessageHandler;
1112
use lightning::util::hash_tables::{HashMap, HashSet};
1213
use lightning_liquidity::events::LiquidityEvent;
@@ -17,7 +18,9 @@ use lightning_liquidity::lsps5::msgs::{
1718
LSPS5AppName, LSPS5ClientError, LSPS5ProtocolError, LSPS5WebhookUrl, WebhookNotification,
1819
WebhookNotificationMethod,
1920
};
20-
use lightning_liquidity::lsps5::service::LSPS5ServiceConfig;
21+
use lightning_liquidity::lsps5::service::{
22+
LSPS5ServiceConfig, DEFAULT_MAX_WEBHOOKS_PER_CLIENT, DEFAULT_NOTIFICATION_COOLDOWN_HOURS,
23+
};
2124
use lightning_liquidity::lsps5::service::{
2225
MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS,
2326
};
@@ -27,18 +30,10 @@ use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig};
2730
use std::sync::{Arc, RwLock};
2831
use std::time::Duration;
2932

30-
/// Default maximum number of webhooks allowed per client.
31-
pub(crate) const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10;
32-
/// Default notification cooldown time in hours.
33-
pub(crate) const DEFAULT_NOTIFICATION_COOLDOWN_HOURS: Duration = Duration::from_secs(24 * 60 * 60);
34-
3533
pub(crate) fn lsps5_test_setup<'a, 'b, 'c>(
3634
nodes: Vec<Node<'a, 'b, 'c>>, time_provider: Arc<dyn TimeProvider + Send + Sync>,
3735
) -> (LSPSNodes<'a, 'b, 'c>, LSPS5Validator) {
38-
let lsps5_service_config = LSPS5ServiceConfig {
39-
max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT,
40-
notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS,
41-
};
36+
let lsps5_service_config = LSPS5ServiceConfig::default();
4237
let service_config = LiquidityServiceConfig {
4338
#[cfg(lsps1_service)]
4439
lsps1_service_config: None,
@@ -1053,3 +1048,112 @@ fn test_notify_without_webhooks_does_nothing() {
10531048
let _ = service_handler.notify_onion_message_incoming(client_node_id);
10541049
assert!(service_node.liquidity_manager.next_event().is_none());
10551050
}
1051+
1052+
#[test]
1053+
fn test_send_notifications_and_peer_connected_resets_cooldown() {
1054+
let mock_time_provider = Arc::new(MockTimeProvider::new(1000));
1055+
let time_provider = Arc::<MockTimeProvider>::clone(&mock_time_provider);
1056+
let chanmon_cfgs = create_chanmon_cfgs(2);
1057+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
1058+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
1059+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
1060+
let (lsps_nodes, _) = lsps5_test_setup(nodes, time_provider);
1061+
let LSPSNodes { service_node, client_node } = lsps_nodes;
1062+
let service_node_id = service_node.inner.node.get_our_node_id();
1063+
let client_node_id = client_node.inner.node.get_our_node_id();
1064+
1065+
let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap();
1066+
let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap();
1067+
1068+
let app_name = "CooldownTestApp";
1069+
let webhook_url = "https://www.example.org/cooldown";
1070+
let _ = client_handler
1071+
.set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string())
1072+
.expect("Register webhook request should succeed");
1073+
let set_req = get_lsps_message!(client_node, service_node_id);
1074+
service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap();
1075+
1076+
let _ = service_node.liquidity_manager.next_event().unwrap();
1077+
1078+
let resp = get_lsps_message!(service_node, client_node_id);
1079+
client_node.liquidity_manager.handle_custom_message(resp, service_node_id).unwrap();
1080+
let _ = client_node.liquidity_manager.next_event().unwrap();
1081+
1082+
// 1. First notification should be sent
1083+
let _ = service_handler.notify_payment_incoming(client_node_id);
1084+
let event = service_node.liquidity_manager.next_event().unwrap();
1085+
match event {
1086+
LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
1087+
notification,
1088+
..
1089+
}) => {
1090+
assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming);
1091+
},
1092+
_ => panic!("Expected SendWebhookNotification event"),
1093+
}
1094+
1095+
// 2. Second notification before cooldown should NOT be sent
1096+
let _ = service_handler.notify_payment_incoming(client_node_id);
1097+
assert!(
1098+
service_node.liquidity_manager.next_event().is_none(),
1099+
"Should not emit event due to cooldown"
1100+
);
1101+
1102+
// 3. Notification of a different method CAN be sent
1103+
let timeout_block = 424242;
1104+
let _ = service_handler.notify_expiry_soon(client_node_id, timeout_block);
1105+
let event = service_node.liquidity_manager.next_event().unwrap();
1106+
match event {
1107+
LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
1108+
notification,
1109+
..
1110+
}) => {
1111+
assert!(matches!(
1112+
notification.method,
1113+
WebhookNotificationMethod::LSPS5ExpirySoon { timeout } if timeout == timeout_block
1114+
));
1115+
},
1116+
_ => panic!("Expected SendWebhookNotification event for expiry_soon"),
1117+
}
1118+
1119+
// 4. Advance time past cooldown and ensure payment_incoming can be sent again
1120+
mock_time_provider.advance_time(DEFAULT_NOTIFICATION_COOLDOWN_HOURS.as_secs() + 1);
1121+
1122+
let _ = service_handler.notify_payment_incoming(client_node_id);
1123+
let event = service_node.liquidity_manager.next_event().unwrap();
1124+
match event {
1125+
LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
1126+
notification,
1127+
..
1128+
}) => {
1129+
assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming);
1130+
},
1131+
_ => panic!("Expected SendWebhookNotification event after cooldown"),
1132+
}
1133+
1134+
// 5. Can't send payment_incoming notification again immediately after cooldown
1135+
let _ = service_handler.notify_payment_incoming(client_node_id);
1136+
assert!(
1137+
service_node.liquidity_manager.next_event().is_none(),
1138+
"Should not emit event due to cooldown"
1139+
);
1140+
1141+
// 6. After peer_connected, notification should be sent again immediately
1142+
let init_msg = Init {
1143+
features: lightning_types::features::InitFeatures::empty(),
1144+
remote_network_address: None,
1145+
networks: None,
1146+
};
1147+
service_node.liquidity_manager.peer_connected(client_node_id, &init_msg, false).unwrap();
1148+
let _ = service_handler.notify_payment_incoming(client_node_id);
1149+
let event = service_node.liquidity_manager.next_event().unwrap();
1150+
match event {
1151+
LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification {
1152+
notification,
1153+
..
1154+
}) => {
1155+
assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming);
1156+
},
1157+
_ => panic!("Expected SendWebhookNotification event after peer_connected"),
1158+
}
1159+
}

0 commit comments

Comments
 (0)