Skip to content

Static invoice server #3628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
102 changes: 102 additions & 0 deletions lightning/src/blinded_path/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ use bitcoin::hashes::sha256::Hash as Sha256;

use core::mem;
use core::ops::Deref;
use core::time::Duration;

/// A blinded path to be used for sending or receiving a message, hiding the identity of the
/// recipient.
Expand Down Expand Up @@ -342,6 +343,43 @@ pub enum OffersContext {
/// [`Offer`]: crate::offers::offer::Offer
nonce: Nonce,
},
/// Context used by a [`BlindedMessagePath`] within the [`Offer`] of an async recipient.
///
/// This variant is received by the static invoice server when handling an [`InvoiceRequest`] on
/// behalf of said async recipient.
///
/// [`Offer`]: crate::offers::offer::Offer
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
StaticInvoiceRequested {
/// An identifier for the async recipient for whom we as a static invoice server are serving
/// [`StaticInvoice`]s. Used paired with the
/// [`OffersContext::StaticInvoiceRequested::invoice_id`] when looking up a corresponding
/// [`StaticInvoice`] to return to the payer if the recipient is offline. This id was previously
/// provided via [`AsyncPaymentsContext::ServeStaticInvoice::recipient_id`].
///
/// Also useful for rate limiting the number of [`InvoiceRequest`]s we will respond to on
/// recipient's behalf.
///
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
recipient_id: Vec<u8>,

/// A random unique identifier for a specific [`StaticInvoice`] that the recipient previously
/// requested be served on their behalf. Useful when paired with the
/// [`OffersContext::StaticInvoiceRequested::recipient_id`] to pull that specific invoice from
/// the database when payers send an [`InvoiceRequest`]. This id was previously
/// provided via [`AsyncPaymentsContext::ServeStaticInvoice::invoice_id`].
///
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
invoice_id: u128,

/// The time as duration since the Unix epoch at which this path expires and messages sent over
/// it should be ignored.
///
/// Useful to timeout async recipients that are no longer supported as clients.
path_absolute_expiry: Duration,
},
/// Context used by a [`BlindedMessagePath`] within a [`Refund`] or as a reply path for an
/// [`InvoiceRequest`].
///
Expand Down Expand Up @@ -405,6 +443,24 @@ pub enum OffersContext {
/// [`AsyncPaymentsMessage`]: crate::onion_message::async_payments::AsyncPaymentsMessage
#[derive(Clone, Debug)]
pub enum AsyncPaymentsContext {
/// Context used by a [`BlindedMessagePath`] provided out-of-band to an async recipient, where the
/// context is provided back to the static invoice server in corresponding [`OfferPathsRequest`]s.
///
/// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest
OfferPathsRequest {
/// An identifier for the async recipient that is requesting blinded paths to include in their
/// [`Offer::paths`]. This ID will be surfaced when the async recipient eventually sends a
/// corresponding [`ServeStaticInvoice`] message, and can be used to rate limit the recipient.
///
/// [`Offer::paths`]: crate::offers::offer::Offer::paths
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
recipient_id: Vec<u8>,
/// The time as duration since the Unix epoch at which this path expires and messages sent over
/// it should be ignored.
///
/// Useful to timeout async recipients that are no longer supported as clients.
path_absolute_expiry: core::time::Duration,
},
/// Context used by a reply path to an [`OfferPathsRequest`], provided back to us as an async
/// recipient in corresponding [`OfferPaths`] messages from the static invoice server.
///
Expand All @@ -420,6 +476,38 @@ pub enum AsyncPaymentsContext {
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
path_absolute_expiry: core::time::Duration,
},
/// Context used by a reply path to an [`OfferPaths`] message, provided back to us as the static
/// invoice server in corresponding [`ServeStaticInvoice`] messages.
///
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
ServeStaticInvoice {
/// An identifier for the async recipient that is requesting that a [`StaticInvoice`] be served
/// on their behalf.
///
/// Useful for retrieving the invoice when payers send an [`InvoiceRequest`] to us as the static
/// invoice server. Also useful to rate limit the invoices being persisted on behalf of a
/// particular recipient. This id will be provided back to us as the static invoice server via
/// [`OffersContext::StaticInvoiceRequested::recipient_id`]
///
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
recipient_id: Vec<u8>,
/// A random unique identifier for the specific [`StaticInvoice`] that the recipient is
/// requesting be served on their behalf. Useful when surfaced alongside the above
/// `recipient_id` when payers send an [`InvoiceRequest`], to pull the specific static invoice
/// from the database. This id will be provided back to us as the static invoice server via
/// [`OffersContext::StaticInvoiceRequested::invoice_id`]
///
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
invoice_id: u128,
/// The time as duration since the Unix epoch at which this path expires and messages sent over
/// it should be ignored.
///
/// Useful to timeout async recipients that are no longer supported as clients.
path_absolute_expiry: core::time::Duration,
},
/// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in
/// corresponding [`StaticInvoicePersisted`] messages.
///
Expand Down Expand Up @@ -508,6 +596,11 @@ impl_writeable_tlv_based_enum!(OffersContext,
(1, nonce, required),
(2, hmac, required)
},
(3, StaticInvoiceRequested) => {
(0, recipient_id, required),
(2, invoice_id, required),
(4, path_absolute_expiry, required),
},
);

impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
Expand All @@ -528,6 +621,15 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
(0, offer_id, required),
(2, path_absolute_expiry, required),
},
(4, OfferPathsRequest) => {
(0, recipient_id, required),
(2, path_absolute_expiry, required),
},
(5, ServeStaticInvoice) => {
(0, recipient_id, required),
(2, invoice_id, required),
(4, path_absolute_expiry, required),
},
);

/// Contains a simple nonce for use in a blinded path's context.
Expand Down
86 changes: 86 additions & 0 deletions lightning/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,75 @@ pub enum Event {
/// onion messages.
peer_node_id: PublicKey,
},
/// As a static invoice server, we received a [`StaticInvoice`] from an async recipient that wants
/// us to serve the invoice to payers on their behalf when they are offline. This event will only
/// be generated if we previously created paths using
/// [`ChannelManager::blinded_paths_for_async_recipient`] and the recipient was configured with
/// them via [`ChannelManager::set_paths_to_static_invoice_server`].
///
/// [`ChannelManager::blinded_paths_for_async_recipient`]: crate::ln::channelmanager::ChannelManager::blinded_paths_for_async_recipient
/// [`ChannelManager::set_paths_to_static_invoice_server`]: crate::ln::channelmanager::ChannelManager::set_paths_to_static_invoice_server
#[cfg(async_payments)]
PersistStaticInvoice {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume there has been prior discussion around this that I'm unware of, but it strikes me as a bit odd to manage persistence via the event queue, if we have KVStore trait readily available in the crate.

Pushing/popping the event will result in at least two additional ChannelManager repersists. Assuming that ~all users run this against the same persistence backend, I wonder why/if this approach is preferable in any way. I think @joostjager brought up a similar question above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is similar to the approach that was taken in #2973, though unfortunately I can't find a link to any discussion that took place landing on that approach...

But, either way, I think the server needs to verify the recipient_id and rate limit them in their business logic before any persistence takes place such as in KVStore. I agree the events are weird but it feels like rate limiting and persistence is something that should be handed in the layer that actually manages the database itself. Let me know what you think.

Pushing/popping the event will result in at least two additional ChannelManager repersists

Hopefully @joostjager and I can get rid of ChannelManager persistence entirely after this feature is done 😅

Copy link
Contributor

@tnull tnull Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully @joostjager and I can get rid of ChannelManager persistence entirely after this feature is done 😅

Hmm, but even then we'd repersist the event queue twice, no? Granted, it would be much less overhead, but we'd still eat the IO latency once or twice before actually getting to persist the data (incurring more IO latency).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The link to KVStore needs to be made at some point anyway to get a working server. If we don't do it in LDK, the work is just shifted to LDK-node? Then if we do it here and can cut out the event queue and event queue persistence, the overall amount of work might be less.

I do see some intersection with #3778. The events as proposed in this PR would allow for async persistence, but it is yet again another way to do it alongside a native async interface and the InProgress/callback mechanism in chain monitor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, but even then we'd repersist the event queue twice, no? Granted, it would be much less overhead, but we'd still eat the IO latency once or twice before actually getting to persist the data (incurring more IO latency).

Hmm... Thoughts on a separate PR that would allow us to process the event queue and skip manager persistence entirely for for certain events? With these events and several others I can think of, no manager persistence is needed and an event being dropped/failed to handle/redundantly handled should not be a problem.

I guess I'm just not sure how we can properly rate limit without knowing anything about the underlying database and its limitations. It feels like an instance where finer-grained control might be desirable and rust-lightning is the wrong layer, unlike with the current uses of KVStore where all channel updates are definitely necessary to persist. Also, I still think users need to authenticate recipient_ids in their business logic before any persistence takes place.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming almost all users would use ldk-node, it doesn't matter so much in which layer KVStore is accessed. To me it seems fine to do it in ldk-node. I am just not very happy with the event mechanism used here. I think a StaticInvoicePersister trait would be simpler, faster (no event persistence) and better encapsulated (not part of a pile of events of all types).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming almost all users would use ldk-node, it doesn't matter so much in which layer KVStore is accessed. To me it seems fine to do it in ldk-node. I am just not very happy with the event mechanism used here. I think a StaticInvoicePersister trait would be simpler, faster (no event persistence) and better encapsulated (not part of a pile of events of all types).

I agree with this sentiment: generally I don't have too much of a strong opinion on how we do it, but (ab)using the persisted event queue for persistence calls seems like unnecessary overhead, IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned, I think we can avoid persisting the event queue when these events are present specifically, as well as existing non-critical events that are in the same boat like ConnectionNeeded, OnionMessagePeerConnected.

But, a separate trait is another approach that has been discussed in the past that we can definitely revisit, will start an offline discussion.

/// The invoice that should be persisted and later provided to payers when handling a future
/// [`Event::StaticInvoiceRequested`].
invoice: StaticInvoice,
/// Useful for the recipient to replace a specific invoice stored by us as the static invoice
/// server.
///
/// When this invoice is persisted, this slot number should be included so if we receive another
/// [`Event::PersistStaticInvoice`] containing the same slot number we can swap the existing
/// invoice out for the new one.
invoice_slot: u16,
/// An identifier for the recipient, originally provided to
/// [`ChannelManager::blinded_paths_for_async_recipient`].
///
/// When an [`Event::StaticInvoiceRequested`] comes in for the invoice, this id will be surfaced
/// and can be used alongside the `invoice_id` to retrieve the invoice from the database.
recipient_id: Vec<u8>,
/// A unique identifier for the invoice. When an [`Event::StaticInvoiceRequested`] comes in for
/// the invoice, this id will be surfaced and can be used alongside the `recipient_id` to
/// retrieve the invoice from the database.
invoice_id: u128,
/// Once the [`StaticInvoice`], `invoice_slot` and `invoice_id` are persisted,
/// [`ChannelManager::static_invoice_persisted`] should be called with this responder to confirm
/// to the recipient that their [`Offer`] is ready to be used for async payments.
///
/// [`ChannelManager::static_invoice_persisted`]: crate::ln::channelmanager::ChannelManager::static_invoice_persisted
/// [`Offer`]: crate::offers::offer::Offer
invoice_persisted_path: Responder,
},
/// As a static invoice server, we received an [`InvoiceRequest`] on behalf of an often-offline
/// recipient for whom we are serving [`StaticInvoice`]s.
///
/// This event will only be generated if we previously created paths using
/// [`ChannelManager::blinded_paths_for_async_recipient`] and the recipient was configured with
/// them via [`ChannelManager::set_paths_to_static_invoice_server`].
///
/// If we previously persisted a [`StaticInvoice`] from an [`Event::PersistStaticInvoice`] that
/// matches the below `recipient_id` and `invoice_id`, that invoice should be retrieved now
/// and forwarded to the payer via [`ChannelManager::send_static_invoice`].
///
/// [`ChannelManager::blinded_paths_for_async_recipient`]: crate::ln::channelmanager::ChannelManager::blinded_paths_for_async_recipient
/// [`ChannelManager::set_paths_to_static_invoice_server`]: crate::ln::channelmanager::ChannelManager::set_paths_to_static_invoice_server
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
/// [`ChannelManager::send_static_invoice`]: crate::ln::channelmanager::ChannelManager::send_static_invoice
#[cfg(async_payments)]
StaticInvoiceRequested {
/// An identifier for the recipient previously surfaced in
/// [`Event::PersistStaticInvoice::recipient_id`]. Useful when paired with the `invoice_id` to
/// retrieve the [`StaticInvoice`] requested by the payer.
recipient_id: Vec<u8>,
/// A random unique identifier for the invoice being requested, previously surfaced in
/// [`Event::PersistStaticInvoice::invoice_id`]. Useful when paired with the `recipient_id` to
/// retrieve the [`StaticInvoice`] requested by the payer.
invoice_id: u128,
/// The path over which the [`StaticInvoice`] will be sent to the payer, which should be
/// provided to [`ChannelManager::send_static_invoice`] along with the invoice.
///
/// [`ChannelManager::send_static_invoice`]: crate::ln::channelmanager::ChannelManager::send_static_invoice
reply_path: Responder,
},
}

impl Writeable for Event {
Expand Down Expand Up @@ -2012,6 +2081,17 @@ impl Writeable for Event {
(8, former_temporary_channel_id, required),
});
},
#[cfg(async_payments)]
&Event::PersistStaticInvoice { .. } => {
45u8.write(writer)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to write anything at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure, but we do this for several other events and generally always write something for all enum variants, so would like to be consistent

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the serialize/deserialize round trip test trips over this? It seems a waste of space and disk access. Also rate limiting is only happening after the persist cycle?

On the other hand, an LSP probably needs to rate limit at a different level (also).

// No need to write these events because we can just restart the static invoice negotiation
// on startup.
},
#[cfg(async_payments)]
&Event::StaticInvoiceRequested { .. } => {
47u8.write(writer)?;
// Never write StaticInvoiceRequested events as buffered onion messages aren't serialized.
},
// Note that, going forward, all new events must only write data inside of
// `write_tlv_fields`. Versions 0.0.101+ will ignore odd-numbered events that write
// data via `write_tlv_fields`.
Expand Down Expand Up @@ -2583,6 +2663,12 @@ impl MaybeReadable for Event {
former_temporary_channel_id: former_temporary_channel_id.0.unwrap(),
}))
},
// Note that we do not write a length-prefixed TLV for PersistStaticInvoice events.
#[cfg(async_payments)]
45u8 => Ok(None),
// Note that we do not write a length-prefixed TLV for StaticInvoiceRequested events.
#[cfg(async_payments)]
47u8 => Ok(None),
// Versions prior to 0.0.100 did not ignore odd types, instead returning InvalidValue.
// Version 0.0.100 failed to properly ignore odd types, possibly resulting in corrupt
// reads.
Expand Down
Loading
Loading