From 80509b518d7285c709e645015d55047f144822fb Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 3 Feb 2025 17:12:06 -0800 Subject: [PATCH 1/8] Add config for paths to a static invoice server As part of being an async recipient, we need to support interactively building an offer and static invoice with an always-online node that will serve static invoices on our behalf. Add a config field containing blinded message paths that async recipients can use to request blinded paths that will be included in their offer. Payers will forward invoice requests over the paths returned by the server, and receive a static invoice in response if the recipient is offline. --- lightning/src/util/config.rs | 12 ++++++++++++ lightning/src/util/ser.rs | 1 + 2 files changed, 13 insertions(+) diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index e98b237691c..472ab464316 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -10,8 +10,10 @@ //! Various user-configurable channel limits and settings which ChannelManager //! applies for you. +use crate::blinded_path::message::BlindedMessagePath; use crate::ln::channel::MAX_FUNDING_SATOSHIS_NO_WUMBO; use crate::ln::channelmanager::{BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT}; +use crate::prelude::*; #[cfg(fuzzing)] use crate::util::ser::Readable; @@ -923,6 +925,14 @@ pub struct UserConfig { /// /// Default value: `false` pub enable_dual_funded_channels: bool, + /// [`BlindedMessagePath`]s to reach an always-online node that will serve [`StaticInvoice`]s on + /// our behalf. Useful if we are an often-offline recipient that wants to receive async payments. + /// Payers will send [`InvoiceRequest`]s over these paths, and receive a [`StaticInvoice`] in + /// response from the always-online node. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + pub paths_to_static_invoice_server: Vec, } impl Default for UserConfig { @@ -937,6 +947,7 @@ impl Default for UserConfig { accept_intercept_htlcs: false, manually_handle_bolt12_invoices: false, enable_dual_funded_channels: false, + paths_to_static_invoice_server: Vec::new(), } } } @@ -957,6 +968,7 @@ impl Readable for UserConfig { accept_intercept_htlcs: Readable::read(reader)?, manually_handle_bolt12_invoices: Readable::read(reader)?, enable_dual_funded_channels: Readable::read(reader)?, + paths_to_static_invoice_server: Vec::new(), }) } } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 737a558946e..3bb47bfdb25 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1080,6 +1080,7 @@ impl_for_vec!(crate::chain::channelmonitor::ChannelMonitorUpdate); impl_for_vec!(crate::ln::channelmanager::MonitorUpdateCompletionAction); impl_for_vec!(crate::ln::channelmanager::PaymentClaimDetails); impl_for_vec!(crate::ln::msgs::SocketAddress); +impl_for_vec!(crate::blinded_path::message::BlindedMessagePath); impl_for_vec!((A, B), A, B); impl_for_vec!(SerialId); impl_for_vec!(InteractiveTxInput); From e5079a66177dbd6af009b3a20fcf92a192261cb5 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 5 Feb 2025 17:19:51 -0800 Subject: [PATCH 2/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 | 19 +- lightning/src/onion_message/async_payments.rs | 189 +++++++++++++++++- .../src/onion_message/functional_tests.rs | 25 ++- lightning/src/onion_message/messenger.rs | 22 ++ 6 files changed, 303 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 50ef6d70464..09d2595c317 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::{ @@ -12995,6 +12996,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 a989d172687..c036a2b82aa 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -30,7 +30,7 @@ use crate::util::ser::{VecWriter, Writeable, Writer}; use crate::ln::peer_channel_encryptor::{PeerChannelEncryptor, NextNoiseStep, MessageBuf, MSG_BUF_ALLOC_SIZE}; use crate::ln::wire; use crate::ln::wire::{Encode, Type}; -use crate::onion_message::async_payments::{AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc}; +use crate::onion_message::async_payments::{AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, ReleaseHeldHtlc, StaticInvoicePersisted}; use crate::onion_message::dns_resolution::{DNSResolverMessageHandler, DNSResolverMessage, DNSSECProof, DNSSECQuery}; use crate::onion_message::messenger::{CustomOnionMessageHandler, Responder, ResponseInstruction, MessageSendInstructions}; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; @@ -148,6 +148,23 @@ 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..2dd9bbff284 100644 --- a/lightning/src/onion_message/async_payments.rs +++ b/lightning/src/onion_message/async_payments.rs @@ -9,15 +9,22 @@ //! 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}; +use core::time::Duration; + // 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 +32,43 @@ 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 via + /// [`UserConfig::paths_to_static_invoice_server`], an [`OfferPaths`] message should be returned. + /// + /// [`UserConfig::paths_to_static_invoice_server`]: crate::util::config::UserConfig::paths_to_static_invoice_server + 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 +94,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 +125,57 @@ 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 duration 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, +} + +/// 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 +186,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 +228,20 @@ 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), +}); + +impl_writeable_tlv_based!(StaticInvoicePersisted, {}); + impl_writeable_tlv_based!(HeldHtlcAvailable, {}); impl_writeable_tlv_based!(ReleaseHeldHtlc, {}); @@ -90,7 +250,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 +264,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 +275,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 +286,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 +299,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 +312,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 b28819ee692..673696cfd11 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 16c9a3704e32650368e9fdf1e6ec7e2e6f1a5219 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 10 Apr 2025 15:26:12 -0400 Subject: [PATCH 3/8] Track cached async receive offers in offers::Flow 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 the static invoice is confirmed as persisted by 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 | 106 ++++++++++++++++++ lightning/src/offers/flow.rs | 27 +++++ lightning/src/offers/mod.rs | 1 + 4 files changed, 140 insertions(+) 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 09d2595c317..5cd7eafd4c5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -86,6 +86,7 @@ use crate::ln::outbound_payment::{ SendAlongPathArgs, 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, @@ -13881,6 +13882,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(()) @@ -14454,6 +14456,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), @@ -14471,6 +14474,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); @@ -15157,6 +15161,8 @@ 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, &args.default_config.paths_to_static_invoice_server[..] ); let channel_manager = ChannelManager { 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..4ced2b03015 --- /dev/null +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -0,0 +1,106 @@ +// 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::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; + +struct AsyncReceiveOffer { + offer: Offer, + /// We determine whether an offer is expiring "soon" based on how far the offer is into its total + /// lifespan, using this field. + offer_created_at: Duration, + + /// The below fields are used to generate and persist a new static invoice with the invoice + /// server, if the invoice is expiring prior to the corresponding offer. 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, + static_invoice_absolute_expiry: Duration, + invoice_update_attempts: u8, +} + +impl_writeable_tlv_based!(AsyncReceiveOffer, { + (0, offer, required), + (2, offer_nonce, required), + (4, offer_created_at, required), + (6, update_static_invoice_path, required), + (8, static_invoice_absolute_expiry, required), + (10, invoice_update_attempts, (static_value, 0)), +}); + +/// 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 +/// with the static invoice server. +/// +/// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow +pub struct AsyncReceiveOfferCache { + 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, + /// Used to determine whether enough time has passed since our last request for offer paths that + /// more requests should be allowed to go out. + #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag + last_offer_paths_request_timestamp: Duration, +} + +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, + last_offer_paths_request_timestamp: Duration::from_secs(0), + } + } +} + +impl Writeable for AsyncReceiveOfferCache { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + write_tlv_fields!(w, { + (0, self.offers, 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), + }); + let offers: Vec = offers; + Ok(Self { + offers, + offer_paths_request_attempts: 0, + last_offer_paths_request_timestamp: Duration::from_secs(0), + }) + } +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 38f674141b1..1eff3c62ce8 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: Vec, #[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: 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, + paths_to_static_invoice_server: &[BlindedMessagePath], + ) -> Self { + self.async_receive_offer_cache = Mutex::new(async_receive_offer_cache); + self.paths_to_static_invoice_server = paths_to_static_invoice_server.to_vec(); + 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 08ea9ab4ebc2992e6448342bb9fe3add83b9d169 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 7 May 2025 12:45:59 -0400 Subject: [PATCH 4/8] Check 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 expire. --- lightning/src/blinded_path/message.rs | 31 ++++++ lightning/src/ln/channelmanager.rs | 24 +++++ .../src/offers/async_receive_offer_cache.rs | 97 +++++++++++++++++++ lightning/src/offers/flow.rs | 73 +++++++++++++- lightning/src/offers/signer.rs | 18 ++++ 5 files changed, 242 insertions(+), 1 deletion(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 164cfcfb1ad..c6abf6075ad 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -404,6 +404,32 @@ 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 { + /// A nonce used for authenticating that an [`OfferPaths`] message is valid for a preceding + /// [`OfferPathsRequest`] that we sent as an async recipient. + /// + /// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + nonce: Nonce, + /// Authentication code for the [`OfferPaths`] message. + /// + /// Prevents nodes from creating their own blinded path that terminates at our async recipient + /// node and causing us to cache an unintended async receive offer. + /// + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + hmac: Hmac, + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// As an async recipient we use this field to time out a static invoice server from sending us + /// offer paths if we are no longer configured to accept paths from them. + 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 +512,11 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (2, hmac, required), (4, path_absolute_expiry, required), }, + (2, OfferPaths) => { + (0, nonce, required), + (2, hmac, required), + (4, 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 5cd7eafd4c5..b85c2533ee6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5139,6 +5139,20 @@ where ) } + #[cfg(async_payments)] + fn check_refresh_async_receive_offers(&self) { + let peers = self.get_peers_for_blinded_path(); + match self.flow.check_refresh_async_receive_offers(peers, &*self.entropy_source) { + 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, @@ -7133,6 +7147,9 @@ where duration_since_epoch, &self.pending_events ); + #[cfg(async_payments)] + self.check_refresh_async_receive_offers(); + // 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. @@ -11654,6 +11671,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_offers(); res } diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 4ced2b03015..fa9ed291110 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -81,6 +81,103 @@ impl AsyncReceiveOfferCache { } } +// The target number of offers we want to have cached at any given time, to mitigate too much +// reuse of the same offer. +#[cfg(async_payments)] +const NUM_CACHED_OFFERS_TARGET: usize = 3; + +// The max number of times we'll attempt to request offer paths or attempt to refresh a static +// invoice before giving up. +#[cfg(async_payments)] +const MAX_UPDATE_ATTEMPTS: u8 = 3; + +// If we run out of attempts to request offer paths from the static invoice server, we'll stop +// sending requests for some time. After this amount of time has passed, more requests are allowed +// to be sent out. +#[cfg(async_payments)] +const PATHS_REQUESTS_RESET_INTERVAL: Duration = Duration::from_secs(3 * 60 * 60); + +// If an offer is 90% of the way through its lifespan, it's expiring soon. This allows us to be +// flexible for various offer lifespans, i.e. an offer that lasts 10 days expires soon after 9 days +// and an offer that lasts 10 years expires soon after 9 years. +#[cfg(async_payments)] +const OFFER_EXPIRES_SOON_THRESHOLD_PERCENT: u64 = 90; + +#[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) -> bool { + // Remove expired offers from the cache. + let mut offer_was_removed = false; + self.offers.retain(|offer| { + if offer.offer.is_expired_no_std(duration_since_epoch) { + offer_was_removed = true; + return false; + } + true + }); + + // If we just removed a newly expired offer, force allowing more paths request attempts. + if offer_was_removed { + self.reset_offer_paths_request_attempts(); + } else { + // If we haven't attempted to request new paths in a long time, allow more requests to go out + // if/when needed. + self.check_reset_offer_paths_request_attempts(duration_since_epoch); + } + + self.needs_new_offers(duration_since_epoch) + && self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS + } + + /// Returns a bool indicating whether new offers are needed in the cache. + fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool { + // If we have fewer than NUM_CACHED_OFFERS_TARGET offers that aren't expiring soon, indicate + // that new offers should be interactively built. + let num_unexpiring_offers = self + .offers + .iter() + .filter(|offer| { + let offer_absolute_expiry = offer.offer.absolute_expiry().unwrap_or(Duration::MAX); + let offer_created_at = offer.offer_created_at; + let offer_lifespan = + offer_absolute_expiry.saturating_sub(offer_created_at).as_secs(); + let elapsed = duration_since_epoch.saturating_sub(offer_created_at).as_secs(); + + // If an offer is in the last 10% of its lifespan, it's expiring soon. + elapsed.saturating_mul(100) + < offer_lifespan.saturating_mul(OFFER_EXPIRES_SOON_THRESHOLD_PERCENT) + }) + .count(); + + num_unexpiring_offers < NUM_CACHED_OFFERS_TARGET + } + + // 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, in case + // the server goes unresponsive. + pub(super) fn new_offers_requested(&mut self, duration_since_epoch: Duration) { + self.offer_paths_request_attempts += 1; + self.last_offer_paths_request_timestamp = duration_since_epoch; + } + + /// If we haven't sent an offer paths request in a long time, reset the limit to allow more + /// requests to be sent out if/when needed. + fn check_reset_offer_paths_request_attempts(&mut self, duration_since_epoch: Duration) { + let should_reset = + self.last_offer_paths_request_timestamp.saturating_add(PATHS_REQUESTS_RESET_INTERVAL) + < duration_since_epoch; + if should_reset { + self.reset_offer_paths_request_attempts(); + } + } + + fn reset_offer_paths_request_attempts(&mut self) { + self.offer_paths_request_attempts = 0; + self.last_offer_paths_request_timestamp = Duration::from_secs(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 1eff3c62ce8..1926d8526da 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")] @@ -217,6 +217,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, @@ -1105,6 +1110,72 @@ 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 every few minutes or so, to keep the + /// offers cache updated. + /// + /// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message. + #[cfg(async_payments)] + pub(crate) fn check_refresh_async_receive_offers( + &self, peers: Vec, entropy: ES, + ) -> Result<(), ()> + where + ES::Target: EntropySource, + { + // Terminate early if this node does not intend to receive async payments. + if self.paths_to_static_invoice_server.is_empty() { + return Ok(()); + } + + let expanded_key = &self.inbound_payment_key; + 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); + + // If we need new offers, send out offer paths request messages to the static invoice server. + if needs_new_offers { + let nonce = Nonce::from_entropy_source(&*entropy); + let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { + nonce, + hmac: signer::hmac_for_offer_paths_context(nonce, expanded_key), + 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(duration_since_epoch); + + let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {}); + enqueue_onion_message_with_reply_paths( + message, + &self.paths_to_static_invoice_server[..], + reply_paths, + &mut self.pending_async_payments_messages.lock().unwrap(), + ); + } + + Ok(()) + } + /// 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/signer.rs b/lightning/src/offers/signer.rs index 329b90d2076..55a4dc0551b 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -55,6 +55,11 @@ const PAYMENT_TLVS_HMAC_INPUT: &[u8; 16] = &[8; 16]; #[cfg(async_payments)] const ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT: &[u8; 16] = &[9; 16]; +// HMAC input used in `AsyncPaymentsContext::OfferPaths` to authenticate inbound offer_paths onion +// messages. +#[cfg(async_payments)] +const ASYNC_PAYMENTS_OFFER_PATHS_INPUT: &[u8; 16] = &[10; 16]; + /// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be /// verified. #[derive(Clone)] @@ -570,3 +575,16 @@ pub(crate) fn verify_held_htlc_available_context( Err(()) } } + +#[cfg(async_payments)] +pub(crate) fn hmac_for_offer_paths_context( + nonce: Nonce, expanded_key: &ExpandedKey, +) -> Hmac { + const IV_BYTES: &[u8; IV_LEN] = b"LDK Offer Paths~"; + let mut hmac = expanded_key.hmac_for_offer(); + hmac.input(IV_BYTES); + hmac.input(&nonce.0); + hmac.input(ASYNC_PAYMENTS_OFFER_PATHS_INPUT); + + Hmac::from_engine(hmac) +} From e47be9708d6df54a06f998ef8cd999bc284131db 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. --- lightning/src/blinded_path/message.rs | 64 +++++++ lightning/src/ln/channelmanager.rs | 25 ++- lightning/src/ln/inbound_payment.rs | 2 +- .../src/offers/async_receive_offer_cache.rs | 27 +++ lightning/src/offers/flow.rs | 165 +++++++++++++++++- lightning/src/offers/signer.rs | 29 +++ 6 files changed, 308 insertions(+), 4 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index c6abf6075ad..c7c733064b3 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -23,6 +23,8 @@ use crate::ln::channelmanager::PaymentId; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::offers::nonce::Nonce; +use crate::offers::offer::Offer; +use crate::onion_message::messenger::Responder; use crate::onion_message::packet::ControlTlvs; use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph}; use crate::sign::{EntropySource, NodeSigner, Recipient}; @@ -430,6 +432,58 @@ pub enum AsyncPaymentsContext { /// offer paths if we are no longer configured to accept paths from them. 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 offer 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. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + offer: Offer, + /// A [`Nonce`] useful for updating the [`StaticInvoice`] that corresponds to the + /// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer + /// lived than the invoice. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + offer_nonce: Nonce, + /// Useful to determine how far an offer is into its lifespan, to decide whether the offer is + /// expiring soon and we should start building a new one. + offer_created_at: core::time::Duration, + /// A [`Responder`] useful for updating the [`StaticInvoice`] that corresponds to the + /// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer + /// lived than the invoice. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + update_static_invoice_path: Responder, + /// The time as duration since the Unix epoch at which the [`StaticInvoice`] expires, used to track + /// when we need to generate and persist a new invoice with the static invoice server. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + static_invoice_absolute_expiry: core::time::Duration, + /// A nonce used for authenticating that a [`StaticInvoicePersisted`] message is valid for a + /// preceding [`ServeStaticInvoice`] message. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + nonce: Nonce, + /// Authentication code for the [`StaticInvoicePersisted`] message. + /// + /// Prevents nodes from creating their own blinded path to us and causing us to cache an + /// unintended async receive offer. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + hmac: Hmac, + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// Prevents a static invoice server from causing an async recipient to cache an old offer if + /// the recipient is no longer configured to use that server. + 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. @@ -517,6 +571,16 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (2, hmac, required), (4, path_absolute_expiry, required), }, + (3, StaticInvoicePersisted) => { + (0, offer, required), + (2, offer_nonce, required), + (4, offer_created_at, required), + (6, update_static_invoice_path, required), + (8, static_invoice_absolute_expiry, required), + (10, nonce, required), + (12, hmac, required), + (14, 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 b85c2533ee6..10cfffff357 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13031,7 +13031,30 @@ 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, + }; + 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 fa9ed291110..39ba6ee8559 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -16,6 +16,8 @@ use crate::io::Read; use crate::ln::msgs::DecodeError; use crate::offers::nonce::Nonce; use crate::offers::offer::Offer; +#[cfg(async_payments)] +use crate::onion_message::async_payments::OfferPaths; use crate::onion_message::messenger::Responder; use crate::prelude::*; use crate::util::ser::{Readable, Writeable, Writer}; @@ -103,6 +105,9 @@ const PATHS_REQUESTS_RESET_INTERVAL: Duration = Duration::from_secs(3 * 60 * 60) #[cfg(async_payments)] const OFFER_EXPIRES_SOON_THRESHOLD_PERCENT: u64 = 90; +#[cfg(async_payments)] +const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 60 * 60; + #[cfg(async_payments)] impl AsyncReceiveOfferCache { /// Remove expired offers from the cache, returning whether new offers are needed. @@ -130,6 +135,28 @@ 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, message: &OfferPaths, duration_since_epoch: Duration, + ) -> bool { + if !self.needs_new_offers(duration_since_epoch) { + return false; + } + + // Require the offer that would be built using these paths to last at least a few hours. + let min_offer_paths_absolute_expiry = + duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS); + let offer_paths_absolute_expiry = + message.paths_absolute_expiry.map(|exp| exp.as_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.iter().all(|offer| offer.offer.paths() != message.paths) + } + /// Returns a bool indicating whether new offers are needed in the cache. fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool { // If we have fewer than NUM_CACHED_OFFERS_TARGET offers that aren't expiring soon, indicate diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 1926d8526da..b71b0151e41 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -65,8 +65,14 @@ use { crate::blinded_path::payment::AsyncBolt12OfferContext, crate::offers::offer::Amount, crate::offers::signer, - crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}, - crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest}, + crate::offers::static_invoice::{ + StaticInvoice, StaticInvoiceBuilder, + DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, + }, + crate::onion_message::async_payments::{ + HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, + }, + crate::onion_message::messenger::Responder, }; #[cfg(feature = "dnssec")] @@ -1176,6 +1182,161 @@ where Ok(()) } + /// Handles an incoming [`OfferPaths`] message from the static invoice server, sending out + /// [`ServeStaticInvoice`] onion messages in response if we want to use the paths we've received + /// to build and cache an async receive offer. + /// + /// 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 expanded_key = &self.inbound_payment_key; + let duration_since_epoch = self.duration_since_epoch(); + + match context { + AsyncPaymentsContext::OfferPaths { nonce, hmac, path_absolute_expiry } => { + if let Err(()) = signer::verify_offer_paths_context(nonce, hmac, expanded_key) { + return None; + } + if duration_since_epoch > path_absolute_expiry { + return None; + } + }, + _ => return None, + } + + { + // Only respond with `ServeStaticInvoice` if we actually need a new offer built. + let cache = self.async_receive_offer_cache.lock().unwrap(); + if !cache.should_build_offer_with_paths(&message, 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(paths_absolute_expiry); + } + let offer = match offer_builder.build() { + Ok(offer) => offer, + Err(_) => { + debug_assert!(false); + return None; + }, + }; + + let (serve_invoice_message, reply_path_context) = match self + .create_serve_static_invoice_message( + offer, + offer_nonce, + duration_since_epoch, + peers, + usable_channels, + responder, + &*entropy, + router, + ) { + Ok((msg, context)) => (msg, context), + Err(()) => return None, + }; + + let context = MessageContext::AsyncPayments(reply_path_context); + Some((serve_invoice_message, context)) + } + + /// Creates a [`ServeStaticInvoice`] onion message, including reply path context for the static + /// invoice server to respond with [`StaticInvoicePersisted`]. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + #[cfg(async_payments)] + fn create_serve_static_invoice_message( + &self, offer: Offer, offer_nonce: Nonce, offer_created_at: Duration, + peers: Vec, usable_channels: Vec, + update_static_invoice_path: Responder, entropy: ES, router: R, + ) -> Result<(ServeStaticInvoice, AsyncPaymentsContext), ()> + 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)) + .unwrap_or_else(|| Duration::from_secs(u64::MAX)); + + // We limit the static invoice lifetime to STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, meaning we'll + // need to refresh the static invoice using the reply path to the `OfferPaths` message if the + // offer expires later than that. + let static_invoice_relative_expiry = core::cmp::min( + offer_relative_expiry.as_secs(), + STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY.as_secs(), + ) as u32; + + let payment_secret = inbound_payment::create_for_spontaneous_payment( + expanded_key, + None, // The async receive offers we create are always amount-less + static_invoice_relative_expiry, + self.duration_since_epoch().as_secs(), + None, + )?; + + let invoice = self + .create_static_invoice_builder( + &router, + &*entropy, + &offer, + offer_nonce, + payment_secret, + static_invoice_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(()))?; + + let reply_path_context = { + let nonce = Nonce::from_entropy_source(entropy); + let hmac = signer::hmac_for_static_invoice_persisted_context(nonce, expanded_key); + let static_invoice_absolute_expiry = + invoice.created_at().saturating_add(invoice.relative_expiry()); + let path_absolute_expiry = + duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY); + AsyncPaymentsContext::StaticInvoicePersisted { + offer, + offer_nonce, + offer_created_at, + update_static_invoice_path, + static_invoice_absolute_expiry, + nonce, + hmac, + path_absolute_expiry, + } + }; + + Ok((ServeStaticInvoice { invoice, forward_invoice_request_path }, reply_path_context)) + } + /// 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/signer.rs b/lightning/src/offers/signer.rs index 55a4dc0551b..445539129f8 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -60,6 +60,11 @@ const ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT: &[u8; 16] = &[9; 16]; #[cfg(async_payments)] const ASYNC_PAYMENTS_OFFER_PATHS_INPUT: &[u8; 16] = &[10; 16]; +// HMAC input used in `AsyncPaymentsContext::StaticInvoicePersisted` to authenticate inbound +// static_invoice_persisted onion messages. +#[cfg(async_payments)] +const ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT: &[u8; 16] = &[11; 16]; + /// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be /// verified. #[derive(Clone)] @@ -588,3 +593,27 @@ pub(crate) fn hmac_for_offer_paths_context( Hmac::from_engine(hmac) } + +#[cfg(async_payments)] +pub(crate) fn verify_offer_paths_context( + nonce: Nonce, hmac: Hmac, expanded_key: &ExpandedKey, +) -> Result<(), ()> { + if hmac_for_offer_paths_context(nonce, expanded_key) == hmac { + Ok(()) + } else { + Err(()) + } +} + +#[cfg(async_payments)] +pub(crate) fn hmac_for_static_invoice_persisted_context( + nonce: Nonce, expanded_key: &ExpandedKey, +) -> Hmac { + const IV_BYTES: &[u8; IV_LEN] = b"LDK InvPersisted"; + let mut hmac = expanded_key.hmac_for_offer(); + hmac.input(IV_BYTES); + hmac.input(&nonce.0); + hmac.input(ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT); + + Hmac::from_engine(hmac) +} From 8dbb87d224e2a9e94af8e805e5bf6e90befe4e09 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 8 May 2025 16:02:31 -0400 Subject: [PATCH 6/8] Cache offer 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, cache the corresponding offer and mark it as ready to receive async payments. --- lightning/src/ln/channelmanager.rs | 7 ++ .../src/offers/async_receive_offer_cache.rs | 118 +++++++++++++++++- lightning/src/offers/flow.rs | 33 +++++ lightning/src/offers/signer.rs | 11 ++ 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 10cfffff357..7fafd469324 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13066,6 +13066,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 39ba6ee8559..8ec89aa58de 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -16,12 +16,15 @@ use crate::io::Read; use crate::ln::msgs::DecodeError; use crate::offers::nonce::Nonce; use crate::offers::offer::Offer; -#[cfg(async_payments)] -use crate::onion_message::async_payments::OfferPaths; use crate::onion_message::messenger::Responder; use crate::prelude::*; use crate::util::ser::{Readable, Writeable, Writer}; use core::time::Duration; +#[cfg(async_payments)] +use { + crate::blinded_path::message::AsyncPaymentsContext, + crate::onion_message::async_payments::OfferPaths, +}; struct AsyncReceiveOffer { offer: Offer, @@ -88,6 +91,13 @@ impl AsyncReceiveOfferCache { #[cfg(async_payments)] const NUM_CACHED_OFFERS_TARGET: usize = 3; +// Refuse to store offers if they will exceed the maximum cache size or the maximum number of +// offers. +#[cfg(async_payments)] +const MAX_CACHE_SIZE: usize = (1 << 10) * 70; // 70KiB +#[cfg(async_payments)] +const MAX_OFFERS: usize = 100; + // The max number of times we'll attempt to request offer paths or attempt to refresh a static // invoice before giving up. #[cfg(async_payments)] @@ -203,6 +213,110 @@ impl AsyncReceiveOfferCache { self.offer_paths_request_attempts = 0; self.last_offer_paths_request_timestamp = Duration::from_secs(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. + pub(super) fn static_invoice_persisted( + &mut self, context: AsyncPaymentsContext, duration_since_epoch: Duration, + ) -> bool { + let ( + candidate_offer, + candidate_offer_nonce, + offer_created_at, + update_static_invoice_path, + static_invoice_absolute_expiry, + ) = match context { + AsyncPaymentsContext::StaticInvoicePersisted { + offer, + offer_nonce, + offer_created_at, + update_static_invoice_path, + static_invoice_absolute_expiry, + .. + } => ( + offer, + offer_nonce, + offer_created_at, + update_static_invoice_path, + static_invoice_absolute_expiry, + ), + _ => return false, + }; + + if candidate_offer.is_expired_no_std(duration_since_epoch) { + return false; + } + if static_invoice_absolute_expiry < duration_since_epoch { + return false; + } + + // If the candidate offer is known, either this is a duplicate message or we updated the + // corresponding static invoice that is stored with the server. + if let Some(existing_offer) = + self.offers.iter_mut().find(|cached_offer| cached_offer.offer == candidate_offer) + { + // The blinded path used to update the static invoice corresponding to an offer should never + // change because we reuse the same path every time we update. + debug_assert_eq!(existing_offer.update_static_invoice_path, update_static_invoice_path); + debug_assert_eq!(existing_offer.offer_nonce, candidate_offer_nonce); + + let needs_persist = + existing_offer.static_invoice_absolute_expiry != static_invoice_absolute_expiry; + + // Since this is the most recent update we've received from the static invoice server, assume + // that the invoice that was just persisted is the only invoice that the server has stored + // corresponding to this offer. + existing_offer.static_invoice_absolute_expiry = static_invoice_absolute_expiry; + existing_offer.invoice_update_attempts = 0; + + return needs_persist; + } + + let candidate_offer = AsyncReceiveOffer { + offer: candidate_offer, + offer_nonce: candidate_offer_nonce, + offer_created_at, + update_static_invoice_path, + static_invoice_absolute_expiry, + invoice_update_attempts: 0, + }; + + // If we have room in the cache, go ahead and add this new offer so we have more options. We + // should generally never get close to the cache limit because we limit the number of requests + // for offer persistence that are sent to begin with. + let candidate_cache_size = + self.serialized_length().saturating_add(candidate_offer.serialized_length()); + if self.offers.len() < MAX_OFFERS && candidate_cache_size <= MAX_CACHE_SIZE { + self.offers.push(candidate_offer); + return true; + } + + // Swap out our lowest expiring offer for this candidate offer if needed. Otherwise we'd be + // risking a situation where all of our existing offers expire soon but we still ignore this one + // even though it's fresh. + const NEVER_EXPIRES: Duration = Duration::from_secs(u64::MAX); + let (soonest_expiring_offer_idx, soonest_offer_expiry) = self + .offers + .iter() + .map(|offer| offer.offer.absolute_expiry().unwrap_or(NEVER_EXPIRES)) + .enumerate() + .min_by(|(_, offer_exp_a), (_, offer_exp_b)| offer_exp_a.cmp(offer_exp_b)) + .unwrap_or_else(|| { + debug_assert!(false); + (0, NEVER_EXPIRES) + }); + + if soonest_offer_expiry < candidate_offer.offer.absolute_expiry().unwrap_or(NEVER_EXPIRES) { + self.offers[soonest_expiring_offer_idx] = candidate_offer; + return true; + } + + false + } } impl Writeable for AsyncReceiveOfferCache { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index b71b0151e41..e1caab388ae 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1337,6 +1337,39 @@ where Ok((ServeStaticInvoice { invoice, forward_invoice_request_path }, reply_path_context)) } + /// 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 expanded_key = &self.inbound_payment_key; + let duration_since_epoch = self.duration_since_epoch(); + + if let AsyncPaymentsContext::StaticInvoicePersisted { + nonce, + hmac, + path_absolute_expiry, + .. + } = context + { + if let Err(()) = + signer::verify_static_invoice_persisted_context(nonce, hmac, expanded_key) + { + return false; + } + + if duration_since_epoch > path_absolute_expiry { + return false; + } + } else { + return false; + } + + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.static_invoice_persisted(context, duration_since_epoch) + } + /// 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/signer.rs b/lightning/src/offers/signer.rs index 445539129f8..66aa1d4229e 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -617,3 +617,14 @@ pub(crate) fn hmac_for_static_invoice_persisted_context( Hmac::from_engine(hmac) } + +#[cfg(async_payments)] +pub(crate) fn verify_static_invoice_persisted_context( + nonce: Nonce, hmac: Hmac, expanded_key: &ExpandedKey, +) -> Result<(), ()> { + if hmac_for_static_invoice_persisted_context(nonce, expanded_key) == hmac { + Ok(()) + } else { + Err(()) + } +} From afe842e36376eff32acab98cab8e0429a2d64527 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 13 May 2025 16:01:30 -0400 Subject: [PATCH 7/8] Check and refresh served static invoices 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. Offers may be very long-lived and have a longer expiration than their corresponding static invoice. Therefore, persist a fresh invoice with the static invoice server when the current invoice gets close to expiration. --- lightning/src/ln/channelmanager.rs | 5 +- .../src/offers/async_receive_offer_cache.rs | 59 +++++++++++- lightning/src/offers/flow.rs | 89 +++++++++++++++++-- lightning/src/onion_message/messenger.rs | 6 ++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7fafd469324..81de1d814e2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5142,7 +5142,10 @@ where #[cfg(async_payments)] fn check_refresh_async_receive_offers(&self) { let peers = self.get_peers_for_blinded_path(); - match self.flow.check_refresh_async_receive_offers(peers, &*self.entropy_source) { + let channels = self.list_usable_channels(); + let entropy = &*self.entropy_source; + let router = &*self.router; + match self.flow.check_refresh_async_receive_offers(peers, channels, entropy, router) { 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 8ec89aa58de..947dce8e911 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -22,7 +22,7 @@ use crate::util::ser::{Readable, Writeable, Writer}; use core::time::Duration; #[cfg(async_payments)] use { - crate::blinded_path::message::AsyncPaymentsContext, + crate::blinded_path::message::AsyncPaymentsContext, crate::offers::offer::OfferId, crate::onion_message::async_payments::OfferPaths, }; @@ -214,6 +214,63 @@ impl AsyncReceiveOfferCache { self.last_offer_paths_request_timestamp = Duration::from_secs(0); } + /// Returns an iterator over the list of cached offers where the invoice is expiring soon and we + /// need to send an updated one to the static invoice server. + pub(super) fn offers_needing_invoice_refresh( + &self, duration_since_epoch: Duration, + ) -> impl Iterator { + self.offers.iter().filter_map(move |offer| { + const ONE_DAY: Duration = Duration::from_secs(24 * 60 * 60); + + if offer.offer.is_expired_no_std(duration_since_epoch) { + return None; + } + if offer.invoice_update_attempts >= MAX_UPDATE_ATTEMPTS { + return None; + } + + let time_until_invoice_expiry = + offer.static_invoice_absolute_expiry.saturating_sub(duration_since_epoch); + let time_until_offer_expiry = offer + .offer + .absolute_expiry() + .unwrap_or_else(|| Duration::from_secs(u64::MAX)) + .saturating_sub(duration_since_epoch); + + // Update the invoice if it expires in less than a day, as long as the offer has a longer + // expiry than that. + let needs_update = time_until_invoice_expiry < ONE_DAY + && time_until_offer_expiry > time_until_invoice_expiry; + if needs_update { + Some(( + &offer.offer, + offer.offer_nonce, + offer.offer_created_at, + &offer.update_static_invoice_path, + )) + } else { + None + } + }) + } + + /// Indicates that we've sent onion messages attempting to update the static invoice corresponding + /// to the provided offer_id. Calling this method allows the cache to self-limit how many invoice + /// update requests are sent. + /// + /// Errors if the offer corresponding to the provided offer_id could not be found. + pub(super) fn increment_invoice_update_attempts( + &mut self, offer_id: OfferId, + ) -> Result<(), ()> { + match self.offers.iter_mut().find(|offer| offer.offer.id() == offer_id) { + Some(offer) => { + offer.invoice_update_attempts += 1; + Ok(()) + }, + None => return Err(()), + } + } + /// 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 e1caab388ae..3ebad954be0 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1116,8 +1116,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 /// @@ -1126,11 +1127,13 @@ 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_offers( - &self, peers: Vec, entropy: ES, + pub(crate) fn check_refresh_async_receive_offers( + &self, peers: Vec, usable_channels: Vec, entropy: ES, + router: R, ) -> 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.is_empty() { @@ -1157,7 +1160,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(()); @@ -1179,9 +1182,85 @@ where ); } + self.check_refresh_static_invoices(peers, usable_channels, entropy, router); + Ok(()) } + /// If a static invoice server has persisted an offer for us but the corresponding invoice is + /// expiring soon, we need to refresh that invoice. Here we enqueue the onion messages that will + /// be used to request invoice refresh, based on the offers provided by the cache. + #[cfg(async_payments)] + fn check_refresh_static_invoices( + &self, peers: Vec, usable_channels: Vec, entropy: ES, + router: R, + ) where + ES::Target: EntropySource, + R::Target: Router, + { + let duration_since_epoch = self.duration_since_epoch(); + + let mut serve_static_invoice_messages = Vec::new(); + { + let cache = self.async_receive_offer_cache.lock().unwrap(); + for offer_and_metadata in cache.offers_needing_invoice_refresh(duration_since_epoch) { + let (offer, offer_nonce, offer_created_at, update_static_invoice_path) = + offer_and_metadata; + let offer_id = offer.id(); + + let (serve_invoice_msg, reply_path_ctx) = match self + .create_serve_static_invoice_message( + offer.clone(), + offer_nonce, + offer_created_at, + peers.clone(), + usable_channels.clone(), + update_static_invoice_path.clone(), + &*entropy, + &*router, + ) { + Ok((msg, ctx)) => (msg, ctx), + Err(()) => continue, + }; + serve_static_invoice_messages.push(( + serve_invoice_msg, + update_static_invoice_path.clone(), + reply_path_ctx, + offer_id, + )); + } + } + + // 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, offer_id) in + serve_static_invoice_messages + { + let context = MessageContext::AsyncPayments(reply_path_ctx); + let reply_paths = match self.create_blinded_paths(peers.clone(), context) { + Ok(paths) => paths, + Err(()) => continue, + }; + + { + // We can't fail past this point, so indicate to the cache that we've requested an invoice + // update. + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + if cache.increment_invoice_update_attempts(offer_id).is_err() { + continue; + } + } + + let message = AsyncPaymentsMessage::ServeStaticInvoice(serve_invoice_msg); + enqueue_onion_message_with_reply_paths( + message, + &[serve_invoice_path.into_reply_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 want to use the paths we've received /// to build and cache an async receive offer. diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index e8db43d7591..891c222236a 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_reply_path(self) -> BlindedMessagePath { + self.reply_path + } } /// Instructions for how and where to send the response to an onion message. From ae932008ec3719f3f0a2e35279bfae02267c6a9b 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 cached async receive offers Over the past several commits we've implemented interactively building an async receive offer with a static invoice server that will service invoice requests on our behalf as an async recipient. Here we add an API to retrieve the resulting offers so we can receive payments when we're offline. --- lightning/src/ln/channelmanager.rs | 20 ++++++++++++++++++- .../src/offers/async_receive_offer_cache.rs | 20 +++++++++++++++++++ lightning/src/offers/flow.rs | 8 ++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 81de1d814e2..f0f4e53fe86 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10742,9 +10742,23 @@ where #[cfg(c_bindings)] create_refund_builder!(self, RefundMaybeWithDerivedMetadataBuilder); + /// Retrieve our cached [`Offer`]s for receiving async payments as an often-offline recipient. + /// Will only be set if [`UserConfig::paths_to_static_invoice_server`] is set 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_cached_async_receive_offers(&self) -> Vec { + self.flow.get_cached_async_receive_offers() + } + /// 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 set + /// [`UserConfig::paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_cached_async_receive_offers`]. + /// + /// 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 @@ -10761,6 +10775,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 + /// [`UserConfig::paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_cached_async_receive_offers`]. #[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 947dce8e911..9407f399f10 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -120,6 +120,26 @@ const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 60 * 60; #[cfg(async_payments)] impl AsyncReceiveOfferCache { + /// Retrieve our cached [`Offer`]s for receiving async payments as an often-offline recipient. + pub fn offers(&self, duration_since_epoch: Duration) -> Vec { + const NEVER_EXPIRES: Duration = Duration::from_secs(u64::MAX); + + self.offers + .iter() + .filter_map(|offer| { + if offer.static_invoice_absolute_expiry < duration_since_epoch { + None + } else if offer.offer.absolute_expiry().unwrap_or(NEVER_EXPIRES) + < duration_since_epoch + { + None + } else { + Some(offer.offer.clone()) + } + }) + .collect() + } + /// Remove expired offers from the cache, returning whether new offers are needed. pub(super) fn prune_expired_offers(&mut self, duration_since_epoch: Duration) -> bool { // Remove expired offers from the cache. diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 3ebad954be0..d030ddf8b29 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1116,6 +1116,14 @@ where core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) } + /// Retrieve our cached [`Offer`]s for receiving async payments as an often-offline recipient. + /// Will only be set if [`UserConfig::paths_to_static_invoice_server`] is set and we succeeded in + /// interactively building a [`StaticInvoice`] with the static invoice server. + #[cfg(async_payments)] + pub(crate) fn get_cached_async_receive_offers(&self) -> Vec { + self.async_receive_offer_cache.lock().unwrap().offers(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.