Skip to content

Commit 2601d0f

Browse files
committed
Add LSPS5 (bLIP-55) webhook notification support
Implement the bLIP-55 / LSPS5 webhook registration protocol on top of the multi-LSP liquidity module (src/liquidity/{client,service}). Client side, exposed via Node::liquidity().lsps5(): - set_webhook / list_webhooks / remove_webhook to manage webhook registrations with a given LSP. Each takes the LSP's node ID explicitly: bLIP-55 has the notification service verify the x-lsps5-signature header against the signing LSP's node ID, so a registration is meaningful only for one LSP at a time. Service side, enabled by passing an LSPS5ServiceConfig to Builder::enable_liquidity_provider(): - Deliver outgoing webhook notifications over HTTPS in response to LSPS5ServiceEvent::SendWebhookNotification. - Send lsps5.payment_incoming when an inbound HTLC forward to a client fails because the client is offline (wired from LdkEvent:: HTLCHandlingFailed with LocalHTLCFailureReason::PeerOffline). - Send lsps5.onion_message_incoming when an intercepted onion message targets a client that is currently offline. - Send lsps5.expiry_soon from a periodic task that scans channels for outbound HTLCs approaching their cltv_expiry, so a client that went offline holding an HTLC has a chance to come online and settle before it expires. Adds integration tests covering webhook registration and the payment_incoming trigger, and wires the feature through the UniFFI bindings.
1 parent 0428cab commit 2601d0f

13 files changed

Lines changed: 1418 additions & 43 deletions

File tree

bindings/ldk_node.udl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ enum NodeError {
206206
"InvalidLnurl",
207207
"ChainSourceNotSupported",
208208
"InvalidPayerProof",
209+
"LiquiditySetWebhookFailed",
210+
"LiquidityRemoveWebhookFailed",
211+
"LiquidityListWebhooksFailed",
212+
"LiquidityNotifyWebhookFailed",
213+
"LiquidityWebhookLimitExceeded",
214+
"LiquidityWebhookNoPriorActivity",
215+
"LiquidityWebhookAppNameNotFound",
216+
"LiquidityWebhookInvalid"
209217
};
210218

211219
typedef dictionary NodeStatus;

src/builder.rs

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ use crate::runtime::{Runtime, RuntimeSpawner};
100100
use crate::tx_broadcaster::TransactionBroadcaster;
101101
use crate::types::{
102102
AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper,
103-
GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager,
104-
PendingPaymentStore,
103+
GossipSync, Graph, KeysManager, LSPS5ServiceConfig, MessageRouter, OnionMessenger,
104+
PaymentStore, PeerManager, PendingPaymentStore,
105105
};
106106
use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister};
107107
use crate::wallet::Wallet;
@@ -144,10 +144,12 @@ struct PathfindingScoresSyncConfig {
144144

145145
#[derive(Debug, Clone, Default)]
146146
struct LiquiditySourceConfig {
147-
// Acts for both LSPS1 and LSPS2 clients connecting to the given service.
147+
// Acts for LSPS1, LSPS2 and LSPS5 clients connecting to the given service.
148148
lsp_nodes: Vec<LspConfig>,
149149
// Act as an LSPS2 service.
150150
lsps2_service: Option<LSPS2ServiceConfig>,
151+
// Act as an LSPS5 service.
152+
lsps5_service: Option<LSPS5ServiceConfig>,
151153
}
152154

153155
#[derive(Clone)]
@@ -532,18 +534,26 @@ impl NodeBuilder {
532534
self
533535
}
534536

535-
/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
536-
/// channels to clients.
537+
/// Configures the [`Node`] instance to provide [bLIP-52 / LSPS2] and/or [bLIP-55 / LSPS5]
538+
/// services to clients.
539+
///
540+
/// [bLIP-52 / LSPS2] issues just-in-time channels to clients, [bLIP-55 / LSPS5] allows clients
541+
/// to register webhooks for push notifications.
542+
///
543+
/// Passing `None` leaves the respective service disabled.
537544
///
538545
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
539546
///
540-
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
547+
/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md
548+
/// [bLIP-55 / LSPS5]: https://github.com/lightning/blips/blob/master/blip-0055.md
541549
pub fn enable_liquidity_provider(
542-
&mut self, lsps2_service_config: LSPS2ServiceConfig,
550+
&mut self, lsps2_service_config: Option<LSPS2ServiceConfig>,
551+
lsps5_service_config: Option<LSPS5ServiceConfig>,
543552
) -> &mut Self {
544553
let liquidity_source_config =
545554
self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default());
546-
liquidity_source_config.lsps2_service = Some(lsps2_service_config);
555+
liquidity_source_config.lsps2_service = lsps2_service_config;
556+
liquidity_source_config.lsps5_service = lsps5_service_config;
547557
self
548558
}
549559

@@ -1171,14 +1181,26 @@ impl Builder {
11711181

11721182
#[cfg(feature = "uniffi")]
11731183
impl ArcedNodeBuilder {
1174-
/// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time
1175-
/// channels to clients.
1184+
/// Configures the [`Node`] instance to provide [bLIP-52 / LSPS2] and/or [bLIP-55 / LSPS5]
1185+
/// services to clients.
1186+
///
1187+
/// [bLIP-52 / LSPS2] issues just-in-time channels to clients, [bLIP-55 / LSPS5] allows clients
1188+
/// to register webhooks for push notifications.
1189+
///
1190+
/// Passing `None` leaves the respective service disabled.
11761191
///
11771192
/// **Caution**: LSP service support is in **alpha** and is considered an experimental feature.
11781193
///
1179-
/// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md
1180-
pub fn enable_liquidity_provider(&self, lsps2_service_config: LSPS2ServiceConfig) {
1181-
self.inner.write().expect("lock").enable_liquidity_provider(lsps2_service_config);
1194+
/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md
1195+
/// [bLIP-55 / LSPS5]: https://github.com/lightning/blips/blob/master/blip-0055.md
1196+
pub fn enable_liquidity_provider(
1197+
&self, lsps2_service_config: Option<LSPS2ServiceConfig>,
1198+
lsps5_service_config: Option<LSPS5ServiceConfig>,
1199+
) {
1200+
self.inner
1201+
.write()
1202+
.expect("lock")
1203+
.enable_liquidity_provider(lsps2_service_config, lsps5_service_config);
11821204
}
11831205
}
11841206

@@ -2256,6 +2278,7 @@ fn build_with_store_internal(
22562278
Arc::clone(&tx_broadcaster),
22572279
Arc::clone(&kv_store),
22582280
Arc::clone(&config),
2281+
Arc::clone(&runtime),
22592282
Arc::clone(&logger),
22602283
);
22612284

@@ -2274,6 +2297,10 @@ fn build_with_store_internal(
22742297
lsc.lsps2_service.as_ref().map(|config| {
22752298
liquidity_source_builder.lsps2_service(promise_secret, config.clone())
22762299
});
2300+
2301+
lsc.lsps5_service
2302+
.as_ref()
2303+
.map(|config| liquidity_source_builder.lsps5_service(config.clone()));
22772304
}
22782305

22792306
let liquidity_source = runtime
@@ -2334,6 +2361,8 @@ fn build_with_store_internal(
23342361

23352362
liquidity_source.lsps2_service().set_peer_manager(Arc::downgrade(&peer_manager));
23362363

2364+
liquidity_source.lsps5_service().set_peer_manager(Arc::downgrade(&peer_manager));
2365+
23372366
let connection_manager = Arc::new(ConnectionManager::new(
23382367
Arc::clone(&peer_manager),
23392368
config.tor_config.clone(),

src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use std::time::Duration;
1414

1515
use bitcoin::secp256k1::PublicKey;
1616
use bitcoin::Network;
17+
use lightning::chain::channelmonitor::HTLC_FAIL_BACK_BUFFER;
1718
use lightning::ln::msgs::SocketAddress;
1819
use lightning::routing::gossip::NodeAlias;
1920
use lightning::routing::router::RouteParametersConfig;
@@ -169,6 +170,28 @@ pub(crate) const LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY: Duration = Duration::f
169170
// thereafter until every configured LSP has been discovered.
170171
pub(crate) const LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY: Duration = Duration::from_secs(60 * 60);
171172

173+
// The timeout after which we abort a LSPS5 webhook notification operation.
174+
pub(crate) const LSPS5_WEBHOOK_TIMEOUT_SECS: u64 = 30;
175+
176+
// The maximum size of a response body we'll accept when delivering an LSPS5 webhook notification.
177+
pub(crate) const LSPS5_WEBHOOK_MAX_RESPONSE_SIZE: usize = 64 * 1024;
178+
179+
// The time in-between checks for HTLCs approaching expiry on LSPS5 clients' channels.
180+
pub(crate) const LSPS5_EXPIRY_CHECK_INTERVAL: Duration = Duration::from_secs(60);
181+
182+
// The number of blocks we wait before notifying a client about the same expiring HTLCs again.
183+
pub(crate) const LSPS5_EXPIRY_RENOTIFY_INTERVAL_BLOCKS: u32 = 6;
184+
185+
// The number of blocks before an outbound HTLC's expiry at which we start notifying offline
186+
// LSPS5 clients.
187+
//
188+
// A client that doesn't come online and settle before `cltv_expiry` loses the payment, and LDK
189+
// force-closes the channel shortly after (`cltv_expiry + LATENCY_GRACE_PERIOD_BLOCKS`). We anchor
190+
// the lead time on `HTLC_FAIL_BACK_BUFFER`, the margin LDK itself treats as too close to expiry to
191+
// safely handle an HTLC, and double it to leave the client room to receive the notification and
192+
// act on it.
193+
pub(crate) const LSPS5_EXPIRY_NOTIFICATION_THRESHOLD_BLOCKS: u32 = HTLC_FAIL_BACK_BUFFER * 2;
194+
172195
#[derive(Debug, Clone)]
173196
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
174197
/// Represents the configuration of an [`Node`] instance.

src/error.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ pub enum Error {
147147
ChainSourceNotSupported,
148148
/// The provided payer proof is invalid.
149149
InvalidPayerProof,
150+
/// Failed to set a webhook with the LSP.
151+
LiquiditySetWebhookFailed,
152+
/// Failed to remove a webhook with the LSP.
153+
LiquidityRemoveWebhookFailed,
154+
/// Failed to list webhooks with the LSP.
155+
LiquidityListWebhooksFailed,
156+
/// Failed to send a webhook notification to a client.
157+
LiquidityNotifyWebhookFailed,
158+
/// The LSP rejected a webhook registration because the client has reached the maximum number
159+
/// of webhooks the LSP allows.
160+
LiquidityWebhookLimitExceeded,
161+
/// The LSP rejected a webhook registration because we have no prior activity with it.
162+
///
163+
/// LSPs typically require an open channel, or an in-flight LSPS1 or LSPS2 flow, before
164+
/// accepting webhook registrations.
165+
LiquidityWebhookNoPriorActivity,
166+
/// No webhook is registered under the given `app_name` with the LSP.
167+
LiquidityWebhookAppNameNotFound,
168+
/// The `app_name` or webhook URL is invalid.
169+
///
170+
/// The `app_name` may exceed 64 bytes, or the URL may exceed 1024 bytes, fail to parse, or
171+
/// not use the `https` scheme.
172+
LiquidityWebhookInvalid,
150173
}
151174

152175
impl fmt::Display for Error {
@@ -239,6 +262,33 @@ impl fmt::Display for Error {
239262
write!(f, "The configured chain source is not supported.")
240263
},
241264
Self::InvalidPayerProof => write!(f, "The provided payer proof is invalid."),
265+
Self::LiquiditySetWebhookFailed => {
266+
write!(f, "Failed to set a webhook with the LSP.")
267+
},
268+
Self::LiquidityRemoveWebhookFailed => {
269+
write!(f, "Failed to remove a webhook with the LSP.")
270+
},
271+
Self::LiquidityListWebhooksFailed => {
272+
write!(f, "Failed to list webhooks with the LSP.")
273+
},
274+
Self::LiquidityNotifyWebhookFailed => {
275+
write!(f, "Failed to send a webhook notification to a client.")
276+
},
277+
Self::LiquidityWebhookLimitExceeded => {
278+
write!(
279+
f,
280+
"The LSP's maximum number of webhooks for this client is already reached."
281+
)
282+
},
283+
Self::LiquidityWebhookNoPriorActivity => {
284+
write!(f, "The LSP rejected the webhook registration due to no prior activity.")
285+
},
286+
Self::LiquidityWebhookAppNameNotFound => {
287+
write!(f, "No webhook is registered under the given app name with this LSP.")
288+
},
289+
Self::LiquidityWebhookInvalid => {
290+
write!(f, "The given app name or webhook URL is invalid.")
291+
},
242292
}
243293
}
244294
}

src/event.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ use lightning::events::bump_transaction::BumpTransactionEvent;
1919
#[cfg(not(feature = "uniffi"))]
2020
use lightning::events::PaidBolt12Invoice;
2121
use lightning::events::{
22-
ClosureReason, Event as LdkEvent, FundingInfo, HTLCLocator as LdkHtlcLocator,
23-
PaymentFailureReason, PaymentPurpose, ReplayEvent,
22+
ClosureReason, Event as LdkEvent, FundingInfo, HTLCHandlingFailureReason,
23+
HTLCHandlingFailureType, HTLCLocator as LdkHtlcLocator, PaymentFailureReason, PaymentPurpose,
24+
ReplayEvent,
2425
};
2526
use lightning::ln::channelmanager::{PaymentId, TrustedChannelFeatures};
27+
use lightning::ln::onion_utils::LocalHTLCFailureReason;
2628
use lightning::ln::types::ChannelId;
2729
use lightning::routing::gossip::NodeId;
2830
use lightning::sign::EntropySource;
@@ -1499,11 +1501,29 @@ where
14991501
prober.handle_background_probe_failed(&path, payment_id);
15001502
}
15011503
},
1502-
LdkEvent::HTLCHandlingFailed { failure_type, .. } => {
1504+
LdkEvent::HTLCHandlingFailed { failure_type, failure_reason, .. } => {
1505+
// Capture the client's node id before `failure_type` is consumed below. A forward
1506+
// that failed only because the next-hop peer was offline is our cue to wake an
1507+
// LSPS5 client. The HTLC is failed back as `temporary_channel_failure`, which is
1508+
// not permanent, so the sender can retry once the client is online.
1509+
let offline_node_id = match (&failure_type, &failure_reason) {
1510+
(
1511+
HTLCHandlingFailureType::Forward { node_id: Some(node_id), .. },
1512+
Some(HTLCHandlingFailureReason::Local {
1513+
reason: LocalHTLCFailureReason::PeerOffline,
1514+
}),
1515+
) => Some(*node_id),
1516+
_ => None,
1517+
};
1518+
15031519
self.liquidity_source
15041520
.lsps2_service()
15051521
.handle_htlc_handling_failed(failure_type)
15061522
.await;
1523+
1524+
if let Some(node_id) = offline_node_id {
1525+
self.liquidity_source.lsps5_service().notify_payment_incoming(node_id);
1526+
}
15071527
},
15081528
LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => {
15091529
match self
@@ -1987,6 +2007,8 @@ where
19872007
debug_assert!(false, "We currently don't handle BOLT12 invoices manually, so this event should never be emitted.");
19882008
},
19892009
LdkEvent::ConnectionNeeded { node_id, addresses } => {
2010+
self.liquidity_source.lsps5_service().notify_onion_message_incoming(node_id);
2011+
19902012
let spawn_logger = self.logger.clone();
19912013
let spawn_cm = Arc::clone(&self.connection_manager);
19922014
let future = async move {
@@ -2045,6 +2067,9 @@ where
20452067
"Onion message intercepted, but no onion message mailbox available"
20462068
);
20472069
}
2070+
self.liquidity_source
2071+
.lsps5_service()
2072+
.notify_onion_message_incoming(peer_node_id);
20482073
} else {
20492074
log_error!(self.logger, "Onion message intercepted for unknown SCID");
20502075
}

src/lib.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ pub use types::{
197197
#[cfg(feature = "storage-vss")]
198198
pub use vss_client;
199199

200-
use crate::config::{LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY, LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY};
200+
use crate::config::{
201+
LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY, LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY,
202+
LSPS5_EXPIRY_CHECK_INTERVAL,
203+
};
201204
use crate::ffi::{maybe_deref, maybe_wrap};
202205
use crate::liquidity::Liquidity;
203206
use crate::scoring::setup_background_pathfinding_scores_sync;
@@ -836,6 +839,28 @@ impl Node {
836839
}
837840
});
838841

842+
// Regularly notify offline LSPS5 clients about HTLCs approaching their expiry.
843+
if self.liquidity_source.liquidity_manager().lsps5_service_handler().is_some() {
844+
let expiry_liquidity_source = Arc::clone(&self.liquidity_source);
845+
let expiry_liquidy_logger = Arc::clone(&self.logger);
846+
let mut stop_expiry = self.stop_sender.subscribe();
847+
self.runtime.spawn_cancellable_background_task(async move {
848+
let mut interval = tokio::time::interval(LSPS5_EXPIRY_CHECK_INTERVAL);
849+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
850+
loop {
851+
tokio::select! {
852+
_ = stop_expiry.changed() => {
853+
log_debug!(expiry_liquidy_logger, "Stopping LSPS5 HTLC expiry checks.");
854+
return;
855+
}
856+
_ = interval.tick() => {
857+
expiry_liquidity_source.lsps5_service().check_expiring_htlcs();
858+
}
859+
}
860+
}
861+
});
862+
}
863+
839864
log_info!(self.logger, "Startup complete.");
840865
*is_running_lock = true;
841866
Ok(())

0 commit comments

Comments
 (0)