From d06d109b1aa50315eaa1a78438e7ce6c7b601484 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 5 Feb 2025 17:19:51 -0800 Subject: [PATCH 1/8] Add static invoice server messages and boilerplate Because async recipients are not online to respond to invoice requests, the plan is for another node on the network that is always-online to serve static invoices on their behalf. The protocol is as follows: - Recipient is configured with blinded message paths to reach the static invoice server - On startup, recipient requests blinded message paths for inclusion in their offer from the static invoice server over the configured paths - Server replies with offer paths for the recipient - Recipient builds their offer using these paths and the corresponding static invoice and replies with the invoice - Server persists the invoice and confirms that they've persisted it, causing the recipient to cache the interactively built offer for use At pay-time, the payer sends an invoice request to the static invoice server, who replies with the static invoice after forwarding the invreq to the recipient (to give them a chance to provide a fresh invoice in case they're online). Here we add the requisite trait methods and onion messages to support this protocol. An alterate design could be for the async recipient to publish static invoices directly without a preceding offer, e.g. on their website. Some drawbacks of this design include: 1) No fallback to regular BOLT 12 in the case that the recipient happens to be online at pay-time. Falling back to regular BOLT 12 allows the recipient to provide a fresh invoice and regain the proof-of-payment property 2) Static invoices don't fit in a QR code 3) No automatic rotation of the static invoice, which is useful in the case that payment paths become outdated due to changing fees, etc --- fuzz/src/onion_message.rs | 27 ++- lightning/src/ln/channelmanager.rs | 27 ++- lightning/src/ln/peer_handler.rs | 23 ++- lightning/src/onion_message/async_payments.rs | 191 +++++++++++++++++- .../src/onion_message/functional_tests.rs | 25 ++- lightning/src/onion_message/messenger.rs | 22 ++ 6 files changed, 309 insertions(+), 6 deletions(-) diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index f2da1a316fc..03903abbf6b 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -15,7 +15,8 @@ use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::ln::script::ShutdownScript; use lightning::offers::invoice::UnsignedBolt12Invoice; use lightning::onion_message::async_payments::{ - AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc, + AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ReleaseHeldHtlc, + ServeStaticInvoice, StaticInvoicePersisted, }; use lightning::onion_message::messenger::{ CustomOnionMessageHandler, Destination, MessageRouter, MessageSendInstructions, @@ -124,6 +125,30 @@ impl OffersMessageHandler for TestOffersMessageHandler { struct TestAsyncPaymentsMessageHandler {} impl AsyncPaymentsMessageHandler for TestAsyncPaymentsMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + let responder = match responder { + Some(resp) => resp, + None => return None, + }; + Some((OfferPaths { paths: Vec::new(), paths_absolute_expiry: None }, responder.respond())) + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, responder: Option, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9e7031bf8cb..67248446494 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -98,7 +98,8 @@ use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::Refund; use crate::offers::signer; use crate::onion_message::async_payments::{ - AsyncPaymentsMessage, AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc, + AsyncPaymentsMessage, AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, + OfferPathsRequest, ReleaseHeldHtlc, ServeStaticInvoice, StaticInvoicePersisted, }; use crate::onion_message::dns_resolution::HumanReadableName; use crate::onion_message::messenger::{ @@ -13374,6 +13375,30 @@ where MR::Target: MessageRouter, L::Target: Logger, { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + _responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + None + } + + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } + fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index d5643604041..338007032ab 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -31,7 +31,8 @@ use crate::ln::types::ChannelId; use crate::ln::wire; use crate::ln::wire::{Encode, Type}; use crate::onion_message::async_payments::{ - AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc, + AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ReleaseHeldHtlc, + ServeStaticInvoice, StaticInvoicePersisted, }; use crate::onion_message::dns_resolution::{ DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, @@ -212,6 +213,26 @@ impl OffersMessageHandler for IgnoringMessageHandler { } } impl AsyncPaymentsMessageHandler for IgnoringMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + _responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + None + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, diff --git a/lightning/src/onion_message/async_payments.rs b/lightning/src/onion_message/async_payments.rs index 7a473c90e8f..1a1f39ff3ad 100644 --- a/lightning/src/onion_message/async_payments.rs +++ b/lightning/src/onion_message/async_payments.rs @@ -9,15 +9,20 @@ //! Message handling for async payments. -use crate::blinded_path::message::AsyncPaymentsContext; +use crate::blinded_path::message::{AsyncPaymentsContext, BlindedMessagePath}; use crate::io; use crate::ln::msgs::DecodeError; +use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::messenger::{MessageSendInstructions, Responder, ResponseInstruction}; use crate::onion_message::packet::OnionMessageContents; use crate::prelude::*; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; // TLV record types for the `onionmsg_tlv` TLV stream as defined in BOLT 4. +const OFFER_PATHS_REQ_TLV_TYPE: u64 = 65538; +const OFFER_PATHS_TLV_TYPE: u64 = 65540; +const SERVE_INVOICE_TLV_TYPE: u64 = 65542; +const INVOICE_PERSISTED_TLV_TYPE: u64 = 65544; const HELD_HTLC_AVAILABLE_TLV_TYPE: u64 = 72; const RELEASE_HELD_HTLC_TLV_TYPE: u64 = 74; @@ -25,6 +30,41 @@ const RELEASE_HELD_HTLC_TLV_TYPE: u64 = 74; /// /// [`OnionMessage`]: crate::ln::msgs::OnionMessage pub trait AsyncPaymentsMessageHandler { + /// Handle an [`OfferPathsRequest`] message. If we are a static invoice server and the message was + /// sent over paths that we previously provided to an async recipient, an [`OfferPaths`] message + /// should be returned. + fn handle_offer_paths_request( + &self, message: OfferPathsRequest, context: AsyncPaymentsContext, + responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)>; + + /// Handle an [`OfferPaths`] message. If this is in response to an [`OfferPathsRequest`] that + /// we previously sent as an async recipient, we should build an [`Offer`] containing the + /// included [`OfferPaths::paths`] and a corresponding [`StaticInvoice`], and reply with + /// [`ServeStaticInvoice`]. + /// + /// [`Offer`]: crate::offers::offer::Offer + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + fn handle_offer_paths( + &self, message: OfferPaths, context: AsyncPaymentsContext, responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)>; + + /// Handle a [`ServeStaticInvoice`] message. If this is in response to an [`OfferPaths`] message + /// we previously sent as a static invoice server, a [`StaticInvoicePersisted`] message should be + /// sent once the message is handled. + fn handle_serve_static_invoice( + &self, message: ServeStaticInvoice, context: AsyncPaymentsContext, + responder: Option, + ); + + /// Handle a [`StaticInvoicePersisted`] message. If this is in response to a + /// [`ServeStaticInvoice`] message we previously sent as an async recipient, then the offer we + /// generated on receipt of a previous [`OfferPaths`] message is now ready to be used for async + /// payments. + fn handle_static_invoice_persisted( + &self, message: StaticInvoicePersisted, context: AsyncPaymentsContext, + ); + /// Handle a [`HeldHtlcAvailable`] message. A [`ReleaseHeldHtlc`] should be returned to release /// the held funds. fn handle_held_htlc_available( @@ -50,6 +90,29 @@ pub trait AsyncPaymentsMessageHandler { /// [`OnionMessage`]: crate::ln::msgs::OnionMessage #[derive(Clone, Debug)] pub enum AsyncPaymentsMessage { + /// A request from an async recipient for [`BlindedMessagePath`]s, sent to a static invoice + /// server. + OfferPathsRequest(OfferPathsRequest), + + /// [`BlindedMessagePath`]s to be included in an async recipient's [`Offer::paths`], sent by a + /// static invoice server in response to an [`OfferPathsRequest`]. + /// + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + OfferPaths(OfferPaths), + + /// A request from an async recipient to a static invoice server that a [`StaticInvoice`] be + /// provided in response to [`InvoiceRequest`]s from payers. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + ServeStaticInvoice(ServeStaticInvoice), + + /// Confirmation from a static invoice server that a [`StaticInvoice`] was persisted and the + /// corresponding [`Offer`] is ready to be used to receive async payments. Sent to an async + /// recipient in response to a [`ServeStaticInvoice`] message. + /// + /// [`Offer`]: crate::offers::offer::Offer + StaticInvoicePersisted(StaticInvoicePersisted), + /// An HTLC is being held upstream for the often-offline recipient, to be released via /// [`ReleaseHeldHtlc`]. HeldHtlcAvailable(HeldHtlcAvailable), @@ -58,6 +121,62 @@ pub enum AsyncPaymentsMessage { ReleaseHeldHtlc(ReleaseHeldHtlc), } +/// A request from an async recipient for [`BlindedMessagePath`]s from a static invoice server. +/// These paths will be used in the async recipient's [`Offer::paths`], so payers can request +/// [`StaticInvoice`]s from the static invoice server. +/// +/// [`Offer::paths`]: crate::offers::offer::Offer::paths +#[derive(Clone, Debug)] +pub struct OfferPathsRequest {} + +/// [`BlindedMessagePath`]s to be included in an async recipient's [`Offer::paths`], sent by a +/// static invoice server in response to an [`OfferPathsRequest`]. +/// +/// [`Offer::paths`]: crate::offers::offer::Offer::paths +#[derive(Clone, Debug)] +pub struct OfferPaths { + /// The paths that should be included in the async recipient's [`Offer::paths`]. + /// + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + pub paths: Vec, + /// The time as seconds since the Unix epoch at which the [`Self::paths`] expire. + pub paths_absolute_expiry: Option, +} + +/// A request from an async recipient to a static invoice server that a [`StaticInvoice`] be +/// provided in response to [`InvoiceRequest`]s from payers. +/// +/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest +#[derive(Clone, Debug)] +pub struct ServeStaticInvoice { + /// The invoice that should be served by the static invoice server. Once this invoice has been + /// persisted, the [`Responder`] accompanying this message should be used to send + /// [`StaticInvoicePersisted`] to the recipient to confirm that the offer corresponding to the + /// invoice is ready to receive async payments. + pub invoice: StaticInvoice, + /// If a static invoice server receives an [`InvoiceRequest`] for a [`StaticInvoice`], they should + /// also forward the [`InvoiceRequest`] to the async recipient so they can respond with a fresh + /// [`Bolt12Invoice`] if the recipient is online at the time. Use this path to forward the + /// [`InvoiceRequest`] to the async recipient. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + pub forward_invoice_request_path: BlindedMessagePath, + /// The "slot" in the static invoice server's database that this invoice should go into. This + /// allows recipients to replace a specific invoice that is stored by the server, which is useful + /// for limiting the number of invoices stored by the server while also keeping all the invoices + /// persisted with the server fresh. + pub invoice_slot: u16, +} + +/// Confirmation from a static invoice server that a [`StaticInvoice`] was persisted and the +/// corresponding [`Offer`] is ready to be used to receive async payments. Sent to an async +/// recipient in response to a [`ServeStaticInvoice`] message. +/// +/// [`Offer`]: crate::offers::offer::Offer +#[derive(Clone, Debug)] +pub struct StaticInvoicePersisted {} + /// An HTLC destined for the recipient of this message is being held upstream. The reply path /// accompanying this onion message should be used to send a [`ReleaseHeldHtlc`] response, which /// will cause the upstream HTLC to be released. @@ -68,6 +187,34 @@ pub struct HeldHtlcAvailable {} #[derive(Clone, Debug)] pub struct ReleaseHeldHtlc {} +impl OnionMessageContents for OfferPaths { + fn tlv_type(&self) -> u64 { + OFFER_PATHS_TLV_TYPE + } + #[cfg(c_bindings)] + fn msg_type(&self) -> String { + "Offer Paths".to_string() + } + #[cfg(not(c_bindings))] + fn msg_type(&self) -> &'static str { + "Offer Paths" + } +} + +impl OnionMessageContents for ServeStaticInvoice { + fn tlv_type(&self) -> u64 { + SERVE_INVOICE_TLV_TYPE + } + #[cfg(c_bindings)] + fn msg_type(&self) -> String { + "Serve Static Invoice".to_string() + } + #[cfg(not(c_bindings))] + fn msg_type(&self) -> &'static str { + "Serve Static Invoice" + } +} + impl OnionMessageContents for ReleaseHeldHtlc { fn tlv_type(&self) -> u64 { RELEASE_HELD_HTLC_TLV_TYPE @@ -82,6 +229,21 @@ impl OnionMessageContents for ReleaseHeldHtlc { } } +impl_writeable_tlv_based!(OfferPathsRequest, {}); + +impl_writeable_tlv_based!(OfferPaths, { + (0, paths, required_vec), + (2, paths_absolute_expiry, option), +}); + +impl_writeable_tlv_based!(ServeStaticInvoice, { + (0, invoice, required), + (2, forward_invoice_request_path, required), + (4, invoice_slot, required), +}); + +impl_writeable_tlv_based!(StaticInvoicePersisted, {}); + impl_writeable_tlv_based!(HeldHtlcAvailable, {}); impl_writeable_tlv_based!(ReleaseHeldHtlc, {}); @@ -90,7 +252,12 @@ impl AsyncPaymentsMessage { /// Returns whether `tlv_type` corresponds to a TLV record for async payment messages. pub fn is_known_type(tlv_type: u64) -> bool { match tlv_type { - HELD_HTLC_AVAILABLE_TLV_TYPE | RELEASE_HELD_HTLC_TLV_TYPE => true, + OFFER_PATHS_REQ_TLV_TYPE + | OFFER_PATHS_TLV_TYPE + | SERVE_INVOICE_TLV_TYPE + | INVOICE_PERSISTED_TLV_TYPE + | HELD_HTLC_AVAILABLE_TLV_TYPE + | RELEASE_HELD_HTLC_TLV_TYPE => true, _ => false, } } @@ -99,6 +266,10 @@ impl AsyncPaymentsMessage { impl OnionMessageContents for AsyncPaymentsMessage { fn tlv_type(&self) -> u64 { match self { + Self::OfferPathsRequest(_) => OFFER_PATHS_REQ_TLV_TYPE, + Self::OfferPaths(msg) => msg.tlv_type(), + Self::ServeStaticInvoice(msg) => msg.tlv_type(), + Self::StaticInvoicePersisted(_) => INVOICE_PERSISTED_TLV_TYPE, Self::HeldHtlcAvailable(_) => HELD_HTLC_AVAILABLE_TLV_TYPE, Self::ReleaseHeldHtlc(msg) => msg.tlv_type(), } @@ -106,6 +277,10 @@ impl OnionMessageContents for AsyncPaymentsMessage { #[cfg(c_bindings)] fn msg_type(&self) -> String { match &self { + Self::OfferPathsRequest(_) => "Offer Paths Request".to_string(), + Self::OfferPaths(msg) => msg.msg_type(), + Self::ServeStaticInvoice(msg) => msg.msg_type(), + Self::StaticInvoicePersisted(_) => "Static Invoice Persisted".to_string(), Self::HeldHtlcAvailable(_) => "Held HTLC Available".to_string(), Self::ReleaseHeldHtlc(msg) => msg.msg_type(), } @@ -113,6 +288,10 @@ impl OnionMessageContents for AsyncPaymentsMessage { #[cfg(not(c_bindings))] fn msg_type(&self) -> &'static str { match &self { + Self::OfferPathsRequest(_) => "Offer Paths Request", + Self::OfferPaths(msg) => msg.msg_type(), + Self::ServeStaticInvoice(msg) => msg.msg_type(), + Self::StaticInvoicePersisted(_) => "Static Invoice Persisted", Self::HeldHtlcAvailable(_) => "Held HTLC Available", Self::ReleaseHeldHtlc(msg) => msg.msg_type(), } @@ -122,6 +301,10 @@ impl OnionMessageContents for AsyncPaymentsMessage { impl Writeable for AsyncPaymentsMessage { fn write(&self, w: &mut W) -> Result<(), io::Error> { match self { + Self::OfferPathsRequest(message) => message.write(w), + Self::OfferPaths(message) => message.write(w), + Self::ServeStaticInvoice(message) => message.write(w), + Self::StaticInvoicePersisted(message) => message.write(w), Self::HeldHtlcAvailable(message) => message.write(w), Self::ReleaseHeldHtlc(message) => message.write(w), } @@ -131,6 +314,10 @@ impl Writeable for AsyncPaymentsMessage { impl ReadableArgs for AsyncPaymentsMessage { fn read(r: &mut R, tlv_type: u64) -> Result { match tlv_type { + OFFER_PATHS_REQ_TLV_TYPE => Ok(Self::OfferPathsRequest(Readable::read(r)?)), + OFFER_PATHS_TLV_TYPE => Ok(Self::OfferPaths(Readable::read(r)?)), + SERVE_INVOICE_TLV_TYPE => Ok(Self::ServeStaticInvoice(Readable::read(r)?)), + INVOICE_PERSISTED_TLV_TYPE => Ok(Self::StaticInvoicePersisted(Readable::read(r)?)), HELD_HTLC_AVAILABLE_TLV_TYPE => Ok(Self::HeldHtlcAvailable(Readable::read(r)?)), RELEASE_HELD_HTLC_TLV_TYPE => Ok(Self::ReleaseHeldHtlc(Readable::read(r)?)), _ => Err(DecodeError::InvalidValue), diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 087a587487a..98fd98d2086 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -9,7 +9,10 @@ //! Onion message testing and test utilities live here. -use super::async_payments::{AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc}; +use super::async_payments::{ + AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ReleaseHeldHtlc, + ServeStaticInvoice, StaticInvoicePersisted, +}; use super::dns_resolution::{ DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, }; @@ -91,6 +94,26 @@ impl OffersMessageHandler for TestOffersMessageHandler { struct TestAsyncPaymentsMessageHandler {} impl AsyncPaymentsMessageHandler for TestAsyncPaymentsMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + _responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + None + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index fd98f78350e..e8db43d7591 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1938,6 +1938,28 @@ where log_receive!(message, reply_path.is_some()); let responder = reply_path.map(Responder::new); match message { + AsyncPaymentsMessage::OfferPathsRequest(msg) => { + let response_instructions = self + .async_payments_handler + .handle_offer_paths_request(msg, context, responder); + if let Some((msg, instructions)) = response_instructions { + let _ = self.handle_onion_message_response(msg, instructions); + } + }, + AsyncPaymentsMessage::OfferPaths(msg) => { + let response_instructions = + self.async_payments_handler.handle_offer_paths(msg, context, responder); + if let Some((msg, instructions)) = response_instructions { + let _ = self.handle_onion_message_response(msg, instructions); + } + }, + AsyncPaymentsMessage::ServeStaticInvoice(msg) => { + self.async_payments_handler + .handle_serve_static_invoice(msg, context, responder); + }, + AsyncPaymentsMessage::StaticInvoicePersisted(msg) => { + self.async_payments_handler.handle_static_invoice_persisted(msg, context); + }, AsyncPaymentsMessage::HeldHtlcAvailable(msg) => { let response_instructions = self .async_payments_handler From ea6160d521d5cba7461035f8594e76fd3dfe1cc2 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 10 Apr 2025 15:26:12 -0400 Subject: [PATCH 2/8] Track cached async receive offers in OffersMessageFlow In future commits, as part of being an async recipient, we will interactively build offers and static invoices with an always-online node that will serve static invoices on our behalf. Once an offer is built and we've requested persistence of the corresponding invoice from the server, we will use the new offer cache added here to save the invoice metadata and the offer in ChannelManager, though the OffersMessageFlow is responsible for keeping the cache updated. We want to cache and persist these offers so we always have them at the ready, we don't want to begin the process of interactively building an offer the moment it is needed. The offers are likely to be long-lived so caching them avoids having to keep interactively rebuilding them after every restart. --- lightning/src/ln/channelmanager.rs | 6 +- .../src/offers/async_receive_offer_cache.rs | 166 ++++++++++++++++++ lightning/src/offers/flow.rs | 27 +++ lightning/src/offers/mod.rs | 1 + 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 lightning/src/offers/async_receive_offer_cache.rs diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 67248446494..2cc7752a528 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -86,6 +86,7 @@ use crate::ln::outbound_payment::{ StaleExpiration, }; use crate::ln::types::ChannelId; +use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::OffersMessageFlow; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, @@ -14264,6 +14265,7 @@ where (15, self.inbound_payment_id_secret, required), (17, in_flight_monitor_updates, option), (19, peer_storage_dir, optional_vec), + (21, self.flow.writeable_async_receive_offer_cache(), required), }); Ok(()) @@ -14843,6 +14845,7 @@ where let mut decode_update_add_htlcs: Option>> = None; let mut inbound_payment_id_secret = None; let mut peer_storage_dir: Option)>> = None; + let mut async_receive_offer_cache: AsyncReceiveOfferCache = AsyncReceiveOfferCache::new(); read_tlv_fields!(reader, { (1, pending_outbound_payments_no_retry, option), (2, pending_intercepted_htlcs, option), @@ -14860,6 +14863,7 @@ where (15, inbound_payment_id_secret, option), (17, in_flight_monitor_updates, option), (19, peer_storage_dir, optional_vec), + (21, async_receive_offer_cache, (default_value, async_receive_offer_cache)), }); let mut decode_update_add_htlcs = decode_update_add_htlcs.unwrap_or_else(|| new_hash_map()); let peer_storage_dir: Vec<(PublicKey, Vec)> = peer_storage_dir.unwrap_or_else(Vec::new); @@ -15546,7 +15550,7 @@ where chain_hash, best_block, our_network_pubkey, highest_seen_timestamp, expanded_inbound_key, secp_ctx.clone(), args.message_router - ); + ).with_async_payments_offers_cache(async_receive_offer_cache); let channel_manager = ChannelManager { chain_hash, diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs new file mode 100644 index 00000000000..408bdc006f0 --- /dev/null +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -0,0 +1,166 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Data structures and methods for caching offers that we interactively build with a static invoice +//! server as an async recipient. The static invoice server will serve the resulting invoices to +//! payers on our behalf when we're offline. + +use crate::blinded_path::message::BlindedMessagePath; +use crate::io; +use crate::io::Read; +use crate::ln::msgs::DecodeError; +use crate::offers::nonce::Nonce; +use crate::offers::offer::Offer; +use crate::onion_message::messenger::Responder; +use crate::prelude::*; +use crate::util::ser::{Readable, Writeable, Writer}; +use core::time::Duration; + +/// The status of this offer in the cache. +enum OfferStatus { + /// This offer has been returned to the user from the cache, so it needs to be stored until it + /// expires and its invoice needs to be kept updated. + Used, + /// This offer has not yet been returned to the user, and is safe to replace to ensure we always + /// have a maximally fresh offer. We always want to have at least 1 offer in this state, + /// preferably a few so we can respond to user requests for new offers without returning the same + /// one multiple times. Returning a new offer each time is better for privacy. + Ready { + /// If this offer's invoice has been persisted for some time, it's safe to replace to ensure we + /// always have the freshest possible offer available when the user goes to pull an offer from + /// the cache. + invoice_confirmed_persisted_at: Duration, + }, + /// This offer's invoice is not yet confirmed as persisted by the static invoice server, so it is + /// not yet ready to receive payments. + Pending, +} + +struct AsyncReceiveOffer { + offer: Offer, + /// Whether this offer is used, ready for use, or pending invoice persistence with the static + /// invoice server. + status: OfferStatus, + + /// The below fields are used to generate and persist a new static invoice with the invoice + /// server. We support automatically rotating the invoice for long-lived offers so users don't + /// have to update the offer they've posted on e.g. their website if fees change or the invoices' + /// payment paths become otherwise outdated. + offer_nonce: Nonce, + update_static_invoice_path: Responder, +} + +impl_writeable_tlv_based_enum!(OfferStatus, + (0, Used) => {}, + (1, Ready) => { + (0, invoice_confirmed_persisted_at, required), + }, + (2, Pending) => {}, +); + +impl_writeable_tlv_based!(AsyncReceiveOffer, { + (0, offer, required), + (2, offer_nonce, required), + (4, status, required), + (6, update_static_invoice_path, required), +}); + +/// If we are an often-offline recipient, we'll want to interactively build offers and static +/// invoices with an always-online node that will serve those static invoices to payers on our +/// behalf when we are offline. +/// +/// This struct is used to cache those interactively built offers, and should be passed into +/// [`OffersMessageFlow`] on startup as well as persisted whenever an offer or invoice is updated. +/// +/// ## Lifecycle of a cached offer +/// +/// 1. On initial startup, recipients will request offer paths from the static invoice server +/// 2. Once a set of offer paths is received, recipients will build an offer and corresponding +/// static invoice, cache the offer as pending, and send the invoice to the server for +/// persistence +/// 3. Once the invoice is confirmed as persisted by the server, the recipient will mark the +/// corresponding offer as ready to receive payments +/// 4. If the offer is later returned to the user, it will be kept cached and its invoice will be +/// kept up-to-date until the offer expires +/// 5. If the offer does not get returned to the user within a certain timeframe, it will be +/// replaced with a new one using fresh offer paths requested from the static invoice server +/// +/// ## Staying in sync with the Static Invoice Server +/// +/// * Pending offers: for a given cached offer where a corresponding invoice is not yet confirmed as +/// persisted by the static invoice server, we will retry persisting an invoice for that offer until +/// it succeeds, once per timer tick +/// * Confirmed offers that have not yet been returned to the user: we will periodically replace an +/// unused confirmed offer with a new one, to try to always have a fresh offer available. We wait +/// several hours in between replacements to ensure the new offer replacement doesn't conflict with +/// the old one +/// * Confirmed offers that have been returned to the user: we will send the server a fresh invoice +/// corresponding to each used offer once per timer tick until the offer expires +/// +/// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow +pub struct AsyncReceiveOfferCache { + /// The cache is allocated up-front with a fixed number of slots for offers, where each slot is + /// filled in with an AsyncReceiveOffer as they are interactively built. + /// + /// We only want to store a limited number of static invoices with the server, and those stored + /// invoices need to regularly be replaced with new ones. When sending a replacement invoice to + /// the server, we indicate which invoice is being replaced by the invoice's "slot number", + /// see [`ServeStaticInvoice::invoice_slot`]. So rather than internally tracking which cached + /// offer corresponds to what invoice slot number on the server's end, we always set the slot + /// number to the index of the offer in the cache. + /// + /// [`ServeStaticInvoice::invoice_slot`]: crate::onion_message::async_payments::ServeStaticInvoice + offers: Vec>, + /// Used to limit the number of times we request paths for our offer from the static invoice + /// server. + #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag + offer_paths_request_attempts: u8, + /// Blinded paths used to request offer paths from the static invoice server. + #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag + paths_to_static_invoice_server: Vec, +} + +impl AsyncReceiveOfferCache { + /// Creates an empty [`AsyncReceiveOfferCache`] to be passed into [`OffersMessageFlow`]. + /// + /// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow + pub fn new() -> Self { + Self { + offers: Vec::new(), + offer_paths_request_attempts: 0, + paths_to_static_invoice_server: Vec::new(), + } + } + + pub(super) fn paths_to_static_invoice_server(&self) -> Vec { + self.paths_to_static_invoice_server.clone() + } +} + +impl Writeable for AsyncReceiveOfferCache { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + write_tlv_fields!(w, { + (0, self.offers, required_vec), + (2, self.paths_to_static_invoice_server, required_vec), + // offer paths request retry info always resets on restart + }); + Ok(()) + } +} + +impl Readable for AsyncReceiveOfferCache { + fn read(r: &mut R) -> Result { + _init_and_read_len_prefixed_tlv_fields!(r, { + (0, offers, required_vec), + (2, paths_to_static_invoice_server, required_vec), + }); + let offers: Vec> = offers; + Ok(Self { offers, offer_paths_request_attempts: 0, paths_to_static_invoice_server }) + } +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 38f674141b1..8267774e9d8 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -36,6 +36,7 @@ use crate::ln::channelmanager::{ Verification, {PaymentId, CLTV_FAR_FAR_AWAY, MAX_SHORT_LIVED_RELATIVE_EXPIRY}, }; use crate::ln::inbound_payment; +use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, UnsignedBolt12Invoice, DEFAULT_RELATIVE_EXPIRY, @@ -56,6 +57,7 @@ use crate::routing::router::Router; use crate::sign::{EntropySource, NodeSigner}; use crate::sync::{Mutex, RwLock}; use crate::types::payment::{PaymentHash, PaymentSecret}; +use crate::util::ser::Writeable; #[cfg(async_payments)] use { @@ -98,6 +100,10 @@ where pub(crate) pending_offers_messages: Mutex>, pending_async_payments_messages: Mutex>, + async_receive_offer_cache: Mutex, + /// Blinded paths used to request offer paths from the static invoice server, if we are an async + /// recipient. + paths_to_static_invoice_server: Mutex>, #[cfg(feature = "dnssec")] pub(crate) hrn_resolver: OMNameResolver, @@ -133,9 +139,25 @@ where hrn_resolver: OMNameResolver::new(current_timestamp, best_block.height), #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex::new(Vec::new()), + + async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + paths_to_static_invoice_server: Mutex::new(Vec::new()), } } + /// If we are an async recipient, on startup we'll interactively build offers and static invoices + /// with an always-online node that will serve static invoices on our behalf. Once the offer is + /// built and the static invoice is confirmed as persisted by the server, the underlying + /// [`AsyncReceiveOfferCache`] should be persisted so we remember the offers we've built. + pub(crate) fn with_async_payments_offers_cache( + mut self, async_receive_offer_cache: AsyncReceiveOfferCache, + ) -> Self { + self.paths_to_static_invoice_server = + Mutex::new(async_receive_offer_cache.paths_to_static_invoice_server()); + self.async_receive_offer_cache = Mutex::new(async_receive_offer_cache); + self + } + /// Gets the node_id held by this [`OffersMessageFlow`]` fn get_our_node_id(&self) -> PublicKey { self.our_network_pubkey @@ -1082,4 +1104,9 @@ where ) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) } + + /// Get the `AsyncReceiveOfferCache` for persistence. + pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ { + &self.async_receive_offer_cache + } } diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index cf078ed0e67..b603deecd60 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -16,6 +16,7 @@ pub mod offer; pub mod flow; +pub(crate) mod async_receive_offer_cache; pub mod invoice; pub mod invoice_error; mod invoice_macros; From f2909d0ddc152272eb26cd2882c9e8d110249850 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Sun, 15 Jun 2025 21:35:03 -0400 Subject: [PATCH 3/8] Add setter for paths to static invoice server In future commits, as part of being an async recipient, we will interactively build offers and static invoices with an always-online node that will serve static invoices on our behalf. Here we add a setter for the blinded paths that we as an async recipient will use to contact the static invoice server to get paths to put in our offers. --- lightning/src/ln/channelmanager.rs | 16 ++++++++++ .../src/offers/async_receive_offer_cache.rs | 29 +++++++++++++++++++ lightning/src/offers/flow.rs | 21 ++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2cc7752a528..4225b71de95 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11055,6 +11055,22 @@ where ) } + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build + /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers + /// on our behalf when we're offline. + /// + /// This method only needs to be called once when the server first takes on the recipient as a + /// client, or when the paths change, e.g. if the paths are set to expire at a particular time. + #[cfg(async_payments)] + pub fn set_paths_to_static_invoice_server( + &self, paths_to_static_invoice_server: Vec, + ) -> Result<(), ()> { + self.flow.set_paths_to_static_invoice_server(paths_to_static_invoice_server)?; + + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + Ok(()) + } + /// Pays for an [`Offer`] using the given parameters by creating an [`InvoiceRequest`] and /// enqueuing it to be sent via an onion message. [`ChannelManager`] will pay the actual /// [`Bolt12Invoice`] once it is received. diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 408bdc006f0..ca719b7415a 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -23,6 +23,7 @@ use crate::util::ser::{Readable, Writeable, Writer}; use core::time::Duration; /// The status of this offer in the cache. +#[derive(Clone)] enum OfferStatus { /// This offer has been returned to the user from the cache, so it needs to be stored until it /// expires and its invoice needs to be kept updated. @@ -42,6 +43,7 @@ enum OfferStatus { Pending, } +#[derive(Clone)] struct AsyncReceiveOffer { offer: Offer, /// Whether this offer is used, ready for use, or pending invoice persistence with the static @@ -141,8 +143,35 @@ impl AsyncReceiveOfferCache { pub(super) fn paths_to_static_invoice_server(&self) -> Vec { self.paths_to_static_invoice_server.clone() } + + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build + /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers + /// on our behalf when we're offline. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + #[cfg(async_payments)] + pub fn set_paths_to_static_invoice_server( + &mut self, paths_to_static_invoice_server: Vec, + ) -> Result<(), ()> { + if paths_to_static_invoice_server.is_empty() { + return Err(()); + } + + self.paths_to_static_invoice_server = paths_to_static_invoice_server; + if self.offers.is_empty() { + // See `AsyncReceiveOfferCache::offers`. + self.offers = vec![None; MAX_CACHED_OFFERS_TARGET]; + } + Ok(()) + } } +// The target number of offers we want to have cached at any given time, to mitigate too much +// reuse of the same offer while also limiting the amount of space our offers take up on the +// server's end. +#[cfg(async_payments)] +const MAX_CACHED_OFFERS_TARGET: usize = 10; + impl Writeable for AsyncReceiveOfferCache { fn write(&self, w: &mut W) -> Result<(), io::Error> { write_tlv_fields!(w, { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 8267774e9d8..2b8d1777a30 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -158,6 +158,27 @@ where self } + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build + /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers + /// on our behalf when we're offline. + /// + /// This method only needs to be called once when the server first takes on the recipient as a + /// client, or when the paths change, e.g. if the paths are set to expire at a particular time. + #[cfg(async_payments)] + pub(crate) fn set_paths_to_static_invoice_server( + &self, paths_to_static_invoice_server: Vec, + ) -> Result<(), ()> { + // Store the paths in the async receive cache so they are persisted with the cache, but also + // store them in-memory in the `OffersMessageFlow` so the flow has access to them when building + // onion messages to send to the static invoice server, without introducing undesirable lock + // dependencies with the cache. + *self.paths_to_static_invoice_server.lock().unwrap() = + paths_to_static_invoice_server.clone(); + + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.set_paths_to_static_invoice_server(paths_to_static_invoice_server) + } + /// Gets the node_id held by this [`OffersMessageFlow`]` fn get_our_node_id(&self) -> PublicKey { self.our_network_pubkey From 7eb8579ec6448570754fbe9dc0e3a1bf1c0a34e1 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 7 May 2025 12:45:59 -0400 Subject: [PATCH 4/8] Initiate and refresh async receive offers As an async recipient, we need to interactively build static invoices that an always-online node will serve to payers on our behalf. At the start of this process, we send a requests for paths to include in our offers to the always-online node on startup and refresh the cached offers when they start to get stale. --- lightning/src/blinded_path/message.rs | 18 +++ lightning/src/ln/channelmanager.rs | 24 ++++ .../src/offers/async_receive_offer_cache.rs | 127 ++++++++++++++++++ lightning/src/offers/flow.rs | 66 ++++++++- 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 164cfcfb1ad..6b8620d371d 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -404,6 +404,21 @@ pub enum OffersContext { /// [`AsyncPaymentsMessage`]: crate::onion_message::async_payments::AsyncPaymentsMessage #[derive(Clone, Debug)] pub enum AsyncPaymentsContext { + /// 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. + /// + /// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + OfferPaths { + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// This avoids the situation where the [`OfferPaths`] message is very delayed and thus + /// outdated. + /// + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + path_absolute_expiry: core::time::Duration, + }, /// Context contained within the reply [`BlindedMessagePath`] we put in outbound /// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`] /// messages. @@ -486,6 +501,9 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (2, hmac, required), (4, path_absolute_expiry, required), }, + (2, OfferPaths) => { + (0, path_absolute_expiry, required), + }, ); /// Contains a simple nonce for use in a blinded path's context. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 4225b71de95..54f6708a1b9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5248,6 +5248,20 @@ where ) } + #[cfg(async_payments)] + fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { + let peers = self.get_peers_for_blinded_path(); + match self.flow.check_refresh_async_receive_offer_cache(peers, timer_tick_occurred) { + Err(()) => { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }, + Ok(()) => {}, + } + } + #[cfg(async_payments)] fn initiate_async_payment( &self, invoice: &StaticInvoice, payment_id: PaymentId, @@ -7242,6 +7256,9 @@ where duration_since_epoch, &self.pending_events ); + #[cfg(async_payments)] + self.check_refresh_async_receive_offer_cache(true); + // Technically we don't need to do this here, but if we have holding cell entries in a // channel that need freeing, it's better to do that here and block a background task // than block the message queueing pipeline. @@ -11978,6 +11995,13 @@ where return NotifyOption::SkipPersistHandleEvents; //TODO: Also re-broadcast announcement_signatures }); + + // While we usually refresh the AsyncReceiveOfferCache on a timer, we also want to start + // interactively building offers as soon as we can after startup. We can't start building offers + // until we have some peer connection(s) to send onion messages over, so as a minor optimization + // refresh the cache when a peer connects. + #[cfg(async_payments)] + self.check_refresh_async_receive_offer_cache(false); res } diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index ca719b7415a..7a388142912 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -172,6 +172,133 @@ impl AsyncReceiveOfferCache { #[cfg(async_payments)] const MAX_CACHED_OFFERS_TARGET: usize = 10; +// The max number of times we'll attempt to request offer paths per timer tick. +#[cfg(async_payments)] +const MAX_UPDATE_ATTEMPTS: u8 = 3; + +// If we have an offer that is replaceable and its invoice was confirmed as persisted more than 2 +// hours ago, we can go ahead and refresh it because we always want to have the freshest offer +// possible when a user goes to retrieve a cached offer. +// +// We avoid replacing unused offers too quickly -- this prevents the case where we send multiple +// invoices from different offers competing for the same slot to the server, messages are received +// delayed or out-of-order, and we end up providing an offer to the user that the server just +// deleted and replaced. +#[cfg(async_payments)] +const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60); + +#[cfg(async_payments)] +impl AsyncReceiveOfferCache { + /// Remove expired offers from the cache, returning whether new offers are needed. + pub(super) fn prune_expired_offers( + &mut self, duration_since_epoch: Duration, force_reset_request_attempts: bool, + ) -> bool { + // Remove expired offers from the cache. + let mut offer_was_removed = false; + for offer_opt in self.offers.iter_mut() { + let offer_is_expired = offer_opt + .as_ref() + .map_or(false, |offer| offer.offer.is_expired_no_std(duration_since_epoch)); + if offer_is_expired { + offer_opt.take(); + offer_was_removed = true; + } + } + + // Allow up to `MAX_UPDATE_ATTEMPTS` offer paths requests to be sent out roughly once per + // minute, or if an offer was removed. + if force_reset_request_attempts || offer_was_removed { + self.reset_offer_paths_request_attempts() + } + + self.needs_new_offer_idx(duration_since_epoch).is_some() + && self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS + } + + /// If we have any empty slots in the cache or offers that can and should be replaced with a fresh + /// offer, here we return the index of the slot that needs a new offer. The index is used for + /// setting [`ServeStaticInvoice::invoice_slot`] when sending the corresponding new static invoice + /// to the server, so the server knows which existing persisted invoice is being replaced, if any. + /// + /// Returns `None` if the cache is full and no offers can currently be replaced. + /// + /// [`ServeStaticInvoice::invoice_slot`]: crate::onion_message::async_payments::ServeStaticInvoice::invoice_slot + fn needs_new_offer_idx(&self, duration_since_epoch: Duration) -> Option { + // If we have any empty offer slots, return the first one we find + let empty_slot_idx_opt = self.offers.iter().position(|offer_opt| offer_opt.is_none()); + if empty_slot_idx_opt.is_some() { + return empty_slot_idx_opt; + } + + // If all of our offers are already used or pending, then none are available to be replaced + let no_replaceable_offers = self + .offers_with_idx() + .all(|(_, offer)| matches!(offer.status, OfferStatus::Used | OfferStatus::Pending)); + if no_replaceable_offers { + return None; + } + + // All offers are pending except for one, so we shouldn't request an update of the only usable + // offer + let num_payable_offers = self + .offers_with_idx() + .filter(|(_, offer)| { + matches!(offer.status, OfferStatus::Used | OfferStatus::Ready { .. }) + }) + .count(); + if num_payable_offers <= 1 { + return None; + } + + // Filter for unused offers where longer than OFFER_REFRESH_THRESHOLD time has passed since they + // were last updated, so they are stale enough to warrant replacement. + let awhile_ago = duration_since_epoch.saturating_sub(OFFER_REFRESH_THRESHOLD); + self.unused_offers() + .filter(|(_, _, invoice_confirmed_persisted_at)| { + *invoice_confirmed_persisted_at < awhile_ago + }) + // Get the stalest offer and return its index + .min_by(|(_, _, persisted_at_a), (_, _, persisted_at_b)| { + persisted_at_a.cmp(&persisted_at_b) + }) + .map(|(idx, _, _)| idx) + } + + /// Returns an iterator over (offer_idx, offer) + fn offers_with_idx(&self) -> impl Iterator { + self.offers.iter().enumerate().filter_map(|(idx, offer_opt)| { + if let Some(offer) = offer_opt { + Some((idx, offer)) + } else { + None + } + }) + } + + /// Returns an iterator over (offer_idx, offer, invoice_confirmed_persisted_at) + /// where all returned offers are [`OfferStatus::Ready`] + fn unused_offers(&self) -> impl Iterator { + self.offers_with_idx().filter_map(|(idx, offer)| match offer.status { + OfferStatus::Ready { invoice_confirmed_persisted_at } => { + Some((idx, offer, invoice_confirmed_persisted_at)) + }, + _ => None, + }) + } + + // Indicates that onion messages requesting new offer paths have been sent to the static invoice + // server. Calling this method allows the cache to self-limit how many requests are sent. + pub(super) fn new_offers_requested(&mut self) { + self.offer_paths_request_attempts += 1; + } + + /// Called on timer tick (roughly once per minute) to allow another [`MAX_UPDATE_ATTEMPTS`] offer + /// paths requests to go out. + fn reset_offer_paths_request_attempts(&mut self) { + self.offer_paths_request_attempts = 0; + } +} + impl Writeable for AsyncReceiveOfferCache { fn write(&self, w: &mut W) -> Result<(), io::Error> { write_tlv_fields!(w, { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 2b8d1777a30..ef7471cae70 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -66,7 +66,7 @@ use { crate::offers::offer::Amount, crate::offers::signer, crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}, - crate::onion_message::async_payments::HeldHtlcAvailable, + crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest}, }; #[cfg(feature = "dnssec")] @@ -238,6 +238,11 @@ where /// even if multiple invoices are received. const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10; +/// The default relative expiry for reply paths where a quick response is expected and the reply +/// path is single-use. +#[cfg(async_payments)] +const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200); + impl OffersMessageFlow where MR::Target: MessageRouter, @@ -1126,6 +1131,65 @@ where core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) } + /// Sends out [`OfferPathsRequest`] onion messages if we are an often-offline recipient and are + /// configured to interactively build offers and static invoices with a static invoice server. + /// + /// # Usage + /// + /// This method should be called on peer connection and once per minute or so, to keep the offers + /// cache updated. When calling this method once per minute, SHOULD set `timer_tick_occurred` so + /// the cache can self-regulate the number of messages sent out. + /// + /// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message. + #[cfg(async_payments)] + pub(crate) fn check_refresh_async_receive_offer_cache( + &self, peers: Vec, timer_tick_occurred: bool, + ) -> Result<(), ()> { + // Terminate early if this node does not intend to receive async payments. + if self.paths_to_static_invoice_server.lock().unwrap().is_empty() { + return Ok(()); + } + + let duration_since_epoch = self.duration_since_epoch(); + + // Update the cache to remove expired offers, and check to see whether we need new offers to be + // interactively built with the static invoice server. + let needs_new_offers = self + .async_receive_offer_cache + .lock() + .unwrap() + .prune_expired_offers(duration_since_epoch, timer_tick_occurred); + + // If we need new offers, send out offer paths request messages to the static invoice server. + if needs_new_offers { + let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { + path_absolute_expiry: duration_since_epoch + .saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY), + }); + let reply_paths = match self.create_blinded_paths(peers, context) { + Ok(paths) => paths, + Err(()) => { + return Err(()); + }, + }; + + // We can't fail past this point, so indicate to the cache that we've requested new offers. + self.async_receive_offer_cache.lock().unwrap().new_offers_requested(); + + let mut pending_async_payments_messages = + self.pending_async_payments_messages.lock().unwrap(); + let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {}); + enqueue_onion_message_with_reply_paths( + message, + &self.paths_to_static_invoice_server.lock().unwrap()[..], + reply_paths, + &mut pending_async_payments_messages, + ); + } + + Ok(()) + } + /// Get the `AsyncReceiveOfferCache` for persistence. pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ { &self.async_receive_offer_cache From 84917d3abcb4f50e5af686f47b4e1401ca98fea8 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 7 May 2025 16:29:44 -0400 Subject: [PATCH 5/8] Send static invoice in response to offer paths As an async recipient, we need to interactively build a static invoice that an always-online node will serve to payers on our behalf. As part of this process, the static invoice server sends us blinded message paths to include in our offer so they'll receive invoice requests from senders trying to pay us while we're offline. On receipt of these paths, create an offer and static invoice and send the invoice back to the server so they can provide the invoice to payers, as well as caching the offer as pending. --- lightning/src/blinded_path/message.rs | 24 +++ lightning/src/ln/channelmanager.rs | 29 +++- lightning/src/ln/inbound_payment.rs | 2 +- .../src/offers/async_receive_offer_cache.rs | 64 ++++++++ lightning/src/offers/flow.rs | 155 +++++++++++++++++- 5 files changed, 271 insertions(+), 3 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 6b8620d371d..99dd4fa667c 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -23,6 +23,7 @@ use crate::ln::channelmanager::PaymentId; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::offers::nonce::Nonce; +use crate::offers::offer::OfferId; use crate::onion_message::packet::ControlTlvs; use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph}; use crate::sign::{EntropySource, NodeSigner, Recipient}; @@ -419,6 +420,25 @@ pub enum AsyncPaymentsContext { /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths path_absolute_expiry: core::time::Duration, }, + /// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in + /// corresponding [`StaticInvoicePersisted`] messages. + /// + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + StaticInvoicePersisted { + /// The id of the offer in the cache corresponding to the [`StaticInvoice`] that has been + /// persisted. This invoice is now ready to be provided by the static invoice server in response + /// to [`InvoiceRequest`]s, so the corresponding offer can be marked as ready to receive + /// payments. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + offer_id: OfferId, + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. If we receive confirmation of an invoice over this path after its + /// expiry, it may be outdated and a new invoice update should be sent instead. + path_absolute_expiry: core::time::Duration, + }, /// Context contained within the reply [`BlindedMessagePath`] we put in outbound /// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`] /// messages. @@ -504,6 +524,10 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (2, OfferPaths) => { (0, path_absolute_expiry, required), }, + (3, StaticInvoicePersisted) => { + (0, offer_id, required), + (2, path_absolute_expiry, required), + }, ); /// Contains a simple nonce for use in a blinded path's context. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 54f6708a1b9..a645ef7aa72 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13426,7 +13426,34 @@ where fn handle_offer_paths( &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { - None + #[cfg(async_payments)] + { + let responder = match _responder { + Some(responder) => responder, + None => return None, + }; + let (serve_static_invoice, reply_context) = match self.flow.handle_offer_paths( + _message, + _context, + responder.clone(), + self.get_peers_for_blinded_path(), + self.list_usable_channels(), + &*self.entropy_source, + &*self.router, + ) { + Some((msg, ctx)) => (msg, ctx), + None => return None, + }; + + // We cached a new pending offer, so persist the cache. + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + + let response_instructions = responder.respond_with_reply_path(reply_context); + return Some((serve_static_invoice, response_instructions)); + } + + #[cfg(not(async_payments))] + return None; } fn handle_serve_static_invoice( diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 51146c1b6f1..a7d45b896a9 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -214,7 +214,7 @@ pub fn create_from_hash( } #[cfg(async_payments)] -pub(super) fn create_for_spontaneous_payment( +pub(crate) fn create_for_spontaneous_payment( keys: &ExpandedKey, min_value_msat: Option, invoice_expiry_delta_secs: u32, current_time: u64, min_final_cltv_expiry_delta: Option, ) -> Result { diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 7a388142912..0d5c8da9657 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -187,6 +187,10 @@ const MAX_UPDATE_ATTEMPTS: u8 = 3; #[cfg(async_payments)] const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60); +// Require offer paths that we receive to last at least 3 months. +#[cfg(async_payments)] +const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 30 * 24 * 60 * 60; + #[cfg(async_payments)] impl AsyncReceiveOfferCache { /// Remove expired offers from the cache, returning whether new offers are needed. @@ -215,6 +219,66 @@ impl AsyncReceiveOfferCache { && self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS } + /// Returns whether the new paths we've just received from the static invoice server should be used + /// to build a new offer. + pub(super) fn should_build_offer_with_paths( + &self, offer_paths: &[BlindedMessagePath], offer_paths_absolute_expiry_secs: Option, + duration_since_epoch: Duration, + ) -> bool { + if self.needs_new_offer_idx(duration_since_epoch).is_none() { + return false; + } + + // Require the offer that would be built using these paths to last at least + // `MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS`. + let min_offer_paths_absolute_expiry = + duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS); + let offer_paths_absolute_expiry = offer_paths_absolute_expiry_secs.unwrap_or(u64::MAX); + if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry { + return false; + } + + // Check that we don't have any current offers that already contain these paths + self.offers_with_idx().all(|(_, offer)| offer.offer.paths() != offer_paths) + } + + /// We've sent a static invoice to the static invoice server for persistence. Cache the + /// corresponding pending offer so we can retry persisting a corresponding invoice with the server + /// until it succeeds, see [`AsyncReceiveOfferCache`] docs. + pub(super) fn cache_pending_offer( + &mut self, offer: Offer, offer_paths_absolute_expiry_secs: Option, offer_nonce: Nonce, + update_static_invoice_path: Responder, duration_since_epoch: Duration, + ) -> Result { + self.prune_expired_offers(duration_since_epoch, false); + + if !self.should_build_offer_with_paths( + offer.paths(), + offer_paths_absolute_expiry_secs, + duration_since_epoch, + ) { + return Err(()); + } + + let idx = match self.needs_new_offer_idx(duration_since_epoch) { + Some(idx) => idx, + None => return Err(()), + }; + + match self.offers.get_mut(idx) { + Some(offer_opt) => { + *offer_opt = Some(AsyncReceiveOffer { + offer, + offer_nonce, + status: OfferStatus::Pending, + update_static_invoice_path, + }); + }, + None => return Err(()), + } + + Ok(idx.try_into().map_err(|_| ())?) + } + /// If we have any empty slots in the cache or offers that can and should be replaced with a fresh /// offer, here we return the index of the slot that needs a new offer. The index is used for /// setting [`ServeStaticInvoice::invoice_slot`] when sending the corresponding new static invoice diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index ef7471cae70..7f9baf7c67b 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -66,7 +66,10 @@ use { crate::offers::offer::Amount, crate::offers::signer, crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}, - crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest}, + crate::onion_message::async_payments::{ + HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, + }, + crate::onion_message::messenger::Responder, }; #[cfg(feature = "dnssec")] @@ -1190,6 +1193,156 @@ where Ok(()) } + /// Handles an incoming [`OfferPaths`] message from the static invoice server, sending out + /// [`ServeStaticInvoice`] onion messages in response if we've built a new async receive offer and + /// need the corresponding [`StaticInvoice`] to be persisted by the static invoice server. + /// + /// Returns `None` if we have enough offers cached already, verification of `message` fails, or we + /// fail to create blinded paths. + #[cfg(async_payments)] + pub(crate) fn handle_offer_paths( + &self, message: OfferPaths, context: AsyncPaymentsContext, responder: Responder, + peers: Vec, usable_channels: Vec, entropy: ES, + router: R, + ) -> Option<(ServeStaticInvoice, MessageContext)> + where + ES::Target: EntropySource, + R::Target: Router, + { + let duration_since_epoch = self.duration_since_epoch(); + match context { + AsyncPaymentsContext::OfferPaths { path_absolute_expiry } => { + if duration_since_epoch > path_absolute_expiry { + return None; + } + }, + _ => return None, + } + + { + // Only respond with `ServeStaticInvoice` if we actually need a new offer built. + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.prune_expired_offers(duration_since_epoch, false); + if !cache.should_build_offer_with_paths( + &message.paths[..], + message.paths_absolute_expiry, + duration_since_epoch, + ) { + return None; + } + } + + let (mut offer_builder, offer_nonce) = + match self.create_async_receive_offer_builder(&*entropy, message.paths) { + Ok((builder, nonce)) => (builder, nonce), + Err(_) => return None, // Only reachable if OfferPaths::paths is empty + }; + if let Some(paths_absolute_expiry) = message.paths_absolute_expiry { + offer_builder = + offer_builder.absolute_expiry(Duration::from_secs(paths_absolute_expiry)); + } + let (offer_id, offer) = match offer_builder.build() { + Ok(offer) => (offer.id(), offer), + Err(_) => { + debug_assert!(false); + return None; + }, + }; + + let (invoice, forward_invoice_request_path) = match self.create_static_invoice_for_server( + &offer, + offer_nonce, + peers, + usable_channels, + &*entropy, + router, + ) { + Ok(res) => res, + Err(()) => return None, + }; + + let res = self.async_receive_offer_cache.lock().unwrap().cache_pending_offer( + offer, + message.paths_absolute_expiry, + offer_nonce, + responder, + duration_since_epoch, + ); + + let invoice_slot = match res { + Ok(idx) => idx, + Err(()) => return None, + }; + + let reply_path_context = { + let path_absolute_expiry = + duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY); + MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted { + offer_id, + path_absolute_expiry, + }) + }; + + let serve_invoice_message = + ServeStaticInvoice { invoice, forward_invoice_request_path, invoice_slot }; + Some((serve_invoice_message, reply_path_context)) + } + + /// Creates a [`StaticInvoice`] and a blinded path for the server to forward invoice requests from + /// payers to our node. + #[cfg(async_payments)] + fn create_static_invoice_for_server( + &self, offer: &Offer, offer_nonce: Nonce, peers: Vec, + usable_channels: Vec, entropy: ES, router: R, + ) -> Result<(StaticInvoice, BlindedMessagePath), ()> + where + ES::Target: EntropySource, + R::Target: Router, + { + let expanded_key = &self.inbound_payment_key; + let duration_since_epoch = self.duration_since_epoch(); + let secp_ctx = &self.secp_ctx; + + let offer_relative_expiry = offer + .absolute_expiry() + .map(|exp| exp.saturating_sub(duration_since_epoch).as_secs()) + .map(|exp_u64| exp_u64.try_into().unwrap_or(u32::MAX)) + .unwrap_or(u32::MAX); + + // Set the invoice to expire at the same time as the offer. We aim to update this invoice as + // often as possible, so there shouldn't be any reason to have it expire earlier than the + // offer. + let payment_secret = inbound_payment::create_for_spontaneous_payment( + expanded_key, + None, // The async receive offers we create are always amount-less + offer_relative_expiry, + duration_since_epoch.as_secs(), + None, + )?; + + let invoice = self + .create_static_invoice_builder( + &router, + &*entropy, + &offer, + offer_nonce, + payment_secret, + offer_relative_expiry, + usable_channels, + peers.clone(), + ) + .and_then(|builder| builder.build_and_sign(secp_ctx)) + .map_err(|_| ())?; + + let nonce = Nonce::from_entropy_source(&*entropy); + let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce }); + let forward_invoice_request_path = self + .create_blinded_paths(peers, context) + .and_then(|paths| paths.into_iter().next().ok_or(()))?; + + Ok((invoice, forward_invoice_request_path)) + } + /// Get the `AsyncReceiveOfferCache` for persistence. pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ { &self.async_receive_offer_cache From 3676cc126856ffe1ab195e4acd5c99847d958801 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 8 May 2025 16:02:31 -0400 Subject: [PATCH 6/8] Mark offer as ready on StaticInvoicePersisted onion message As an async recipient, we need to interactively build a static invoice that an always-online node will serve on our behalf. Once this invoice is built and persisted by the static invoice server, they will send us a confirmation onion message. At this time, mark the corresponding pending offer as ready to receive async payments. --- lightning/src/ln/channelmanager.rs | 7 +++ .../src/offers/async_receive_offer_cache.rs | 43 ++++++++++++++++++- lightning/src/offers/flow.rs | 10 +++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a645ef7aa72..68212f3563e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13465,6 +13465,13 @@ where fn handle_static_invoice_persisted( &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, ) { + #[cfg(async_payments)] + { + let should_persist = self.flow.handle_static_invoice_persisted(_context); + if should_persist { + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + } + } } fn handle_held_htlc_available( diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 0d5c8da9657..121ba99704a 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -22,8 +22,11 @@ use crate::prelude::*; use crate::util::ser::{Readable, Writeable, Writer}; use core::time::Duration; +#[cfg(async_payments)] +use crate::blinded_path::message::AsyncPaymentsContext; + /// The status of this offer in the cache. -#[derive(Clone)] +#[derive(Clone, PartialEq)] enum OfferStatus { /// This offer has been returned to the user from the cache, so it needs to be stored until it /// expires and its invoice needs to be kept updated. @@ -361,6 +364,44 @@ impl AsyncReceiveOfferCache { fn reset_offer_paths_request_attempts(&mut self) { self.offer_paths_request_attempts = 0; } + + /// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice + /// server, which indicates that a new offer was persisted by the server and they are ready to + /// serve the corresponding static invoice to payers on our behalf. + /// + /// Returns a bool indicating whether an offer was added/updated and re-persistence of the cache + /// is needed. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + pub(super) fn static_invoice_persisted( + &mut self, context: AsyncPaymentsContext, duration_since_epoch: Duration, + ) -> bool { + let offer_id = match context { + AsyncPaymentsContext::StaticInvoicePersisted { path_absolute_expiry, offer_id } => { + if duration_since_epoch > path_absolute_expiry { + return false; + } + offer_id + }, + _ => return false, + }; + + let mut offers = self.offers.iter_mut(); + let offer_entry = offers.find(|o| o.as_ref().map_or(false, |o| o.offer.id() == offer_id)); + if let Some(Some(ref mut offer)) = offer_entry { + if offer.status == OfferStatus::Used { + // We succeeded in updating the invoice for a used offer, no re-persistence of the cache + // needed + return false; + } + + offer.status = + OfferStatus::Ready { invoice_confirmed_persisted_at: duration_since_epoch }; + return true; + } + + false + } } impl Writeable for AsyncReceiveOfferCache { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 7f9baf7c67b..a6855f83fa0 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1343,6 +1343,16 @@ where Ok((invoice, forward_invoice_request_path)) } + /// Handles an incoming [`StaticInvoicePersisted`] onion message from the static invoice server. + /// Returns a bool indicating whether the async receive offer cache needs to be re-persisted. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + #[cfg(async_payments)] + pub(crate) fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool { + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.static_invoice_persisted(context, self.duration_since_epoch()) + } + /// Get the `AsyncReceiveOfferCache` for persistence. pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ { &self.async_receive_offer_cache From cd925506df5e0c5126201d6be9ad0fa656c36b65 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 13 May 2025 16:01:30 -0400 Subject: [PATCH 7/8] Refresh served static invoices on each timer tick As an async recipient, we need to interactively build offers and corresponding static invoices, the latter of which an always-online node will serve to payers on our behalf. We want the static invoice server to always have the freshest possible invoice available, so on each timer tick for every offer that is either currently in use or pending confirmation, send them a new invoice. --- lightning/src/ln/channelmanager.rs | 12 ++- .../src/offers/async_receive_offer_cache.rs | 24 +++++ lightning/src/offers/flow.rs | 97 +++++++++++++++++-- lightning/src/onion_message/messenger.rs | 6 ++ 4 files changed, 132 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 68212f3563e..e58a1cabde9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5251,7 +5251,17 @@ where #[cfg(async_payments)] fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { let peers = self.get_peers_for_blinded_path(); - match self.flow.check_refresh_async_receive_offer_cache(peers, timer_tick_occurred) { + let channels = self.list_usable_channels(); + let entropy = &*self.entropy_source; + let router = &*self.router; + let refresh_res = self.flow.check_refresh_async_receive_offer_cache( + peers, + channels, + entropy, + router, + timer_tick_occurred, + ); + match refresh_res { Err(()) => { log_error!( self.logger, diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 121ba99704a..693ab8b8a9b 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -365,6 +365,30 @@ impl AsyncReceiveOfferCache { self.offer_paths_request_attempts = 0; } + /// Returns an iterator over the list of cached offers where we need to send an updated invoice to + /// the static invoice server. + pub(super) fn offers_needing_invoice_refresh( + &self, + ) -> impl Iterator { + // For any offers which are either in use or pending confirmation by the server, we should send + // them a fresh invoice on each timer tick. + self.offers_with_idx().filter_map(|(idx, offer)| { + let needs_invoice_update = + offer.status == OfferStatus::Used || offer.status == OfferStatus::Pending; + if needs_invoice_update { + let offer_slot = idx.try_into().unwrap_or(u16::MAX); + Some(( + &offer.offer, + offer.offer_nonce, + offer_slot, + &offer.update_static_invoice_path, + )) + } else { + None + } + }) + } + /// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice /// server, which indicates that a new offer was persisted by the server and they are ready to /// serve the corresponding static invoice to payers on our behalf. diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index a6855f83fa0..f55a3e512ec 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1134,8 +1134,9 @@ where core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) } - /// Sends out [`OfferPathsRequest`] onion messages if we are an often-offline recipient and are - /// configured to interactively build offers and static invoices with a static invoice server. + /// Sends out [`OfferPathsRequest`] and [`ServeStaticInvoice`] onion messages if we are an + /// often-offline recipient and are configured to interactively build offers and static invoices + /// with a static invoice server. /// /// # Usage /// @@ -1145,9 +1146,14 @@ where /// /// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message. #[cfg(async_payments)] - pub(crate) fn check_refresh_async_receive_offer_cache( - &self, peers: Vec, timer_tick_occurred: bool, - ) -> Result<(), ()> { + pub(crate) fn check_refresh_async_receive_offer_cache( + &self, peers: Vec, usable_channels: Vec, entropy: ES, + router: R, timer_tick_occurred: bool, + ) -> Result<(), ()> + where + ES::Target: EntropySource, + R::Target: Router, + { // Terminate early if this node does not intend to receive async payments. if self.paths_to_static_invoice_server.lock().unwrap().is_empty() { return Ok(()); @@ -1169,7 +1175,7 @@ where path_absolute_expiry: duration_since_epoch .saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY), }); - let reply_paths = match self.create_blinded_paths(peers, context) { + let reply_paths = match self.create_blinded_paths(peers.clone(), context) { Ok(paths) => paths, Err(()) => { return Err(()); @@ -1190,9 +1196,88 @@ where ); } + if timer_tick_occurred { + self.check_refresh_static_invoices( + peers, + usable_channels, + duration_since_epoch, + entropy, + router, + ); + } + Ok(()) } + /// Enqueue onion messages that will used to request invoice refresh from the static invoice + /// server, based on the offers provided by the cache. + #[cfg(async_payments)] + fn check_refresh_static_invoices( + &self, peers: Vec, usable_channels: Vec, + duration_since_epoch: Duration, entropy: ES, router: R, + ) where + ES::Target: EntropySource, + R::Target: Router, + { + let mut serve_static_invoice_msgs = Vec::new(); + { + let cache = self.async_receive_offer_cache.lock().unwrap(); + for offer_and_metadata in cache.offers_needing_invoice_refresh() { + let (offer, offer_nonce, slot_number, update_static_invoice_path) = + offer_and_metadata; + + let (invoice, forward_invreq_path) = match self.create_static_invoice_for_server( + offer, + offer_nonce, + peers.clone(), + usable_channels.clone(), + &*entropy, + &*router, + ) { + Ok((invoice, path)) => (invoice, path), + Err(()) => continue, + }; + + let reply_path_context = { + let path_absolute_expiry = + duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY); + MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted { + path_absolute_expiry, + offer_id: offer.id(), + }) + }; + + let serve_invoice_message = ServeStaticInvoice { + invoice, + forward_invoice_request_path: forward_invreq_path, + invoice_slot: slot_number, + }; + serve_static_invoice_msgs.push(( + serve_invoice_message, + update_static_invoice_path.clone(), + reply_path_context, + )); + } + } + + // Enqueue the new serve_static_invoice messages in a separate loop to avoid holding the offer + // cache lock and the pending_async_payments_messages lock at the same time. + for (serve_invoice_msg, serve_invoice_path, reply_path_ctx) in serve_static_invoice_msgs { + let reply_paths = match self.create_blinded_paths(peers.clone(), reply_path_ctx) { + Ok(paths) => paths, + Err(()) => continue, + }; + + let message = AsyncPaymentsMessage::ServeStaticInvoice(serve_invoice_msg); + enqueue_onion_message_with_reply_paths( + message, + &[serve_invoice_path.into_blinded_path()], + reply_paths, + &mut self.pending_async_payments_messages.lock().unwrap(), + ); + } + } + /// Handles an incoming [`OfferPaths`] message from the static invoice server, sending out /// [`ServeStaticInvoice`] onion messages in response if we've built a new async receive offer and /// need the corresponding [`StaticInvoice`] to be persisted by the static invoice server. diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index e8db43d7591..3b2566119de 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -432,6 +432,12 @@ impl Responder { context: Some(context), } } + + /// Converts a [`Responder`] into its inner [`BlindedMessagePath`]. + #[cfg(async_payments)] + pub(crate) fn into_blinded_path(self) -> BlindedMessagePath { + self.reply_path + } } /// Instructions for how and where to send the response to an onion message. From 0608de15254b9c8e745001eb193d4975e246a21f Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Fri, 11 Apr 2025 16:43:58 -0400 Subject: [PATCH 8/8] Add API to retrieve a cached async receive offer Over the past multiple commits we've implemented interactively building async receive offers with a static invoice server that will service invoice requests on our behalf as an async recipient. Here we add an API to retrieve a resulting offer so we can receive payments when we're offline. --- lightning/src/ln/channelmanager.rs | 26 +++++++++++++- .../src/offers/async_receive_offer_cache.rs | 35 +++++++++++++++++++ lightning/src/offers/flow.rs | 9 +++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e58a1cabde9..bfeb181e755 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11028,9 +11028,29 @@ where #[cfg(c_bindings)] create_refund_builder!(self, RefundMaybeWithDerivedMetadataBuilder); + /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. Will only + /// return an offer if [`Self::set_paths_to_static_invoice_server`] was called and we succeeded in + /// interactively building a [`StaticInvoice`] with the static invoice server. + /// + /// Useful for posting offers to receive payments later, such as posting an offer on a website. + #[cfg(async_payments)] + pub fn get_async_receive_offer(&self) -> Result { + let (offer, needs_persist) = self.flow.get_async_receive_offer()?; + if needs_persist { + // We need to re-persist the cache if a fresh offer was just marked as used to ensure we + // continue to keep this offer's invoice updated and don't replace it with the server. + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + } + Ok(offer) + } + /// Create an offer for receiving async payments as an often-offline recipient. /// - /// Because we may be offline when the payer attempts to request an invoice, you MUST: + /// Instead of using this method, it is preferable to call + /// [`Self::set_paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_async_receive_offer`]. + /// + /// If you want to build the [`StaticInvoice`] manually using this method instead, you MUST: /// 1. Provide at least 1 [`BlindedMessagePath`] terminating at an always-online node that will /// serve the [`StaticInvoice`] created from this offer on our behalf. /// 2. Use [`Self::create_static_invoice_builder`] to create a [`StaticInvoice`] from this @@ -11047,6 +11067,10 @@ where /// Creates a [`StaticInvoiceBuilder`] from the corresponding [`Offer`] and [`Nonce`] that were /// created via [`Self::create_async_receive_offer_builder`]. If `relative_expiry` is unset, the /// invoice's expiry will default to [`STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY`]. + /// + /// Instead of using this method to manually build the invoice, it is preferable to set + /// [`Self::set_paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_async_receive_offer`]. #[cfg(async_payments)] pub fn create_static_invoice_builder<'a>( &self, offer: &'a Offer, offer_nonce: Nonce, relative_expiry: Option, diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 693ab8b8a9b..dd10fb099c2 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -196,6 +196,41 @@ const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 30 * 24 * 60 * 60; #[cfg(async_payments)] impl AsyncReceiveOfferCache { + /// Retrieve a cached [`Offer`] for receiving async payments as an often-offline recipient, as + /// well as returning a bool indicating whether the cache needs to be re-persisted. + /// + // We need to re-persist the cache if a fresh offer was just marked as used to ensure we continue + // to keep this offer's invoice updated and don't replace it with the server. + pub fn get_async_receive_offer( + &mut self, duration_since_epoch: Duration, + ) -> Result<(Offer, bool), ()> { + self.prune_expired_offers(duration_since_epoch, false); + + // Find the freshest unused offer, where "freshness" is based on when the invoice was confirmed + // persisted by the server. See `OfferStatus::Ready`. + let newest_unused_offer_opt = self + .unused_offers() + .max_by(|(_, _, persisted_at_a), (_, _, persisted_at_b)| { + persisted_at_a.cmp(&persisted_at_b) + }) + .map(|(idx, offer, _)| (idx, offer.offer.clone())); + if let Some((idx, newest_ready_offer)) = newest_unused_offer_opt { + self.offers[idx].as_mut().map(|offer| offer.status = OfferStatus::Used); + return Ok((newest_ready_offer, true)); + } + + // If no unused offers are available, return the used offer with the latest absolute expiry + self.offers_with_idx() + .filter(|(_, offer)| matches!(offer.status, OfferStatus::Used)) + .max_by(|a, b| { + let abs_expiry_a = a.1.offer.absolute_expiry().unwrap_or(Duration::MAX); + let abs_expiry_b = b.1.offer.absolute_expiry().unwrap_or(Duration::MAX); + abs_expiry_a.cmp(&abs_expiry_b) + }) + .map(|(_, cache_offer)| (cache_offer.offer.clone(), false)) + .ok_or(()) + } + /// Remove expired offers from the cache, returning whether new offers are needed. pub(super) fn prune_expired_offers( &mut self, duration_since_epoch: Duration, force_reset_request_attempts: bool, diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index f55a3e512ec..0990251c311 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1134,6 +1134,15 @@ where core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) } + /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. Will only + /// return an offer if [`Self::set_paths_to_static_invoice_server`] was called and we succeeded in + /// interactively building a [`StaticInvoice`] with the static invoice server. + #[cfg(async_payments)] + pub(crate) fn get_async_receive_offer(&self) -> Result<(Offer, bool), ()> { + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.get_async_receive_offer(self.duration_since_epoch()) + } + /// Sends out [`OfferPathsRequest`] and [`ServeStaticInvoice`] onion messages if we are an /// often-offline recipient and are configured to interactively build offers and static invoices /// with a static invoice server.