From ad21515909b193d0cd6bd8a966731ee029827529 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 3 Feb 2025 17:12:06 -0800 Subject: [PATCH 01/16] 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 672783a127f..ecc70f2b393 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; @@ -884,6 +886,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 { @@ -898,6 +908,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(), } } } @@ -918,6 +929,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 4c35a16e437476b6308a73ae3719fb279dfffe9b Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 5 Feb 2025 17:19:51 -0800 Subject: [PATCH 02/16] 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 a5782dacd42..e9b8b3d57d5 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 b018c6c74cd..14f2ea8f96e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -97,7 +97,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::{ @@ -12806,6 +12807,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, + ) { + } + #[rustfmt::skip] fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 5c3bfd48d55..d09790ffbca 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -31,7 +31,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}; @@ -150,6 +150,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 2a6c7449b0fe8065c6ea680e56f82b7c96d16d21 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 10 Apr 2025 15:26:12 -0400 Subject: [PATCH 03/16] 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 14f2ea8f96e..94710cdb90a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -85,6 +85,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, @@ -13683,6 +13684,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(()) @@ -14247,6 +14249,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), @@ -14264,6 +14267,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); @@ -14950,6 +14954,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 9b1b64486a99b0de4440df7d3a8402f7abcb1aa1 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 7 May 2025 12:45:59 -0400 Subject: [PATCH 04/16] 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 94710cdb90a..1fef2f788a3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5105,7 +5105,21 @@ 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(()) => {}, + } + } + #[rustfmt::skip] + #[cfg(async_payments)] fn initiate_async_payment( &self, invoice: &StaticInvoice, payment_id: PaymentId ) -> Result<(), Bolt12PaymentError> { @@ -7049,6 +7063,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. @@ -11484,6 +11501,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 165748cfcdadc54e61b3a985c0711f66cdaac55f Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 7 May 2025 16:29:44 -0400 Subject: [PATCH 05/16] 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 1fef2f788a3..4ee7b386ea1 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12842,7 +12842,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 37b328d9d4d815c048355bde27ef44362e5cc2f4 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 8 May 2025 16:02:31 -0400 Subject: [PATCH 06/16] 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 4ee7b386ea1..21bd4f386c8 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12877,6 +12877,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); + } + } } #[rustfmt::skip] 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 4c320970eb6b869febc4aff5e10c40678b76663c Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 13 May 2025 16:01:30 -0400 Subject: [PATCH 07/16] 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 21bd4f386c8..d0c3eafdf7b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5107,7 +5107,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 17f3f5a0a2ff7102b3ffc58ab13ed66734cb1c3f Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Fri, 11 Apr 2025 16:43:58 -0400 Subject: [PATCH 08/16] 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 d0c3eafdf7b..efac9de22d5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10608,9 +10608,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 @@ -10627,6 +10641,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)] #[rustfmt::skip] pub fn create_static_invoice_builder<'a>( 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. From 457e951cc8d9b9be0076484eba1dd3a9370156be Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 20 Feb 2025 16:45:58 -0500 Subject: [PATCH 09/16] BOLT 12 {Static}Invoices: expose more is_expired methods In upcoming commits, we need to check whether a static invoice or its underlying offer is expired in no-std builds. Here we expose the methods to do so. The methods could instead be kept private to the crate, but they seem potentially useful. --- lightning/src/offers/invoice.rs | 4 ++++ lightning/src/offers/invoice_macros.rs | 5 +++++ lightning/src/offers/static_invoice.rs | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 3615850a22e..6a9096338ff 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1223,6 +1223,10 @@ impl InvoiceContents { is_expired(self.created_at(), self.relative_expiry()) } + fn is_expired_no_std(&self, duration_since_epoch: Duration) -> bool { + self.created_at().saturating_add(self.relative_expiry()) < duration_since_epoch + } + fn payment_hash(&self) -> PaymentHash { self.fields().payment_hash } diff --git a/lightning/src/offers/invoice_macros.rs b/lightning/src/offers/invoice_macros.rs index af3c2a6155e..1ac6e40b896 100644 --- a/lightning/src/offers/invoice_macros.rs +++ b/lightning/src/offers/invoice_macros.rs @@ -131,6 +131,11 @@ macro_rules! invoice_accessors_common { ($self: ident, $contents: expr, $invoice $contents.is_expired() } + /// Whether the invoice has expired given the current time as duration since the Unix epoch. + pub fn is_expired_no_std(&$self, duration_since_epoch: Duration) -> bool { + $contents.is_expired_no_std(duration_since_epoch) + } + /// Fallback addresses for paying the invoice on-chain, in order of most-preferred to /// least-preferred. pub fn fallbacks(&$self) -> Vec
{ diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 08170fda867..8fa5790161e 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -395,6 +395,18 @@ impl StaticInvoice { self.signature } + /// Whether the [`Offer`] that this invoice is based on is expired. + #[cfg(feature = "std")] + pub fn is_offer_expired(&self) -> bool { + self.contents.is_expired() + } + + /// Whether the [`Offer`] that this invoice is based on is expired, given the current time as + /// duration since the Unix epoch. + pub fn is_offer_expired_no_std(&self, duration_since_epoch: Duration) -> bool { + self.contents.is_offer_expired_no_std(duration_since_epoch) + } + #[allow(unused)] // TODO: remove this once we remove the `async_payments` cfg flag pub(crate) fn is_from_same_offer(&self, invreq: &InvoiceRequest) -> bool { let invoice_offer_tlv_stream = @@ -411,7 +423,6 @@ impl InvoiceContents { self.offer.is_expired() } - #[cfg(not(feature = "std"))] fn is_offer_expired_no_std(&self, duration_since_epoch: Duration) -> bool { self.offer.is_expired_no_std(duration_since_epoch) } @@ -528,6 +539,10 @@ impl InvoiceContents { is_expired(self.created_at(), self.relative_expiry()) } + fn is_expired_no_std(&self, duration_since_epoch: Duration) -> bool { + self.created_at().saturating_add(self.relative_expiry()) < duration_since_epoch + } + fn fallbacks(&self) -> Vec
{ let chain = self.chain(); self.fallbacks From fe111124cf7abed54165632d7ee1a41823f685f1 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 3 Feb 2025 15:26:01 -0800 Subject: [PATCH 10/16] Util for blinded paths to configure an async recipient As part of serving static invoices to payers on behalf of often-offline recipients, these recipients need a way to contact the static invoice server to retrieve blinded paths to include in their offers. Add a utility to create blinded paths for this purpose as a static invoice server. The recipient will be configured with the resulting paths and use them to request offer paths on startup. --- lightning/src/blinded_path/message.rs | 32 +++++++++++++++++++ lightning/src/ln/channelmanager.rs | 30 ++++++++++++++++++ lightning/src/offers/flow.rs | 45 +++++++++++++++++++++++++++ lightning/src/offers/signer.rs | 18 +++++++++++ 4 files changed, 125 insertions(+) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index c7c733064b3..ee458ac61bb 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -406,6 +406,33 @@ pub enum OffersContext { /// [`AsyncPaymentsMessage`]: crate::onion_message::async_payments::AsyncPaymentsMessage #[derive(Clone, Debug)] pub enum AsyncPaymentsContext { + /// Context used by a [`BlindedMessagePath`] that an async recipient is configured with in + /// [`UserConfig::paths_to_static_invoice_server`], provided back to the static invoice server in + /// corresponding [`OfferPathsRequest`]s. + /// + /// [`UserConfig::paths_to_static_invoice_server`]: crate::util::config::UserConfig::paths_to_static_invoice_server + /// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest + OfferPathsRequest { + /// An identifier for the async recipient that is requesting blinded paths to include in their + /// [`Offer::paths`]. This ID will be surfaced when the async recipient eventually sends a + /// corresponding [`ServeStaticInvoice`] message, and can be used to rate limit the recipient. + /// + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + recipient_id_nonce: Nonce, + /// Authentication code for the [`OfferPathsRequest`]. + /// + /// Prevents nodes from requesting offer paths from the static invoice server without having + /// been previously configured with a [`BlindedMessagePath`] that the server generated. + /// + /// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest + hmac: Hmac, + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// Useful to timeout async recipients that are no longer supported as clients. + path_absolute_expiry: core::time::Duration, + }, /// Context used by a reply path to an [`OfferPathsRequest`], provided back to us as an async /// recipient in corresponding [`OfferPaths`] messages from the static invoice server. /// @@ -581,6 +608,11 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (12, hmac, required), (14, path_absolute_expiry, required), }, + (4, OfferPathsRequest) => { + (0, recipient_id_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 efac9de22d5..c1c60d88a7f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -11013,6 +11013,36 @@ where inbound_payment::get_payment_preimage(payment_hash, payment_secret, &self.inbound_payment_key) } + /// [`BlindedMessagePath`]s for an async recipient to communicate with this node and interactively + /// build [`Offer`]s and [`StaticInvoice`]s for receiving async payments. + /// + /// ## Usage + /// 1. Static invoice server calls [`Self::blinded_paths_for_async_recipient`] + /// 2. Static invoice server communicates the resulting paths out-of-band to the async recipient, + /// who includes these paths in their [`UserConfig::paths_to_static_invoice_server`] + /// 3. Async recipient automatically sends [`OfferPathsRequest`]s over the configured paths, and + /// uses the resulting paths from the server's [`OfferPaths`] response to build their async + /// receive offer + /// + /// If `relative_expiry` is unset, the [`BlindedMessagePath`]s expiry will default to + /// [`DEFAULT_CONFIG_PATH_RELATIVE_EXPIRY`]. + /// + /// Returns the paths to be included in the recipient's + /// [`UserConfig::paths_to_static_invoice_server`] as well as a nonce that uniquely identifies the + /// recipient that has been configured with these paths. // TODO link to events that surface this nonce + /// + /// [`UserConfig::paths_to_static_invoice_server`]: crate::util::config::UserConfig::paths_to_static_invoice_server + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + /// [`DEFAULT_CONFIG_PATH_RELATIVE_EXPIRY`]: crate::onion_message::async_payments::DEFAULT_CONFIG_PATH_RELATIVE_EXPIRY + #[cfg(async_payments)] + pub fn blinded_paths_for_async_recipient( + &self, relative_expiry: Option, + ) -> Result<(Vec, Nonce), ()> { + let peers = self.get_peers_for_blinded_path(); + let entropy = &*self.entropy_source; + self.flow.blinded_paths_for_async_recipient(peers, relative_expiry, entropy) + } + #[cfg(any(test, async_payments))] #[rustfmt::skip] pub(super) fn duration_since_epoch(&self) -> Duration { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index d030ddf8b29..3134b9f423e 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -232,6 +232,51 @@ impl OffersMessageFlow where MR::Target: MessageRouter, { + /// [`BlindedMessagePath`]s for an async recipient to communicate with this node and interactively + /// build [`Offer`]s and [`StaticInvoice`]s for receiving async payments. + /// + /// ## Usage + /// 1. Static invoice server calls [`Self::blinded_paths_for_async_recipient`] + /// 2. Static invoice server communicates the resulting paths out-of-band to the async recipient, + /// who includes these paths in their [`UserConfig::paths_to_static_invoice_server`] + /// 3. Async recipient automatically sends [`OfferPathsRequest`]s to the server over the + /// configured paths, and uses the paths from the server's [`OfferPaths`] response to build + /// their async receive offer + /// + /// If `relative_expiry` is unset, the [`BlindedMessagePath`]s expiry will default to + /// [`DEFAULT_CONFIG_PATH_RELATIVE_EXPIRY`]. + /// + /// Returns the paths to be included in the recipient's + /// [`UserConfig::paths_to_static_invoice_server`] as well as a nonce that uniquely identifies the + /// recipient that has been configured with these paths. // TODO link to events that surface this nonce + /// + /// [`UserConfig::paths_to_static_invoice_server`]: crate::util::config::UserConfig::paths_to_static_invoice_server + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + /// [`DEFAULT_CONFIG_PATH_RELATIVE_EXPIRY`]: crate::onion_message::async_payments::DEFAULT_CONFIG_PATH_RELATIVE_EXPIRY + #[cfg(async_payments)] + pub fn blinded_paths_for_async_recipient( + &self, peers: Vec, relative_expiry: Option, entropy: ES, + ) -> Result<(Vec, Nonce), ()> + where + ES::Target: EntropySource, + { + let expanded_key = &self.inbound_payment_key; + + let path_absolute_expiry = relative_expiry + .unwrap_or(Duration::from_secs(u64::MAX)) + .saturating_add(self.duration_since_epoch()); + + let recipient_id_nonce = Nonce::from_entropy_source(entropy); + let hmac = signer::hmac_for_offer_paths_request_context(recipient_id_nonce, expanded_key); + + let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPathsRequest { + recipient_id_nonce, + hmac, + path_absolute_expiry, + }); + self.create_blinded_paths(peers, context).map(|paths| (paths, recipient_id_nonce)) + } + /// Creates a collection of blinded paths by delegating to [`MessageRouter`] based on /// the path's intended lifetime. /// diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index 66aa1d4229e..4c9d315a3f8 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -65,6 +65,11 @@ const ASYNC_PAYMENTS_OFFER_PATHS_INPUT: &[u8; 16] = &[10; 16]; #[cfg(async_payments)] const ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT: &[u8; 16] = &[11; 16]; +/// HMAC input used in `AsyncPaymentsContext::OfferPathsRequest` to authenticate inbound +/// offer_paths_request onion messages. +#[cfg(async_payments)] +const ASYNC_PAYMENTS_OFFER_PATHS_REQUEST_INPUT: &[u8; 16] = &[12; 16]; + /// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be /// verified. #[derive(Clone)] @@ -581,6 +586,19 @@ pub(crate) fn verify_held_htlc_available_context( } } +#[cfg(async_payments)] +pub(crate) fn hmac_for_offer_paths_request_context( + nonce: Nonce, expanded_key: &ExpandedKey, +) -> Hmac { + const IV_BYTES: &[u8; IV_LEN] = b"LDK Paths Please"; // TODO + let mut hmac = expanded_key.hmac_for_offer(); + hmac.input(IV_BYTES); + hmac.input(&nonce.0); + hmac.input(ASYNC_PAYMENTS_OFFER_PATHS_REQUEST_INPUT); + + Hmac::from_engine(hmac) +} + #[cfg(async_payments)] pub(crate) fn hmac_for_offer_paths_context( nonce: Nonce, expanded_key: &ExpandedKey, From bd9add501efafadf1d4675d49908d5e7e2d47c62 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 6 Feb 2025 16:29:42 -0800 Subject: [PATCH 11/16] Send offer paths in response to requests As part of serving static invoices to payers on behalf of often-offline recipients, we need to provide the async recipient with blinded message paths to include in their offers. Support responding to inbound requests for offer paths from async recipients. --- lightning/src/blinded_path/message.rs | 91 +++++++++++++++++++++++++++ lightning/src/ln/channelmanager.rs | 15 +++++ lightning/src/offers/flow.rs | 82 ++++++++++++++++++++++++ lightning/src/offers/signer.rs | 47 ++++++++++++++ 4 files changed, 235 insertions(+) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index ee458ac61bb..5d5146aeea1 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -36,6 +36,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use core::mem; use core::ops::Deref; +use core::time::Duration; /// A blinded path to be used for sending or receiving a message, hiding the identity of the /// recipient. @@ -343,6 +344,47 @@ pub enum OffersContext { /// [`Offer`]: crate::offers::offer::Offer nonce: Nonce, }, + /// Context used by a [`BlindedMessagePath`] within the [`Offer`] of an async recipient. + /// + /// This variant is received by the static invoice server when handling an [`InvoiceRequest`] on + /// behalf of said async recipient. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + StaticInvoiceRequested { + /// An identifier for the async recipient for whom the static invoice server is serving + /// [`StaticInvoice`]s. Used to look up a corresponding [`StaticInvoice`] to return to the payer + /// if the recipient is offline. + /// + /// Also useful for the server to rate limit the number of [`InvoiceRequest`]s it will respond + /// to on recipient's behalf. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + recipient_id_nonce: Nonce, + + /// A nonce used for authenticating that a received [`InvoiceRequest`] is valid for a preceding + /// [`OfferPaths`] message sent by the static invoice server. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + nonce: Nonce, + + /// Authentication code for the [`InvoiceRequest`]. + /// + /// Prevents nodes from creating their own blinded path to the static invoice server and causing + /// them to unintentionally hit their database looking for a [`StaticInvoice`] to return. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + hmac: Hmac, + + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// Useful to timeout async recipients that are no longer supported as clients. + path_absolute_expiry: Duration, + }, /// Context used by a [`BlindedMessagePath`] within a [`Refund`] or as a reply path for an /// [`InvoiceRequest`]. /// @@ -459,6 +501,43 @@ 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 an [`OfferPaths`] message, provided back to the static invoice + /// server in corresponding [`ServeStaticInvoice`] messages. + /// + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + ServeStaticInvoice { + /// An identifier for the async recipient that is requesting that a [`StaticInvoice`] be served + /// on their behalf. + /// + /// Useful as a key to retrieve the invoice when payers send an [`InvoiceRequest`] to the static + /// invoice server. Also useful to rate limit the invoices being persisted on behalf of a + /// particular recipient. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + recipient_id_nonce: Nonce, + /// A nonce used for authenticating that a [`ServeStaticInvoice`] message is valid for a preceding + /// [`OfferPaths`] message. + /// + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + nonce: Nonce, + /// Authentication code for the [`ServeStaticInvoice`] message. + /// + /// Prevents nodes from creating their own blinded path to the static invoice server and causing + /// them to persist an unintended [`StaticInvoice`]. + /// + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + hmac: Hmac, + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// Useful to timeout async recipients that are no longer supported as clients. + path_absolute_expiry: core::time::Duration, + }, /// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in /// corresponding [`StaticInvoicePersisted`] messages. /// @@ -580,6 +659,12 @@ impl_writeable_tlv_based_enum!(OffersContext, (1, nonce, required), (2, hmac, required) }, + (3, StaticInvoiceRequested) => { + (0, recipient_id_nonce, required), + (2, nonce, required), + (4, hmac, required), + (6, path_absolute_expiry, required), + }, ); impl_writeable_tlv_based_enum!(AsyncPaymentsContext, @@ -613,6 +698,12 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (2, hmac, required), (4, path_absolute_expiry, required), }, + (5, ServeStaticInvoice) => { + (0, recipient_id_nonce, required), + (2, nonce, required), + (4, hmac, required), + (6, 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 c1c60d88a7f..e75e9ad5e8f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12887,6 +12887,21 @@ where &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, _responder: Option, ) -> Option<(OfferPaths, ResponseInstruction)> { + #[cfg(async_payments)] + { + let peers = self.get_peers_for_blinded_path(); + let (message, reply_path_context) = match self.flow.handle_offer_paths_request( + _context, + peers, + &*self.entropy_source, + ) { + Some(msg) => msg, + None => return None, + }; + _responder.map(|resp| (message, resp.respond_with_reply_path(reply_path_context))) + } + + #[cfg(not(async_payments))] None } diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 3134b9f423e..f6e1d00bf3d 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -228,6 +228,10 @@ const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10; #[cfg(async_payments)] const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200); +// Default to async receive offers and the paths used to update them lasting 1 year. +#[cfg(async_payments)] +const DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY: Duration = Duration::from_secs(365 * 24 * 60 * 60); + impl OffersMessageFlow where MR::Target: MessageRouter, @@ -1314,6 +1318,84 @@ where } } + /// Handles an incoming [`OfferPathsRequest`] onion message from an often-offline recipient who + /// wants us (the static invoice server) to serve [`StaticInvoice`]s to payers on their behalf. + /// Sends out [`OfferPaths`] onion messages in response. + #[cfg(async_payments)] + pub(crate) fn handle_offer_paths_request( + &self, context: AsyncPaymentsContext, peers: Vec, entropy: ES, + ) -> Option<(OfferPaths, MessageContext)> + where + ES::Target: EntropySource, + { + let expanded_key = &self.inbound_payment_key; + let duration_since_epoch = self.duration_since_epoch(); + + // First verify the message context to make sure we created the blinded path that this message + // was received over. + let recipient_id_nonce = match context { + AsyncPaymentsContext::OfferPathsRequest { + recipient_id_nonce, + hmac, + path_absolute_expiry, + } => { + if let Err(()) = signer::verify_offer_paths_request_context( + recipient_id_nonce, + hmac, + expanded_key, + ) { + return None; + } + if duration_since_epoch > path_absolute_expiry { + return None; + } + recipient_id_nonce + }, + _ => return None, + }; + + // Next create the blinded paths that will be included in the async recipient's offer. + let (offer_paths, paths_expiry) = { + let path_absolute_expiry = + duration_since_epoch.saturating_add(DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY); + let nonce = Nonce::from_entropy_source(&*entropy); + let hmac = signer::hmac_for_async_recipient_invreq_context(nonce, expanded_key); + let context = OffersContext::StaticInvoiceRequested { + recipient_id_nonce, + nonce, + hmac, + path_absolute_expiry, + }; + match self.create_blinded_paths_using_absolute_expiry( + context, + Some(path_absolute_expiry), + peers, + ) { + Ok(paths) => (paths, path_absolute_expiry), + Err(()) => return None, + } + }; + + // Finally create a reply path so that the recipient can respond to our offer_paths message with + // the static invoice that they create, that corresponds to the offer containing our paths. + let reply_path_context = { + let nonce = Nonce::from_entropy_source(entropy); + let path_absolute_expiry = + duration_since_epoch.saturating_add(DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY); + let hmac = signer::hmac_for_serve_static_invoice_context(nonce, expanded_key); + MessageContext::AsyncPayments(AsyncPaymentsContext::ServeStaticInvoice { + nonce, + recipient_id_nonce, + hmac, + path_absolute_expiry, + }) + }; + + let offer_paths_om = + OfferPaths { paths: offer_paths, paths_absolute_expiry: Some(paths_expiry) }; + return Some((offer_paths_om, reply_path_context)); + } + /// 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/offers/signer.rs b/lightning/src/offers/signer.rs index 4c9d315a3f8..e7703b86b9d 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -70,6 +70,16 @@ const ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT: &[u8; 16] = &[11; 16]; #[cfg(async_payments)] const ASYNC_PAYMENTS_OFFER_PATHS_REQUEST_INPUT: &[u8; 16] = &[12; 16]; +/// HMAC input used in `OffersContext::StaticInvoiceRequested` to authenticate inbound invoice +/// requests that are being serviced on behalf of async recipients. +#[cfg(async_payments)] +const ASYNC_PAYMENTS_INVREQ: &[u8; 16] = &[13; 16]; + +/// HMAC input used in `AsyncPaymentsContext::ServeStaticInvoice` to authenticate inbound +/// serve_static_invoice onion messages. +#[cfg(async_payments)] +const ASYNC_PAYMENTS_SERVE_STATIC_INVOICE_INPUT: &[u8; 16] = &[14; 16]; + /// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be /// verified. #[derive(Clone)] @@ -599,6 +609,17 @@ pub(crate) fn hmac_for_offer_paths_request_context( Hmac::from_engine(hmac) } +#[cfg(async_payments)] +pub(crate) fn verify_offer_paths_request_context( + nonce: Nonce, hmac: Hmac, expanded_key: &ExpandedKey, +) -> Result<(), ()> { + if hmac_for_offer_paths_request_context(nonce, expanded_key) == hmac { + Ok(()) + } else { + Err(()) + } +} + #[cfg(async_payments)] pub(crate) fn hmac_for_offer_paths_context( nonce: Nonce, expanded_key: &ExpandedKey, @@ -623,6 +644,19 @@ pub(crate) fn verify_offer_paths_context( } } +#[cfg(async_payments)] +pub(crate) fn hmac_for_serve_static_invoice_context( + nonce: Nonce, expanded_key: &ExpandedKey, +) -> Hmac { + const IV_BYTES: &[u8; IV_LEN] = b"LDK Serve Inv~~~"; + let mut hmac = expanded_key.hmac_for_offer(); + hmac.input(IV_BYTES); + hmac.input(&nonce.0); + hmac.input(ASYNC_PAYMENTS_SERVE_STATIC_INVOICE_INPUT); + + Hmac::from_engine(hmac) +} + #[cfg(async_payments)] pub(crate) fn hmac_for_static_invoice_persisted_context( nonce: Nonce, expanded_key: &ExpandedKey, @@ -646,3 +680,16 @@ pub(crate) fn verify_static_invoice_persisted_context( Err(()) } } + +#[cfg(async_payments)] +pub(crate) fn hmac_for_async_recipient_invreq_context( + nonce: Nonce, expanded_key: &ExpandedKey, +) -> Hmac { + const IV_BYTES: &[u8; IV_LEN] = b"LDK Async Invreq"; + let mut hmac = expanded_key.hmac_for_offer(); + hmac.input(IV_BYTES); + hmac.input(&nonce.0); + hmac.input(ASYNC_PAYMENTS_INVREQ); + + Hmac::from_engine(hmac) +} From a52354b00571bca30e0c68434c4e8a95ff08fb76 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 18 Feb 2025 17:30:08 -0500 Subject: [PATCH 12/16] Static invoice server: persist invoices once built As part of serving static invoices to payers on behalf of often-offline recipients, the recipient will send us the final static invoice once it's done being interactively built. We will then persist this invoice and confirm to them that the corresponding offer is ready to be used for async payments. Surface an event once the invoice is received and expose an API to tell the recipient that it's ready for payments. --- lightning/src/events/mod.rs | 38 +++++++++++++++++++++ lightning/src/ln/channelmanager.rs | 30 +++++++++++++++++ lightning/src/offers/flow.rs | 54 ++++++++++++++++++++++++++++++ lightning/src/offers/signer.rs | 11 ++++++ 4 files changed, 133 insertions(+) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index c16305bcca0..ccf2f69f9cc 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -52,6 +52,9 @@ use bitcoin::{OutPoint, Transaction}; use core::ops::Deref; use core::time::Duration; +#[cfg(async_payments)] +use crate::offers::nonce::Nonce; + #[allow(unused_imports)] use crate::prelude::*; @@ -1572,6 +1575,32 @@ pub enum Event { /// onion messages. peer_node_id: PublicKey, }, + /// As a static invoice server, we received a [`StaticInvoice`] from an async recipient that wants + /// us to serve the invoice to payers on their behalf when they are offline. This event will only + /// be generated if we previously created paths using + /// [`ChannelManager::blinded_paths_for_async_recipient`] and configured the recipient with them + /// via [`UserConfig::paths_to_static_invoice_server`]. + /// + /// [`ChannelManager::blinded_paths_for_async_recipient`]: crate::ln::channelmanager::ChannelManager::blinded_paths_for_async_recipient + /// [`UserConfig::paths_to_static_invoice_server`]: crate::util::config::UserConfig::paths_to_static_invoice_server + #[cfg(async_payments)] + PersistStaticInvoice { + /// The invoice that should be persisted and later provided to payers when handling a future + /// `Event::StaticInvoiceRequested`. + invoice: StaticInvoice, + /// An identifier for the recipient, originally surfaced in + /// [`ChannelManager::blinded_paths_for_async_recipient`]. When an + /// `Event::StaticInvoiceRequested` comes in for this invoice, this id will be surfaced so the + /// persisted invoice can be retrieved from the database. + recipient_id_nonce: Nonce, + /// Once the [`StaticInvoice`] is persisted, [`ChannelManager::static_invoice_persisted`] should + /// be called with this responder to confirm to the recipient that their [`Offer`] is ready to + /// be used for async payments. + /// + /// [`ChannelManager::static_invoice_persisted`]: crate::ln::channelmanager::ChannelManager::static_invoice_persisted + /// [`Offer`]: crate::offers::offer::Offer + invoice_persisted_path: Responder, + }, } impl Writeable for Event { @@ -1996,6 +2025,12 @@ impl Writeable for Event { (8, former_temporary_channel_id, required), }); }, + #[cfg(async_payments)] + &Event::PersistStaticInvoice { .. } => { + 45u8.write(writer)?; + // No need to write these events because we can just restart the static invoice negotiation + // on startup. + }, // Note that, going forward, all new events must only write data inside of // `write_tlv_fields`. Versions 0.0.101+ will ignore odd-numbered events that write // data via `write_tlv_fields`. @@ -2560,6 +2595,9 @@ impl MaybeReadable for Event { former_temporary_channel_id: former_temporary_channel_id.0.unwrap(), })) }, + // Note that we do not write a length-prefixed TLV for PersistStaticInvoice events. + #[cfg(async_payments)] + 45u8 => Ok(None), // Versions prior to 0.0.100 did not ignore odd types, instead returning InvalidValue. // Version 0.0.100 failed to properly ignore odd types, possibly resulting in corrupt // reads. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e75e9ad5e8f..c800bcc8b7a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5121,6 +5121,13 @@ where } } + /// Should be called after handling an [`Event::PersistStaticInvoice`], where the `Responder` + /// comes from [`Event::PersistStaticInvoice::invoice_persisted_path`]. + #[cfg(async_payments)] + pub fn static_invoice_persisted(&self, invoice_persisted_path: Responder) { + self.flow.serving_static_invoice(invoice_persisted_path); + } + #[rustfmt::skip] #[cfg(async_payments)] fn initiate_async_payment( @@ -12938,6 +12945,29 @@ where &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, _responder: Option, ) { + #[cfg(async_payments)] + { + let responder = match _responder { + Some(resp) => resp, + None => return, + }; + + let recipient_id_nonce = + match self.flow.verify_serve_static_invoice_message(&_message, _context) { + Ok(nonce) => nonce, + Err(()) => return, + }; + + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + Event::PersistStaticInvoice { + invoice: _message.invoice, + recipient_id_nonce, + invoice_persisted_path: responder, + }, + None, + )); + } } fn handle_static_invoice_persisted( diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index f6e1d00bf3d..e10f904e06a 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -71,6 +71,7 @@ use { }, crate::onion_message::async_payments::{ HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, + StaticInvoicePersisted, }, crate::onion_message::messenger::Responder, }; @@ -1551,6 +1552,59 @@ where Ok((ServeStaticInvoice { invoice, forward_invoice_request_path }, reply_path_context)) } + /// Verifies an incoming [`ServeStaticInvoice`] onion message from an often-offline recipient who + /// wants us as a static invoice server to serve the [`ServeStaticInvoice::invoice`] to payers on + /// their behalf. + /// + /// If verification succeeds, the provided [`ServeStaticInvoice::invoice`] should be persisted + /// keyed by [`ServeStaticInvoice::recipient_id_nonce`]. The invoice should then be served in + /// response to incoming [`InvoiceRequest`]s that have a context of + /// [`OffersContext::StaticInvoiceRequested`], where the + /// [`OffersContext::StaticInvoiceRequested::recipient_id_nonce`] matches the `recipient_id_nonce` + /// from the original [`ServeStaticInvoice`] message. + /// + /// Once the invoice is persisted, [`Self::static_invoice_persisted`] must be called to confirm to + /// the recipient that their offer is ready to receive async payments. + /// + /// [`ServeStaticInvoice::invoice`]: crate::onion_message::async_payments::ServeStaticInvoice::invoice + #[cfg(async_payments)] + pub(crate) fn verify_serve_static_invoice_message( + &self, message: &ServeStaticInvoice, context: AsyncPaymentsContext, + ) -> Result { + if message.invoice.is_expired_no_std(self.duration_since_epoch()) { + return Err(()); + } + let expanded_key = &self.inbound_payment_key; + match context { + AsyncPaymentsContext::ServeStaticInvoice { + recipient_id_nonce, + nonce, + hmac, + path_absolute_expiry, + } => { + signer::verify_serve_static_invoice_context(nonce, hmac, expanded_key)?; + if self.duration_since_epoch() > path_absolute_expiry { + return Err(()); + } + + return Ok(recipient_id_nonce); + }, + _ => return Err(()), + }; + } + + /// Indicates that a [`ServeStaticInvoice::invoice`] has been persisted and is ready to be served + /// to payers on behalf of an often-offline recipient. This method must be called after persisting + /// a [`StaticInvoice`] to confirm to the recipient that their corresponding [`Offer`] is ready to + /// receive async payments. + #[cfg(async_payments)] + pub(crate) fn serving_static_invoice(&self, responder: Responder) { + let mut pending_async_payments_messages = + self.pending_async_payments_messages.lock().unwrap(); + let message = AsyncPaymentsMessage::StaticInvoicePersisted(StaticInvoicePersisted {}); + pending_async_payments_messages.push((message, responder.respond().into_instructions())); + } + /// 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. /// diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index e7703b86b9d..77580865a8d 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -657,6 +657,17 @@ pub(crate) fn hmac_for_serve_static_invoice_context( Hmac::from_engine(hmac) } +#[cfg(async_payments)] +pub(crate) fn verify_serve_static_invoice_context( + nonce: Nonce, hmac: Hmac, expanded_key: &ExpandedKey, +) -> Result<(), ()> { + if hmac_for_serve_static_invoice_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, From 5b8028c2198910f475aeefa892cade47b843c561 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 19 Feb 2025 19:18:13 -0500 Subject: [PATCH 13/16] Static invoice server: forward static invoices to payers Here we implement serving static invoices to payers on behalf of often-offline recipients. These recipients previously encoded blinded paths terminating at our node in their offer, so we receive invoice requests on their behalf. Handle those inbound invreqs by retrieving a static invoice we previously persisted on behalf of the payee, and forward it to the payer as a reply to their invreq. --- lightning/src/events/mod.rs | 40 ++++++++++++++++++- lightning/src/ln/channelmanager.rs | 23 ++++++++++- lightning/src/offers/flow.rs | 64 +++++++++++++++++++++++++++++- lightning/src/offers/signer.rs | 11 +++++ 4 files changed, 132 insertions(+), 6 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index ccf2f69f9cc..dd0b47fb366 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1586,11 +1586,11 @@ pub enum Event { #[cfg(async_payments)] PersistStaticInvoice { /// The invoice that should be persisted and later provided to payers when handling a future - /// `Event::StaticInvoiceRequested`. + /// [`Event::StaticInvoiceRequested`]. invoice: StaticInvoice, /// An identifier for the recipient, originally surfaced in /// [`ChannelManager::blinded_paths_for_async_recipient`]. When an - /// `Event::StaticInvoiceRequested` comes in for this invoice, this id will be surfaced so the + /// [`Event::StaticInvoiceRequested`] comes in for this invoice, this id will be surfaced so the /// persisted invoice can be retrieved from the database. recipient_id_nonce: Nonce, /// Once the [`StaticInvoice`] is persisted, [`ChannelManager::static_invoice_persisted`] should @@ -1601,6 +1601,34 @@ pub enum Event { /// [`Offer`]: crate::offers::offer::Offer invoice_persisted_path: Responder, }, + /// As a static invoice server, we received an [`InvoiceRequest`] on behalf of an often-offline + /// recipient for whom we are serving [`StaticInvoice`]s. + /// + /// This event will only be generated if we previously created paths using + /// [`ChannelManager::blinded_paths_for_async_recipient`] and configured the recipient with them + /// via [`UserConfig::paths_to_static_invoice_server`]. + /// + /// If we previously persisted a [`StaticInvoice`] from an [`Event::PersistStaticInvoice`] that + /// matches the contained [`Event::StaticInvoiceRequested::recipient_id_nonce`], that + /// invoice should be retrieved now and forwarded to the payer via + /// [`ChannelManager::send_static_invoice`]. + /// + /// [`ChannelManager::blinded_paths_for_async_recipient`]: crate::ln::channelmanager::ChannelManager::blinded_paths_for_async_recipient + /// [`UserConfig::paths_to_static_invoice_server`]: crate::util::config::UserConfig::paths_to_static_invoice_server + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`ChannelManager::send_static_invoice`]: crate::ln::channelmanager::ChannelManager::send_static_invoice + #[cfg(async_payments)] + StaticInvoiceRequested { + /// An identifier for the recipient previously surfaced in + /// [`Event::PersistStaticInvoice::recipient_id_nonce`]. Useful to retrieve the [`StaticInvoice`] + /// requested by the payer. + recipient_id_nonce: Nonce, + /// The path over which the [`StaticInvoice`] will be sent to the payer, which should be + /// provided to [`ChannelManager::send_static_invoice`] along with the invoice. + /// + /// [`ChannelManager::send_static_invoice`]: crate::ln::channelmanager::ChannelManager::send_static_invoice + reply_path: Responder, + }, } impl Writeable for Event { @@ -2031,6 +2059,11 @@ impl Writeable for Event { // No need to write these events because we can just restart the static invoice negotiation // on startup. }, + #[cfg(async_payments)] + &Event::StaticInvoiceRequested { .. } => { + 47u8.write(writer)?; + // Never write StaticInvoiceRequested events as buffered onion messages aren't serialized. + }, // Note that, going forward, all new events must only write data inside of // `write_tlv_fields`. Versions 0.0.101+ will ignore odd-numbered events that write // data via `write_tlv_fields`. @@ -2598,6 +2631,9 @@ impl MaybeReadable for Event { // Note that we do not write a length-prefixed TLV for PersistStaticInvoice events. #[cfg(async_payments)] 45u8 => Ok(None), + // Note that we do not write a length-prefixed TLV for StaticInvoiceRequested events. + #[cfg(async_payments)] + 47u8 => Ok(None), // Versions prior to 0.0.100 did not ignore odd types, instead returning InvalidValue. // Version 0.0.100 failed to properly ignore odd types, possibly resulting in corrupt // reads. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c800bcc8b7a..5d6fcba8b3d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -86,7 +86,7 @@ use crate::ln::outbound_payment::{ }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; -use crate::offers::flow::OffersMessageFlow; +use crate::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, }; @@ -5128,6 +5128,15 @@ where self.flow.serving_static_invoice(invoice_persisted_path); } + /// Forwards a [`StaticInvoice`] that was previously persisted by us from an + /// [`Event::PersistStaticInvoice`], in response to an [`Event::StaticInvoiceRequested`]. + #[cfg(async_payments)] + pub fn send_static_invoice( + &self, invoice: StaticInvoice, responder: Responder, + ) -> Result<(), Bolt12SemanticError> { + self.flow.enqueue_static_invoice(invoice, responder) + } + #[rustfmt::skip] #[cfg(async_payments)] fn initiate_async_payment( @@ -12760,7 +12769,17 @@ where }; let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { - Ok(invoice_request) => invoice_request, + Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, + Ok(InvreqResponseInstructions::SendStaticInvoice { + recipient_id_nonce: _recipient_id_nonce + }) => { + #[cfg(async_payments)] + self.pending_events.lock().unwrap().push_back((Event::StaticInvoiceRequested { + recipient_id_nonce: _recipient_id_nonce, reply_path: responder + }, None)); + + return None + }, Err(_) => return None, }; diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index e10f904e06a..996b2d3deef 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -420,6 +420,26 @@ fn enqueue_onion_message_with_reply_paths( }); } +/// Instructions for how to respond to an `InvoiceRequest`. +pub enum InvreqResponseInstructions { + /// We are the recipient of this payment, and a [`Bolt12Invoice`] should be sent in response to + /// the invoice request since it is now verified. + SendInvoice(VerifiedInvoiceRequest), + /// We are a static invoice server and should respond to this invoice request by retrieving the + /// [`StaticInvoice`] corresponding to the `recipient_id_nonce` and calling + /// `OffersMessageFlow::enqueue_static_invoice`. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + // TODO: if the server stores multiple invoices on behalf of the recipient, how to narrow down + // which one is being requested? + SendStaticInvoice { + /// An identifier for the async recipient for whom we are serving [`StaticInvoice`]s. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + recipient_id_nonce: Nonce, + }, +} + impl OffersMessageFlow where MR::Target: MessageRouter, @@ -437,13 +457,31 @@ where /// - The verification process (via recipient context data or metadata) fails. pub fn verify_invoice_request( &self, invoice_request: InvoiceRequest, context: Option, - ) -> Result { + ) -> Result { let secp_ctx = &self.secp_ctx; let expanded_key = &self.inbound_payment_key; let nonce = match context { None if invoice_request.metadata().is_some() => None, Some(OffersContext::InvoiceRequest { nonce }) => Some(nonce), + #[cfg(async_payments)] + Some(OffersContext::StaticInvoiceRequested { + recipient_id_nonce, + nonce, + hmac, + path_absolute_expiry, + }) => { + // TODO: vet invreq more? + if signer::verify_async_recipient_invreq_context(nonce, hmac, expanded_key).is_err() + { + return Err(()); + } + if path_absolute_expiry < self.duration_since_epoch() { + return Err(()); + } + + return Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id_nonce }); + }, _ => return Err(()), }; @@ -454,7 +492,7 @@ where None => invoice_request.verify_using_metadata(expanded_key, secp_ctx), }?; - Ok(invoice_request) + Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) } /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer metadata, @@ -1064,6 +1102,28 @@ where Ok(()) } + /// Forwards a [`StaticInvoice`] that was previously persisted by us from an + /// [`Event::PersistStaticInvoice`], in response to an [`Event::StaticInvoiceRequested`]. + #[cfg(async_payments)] + pub fn enqueue_static_invoice( + &self, invoice: StaticInvoice, responder: Responder, + ) -> Result<(), Bolt12SemanticError> { + let duration_since_epoch = self.duration_since_epoch(); + if invoice.is_expired_no_std(duration_since_epoch) { + return Err(Bolt12SemanticError::AlreadyExpired); + } + if invoice.is_offer_expired_no_std(duration_since_epoch) { + return Err(Bolt12SemanticError::AlreadyExpired); + } + + let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); + let message = OffersMessage::StaticInvoice(invoice); + // TODO include reply path for invoice error + pending_offers_messages.push((message, responder.respond().into_instructions())); + + Ok(()) + } + /// Enqueues `held_htlc_available` onion messages to be sent to the payee via the reply paths /// contained within the provided [`StaticInvoice`]. /// diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index 77580865a8d..9ba94d9d007 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -704,3 +704,14 @@ pub(crate) fn hmac_for_async_recipient_invreq_context( Hmac::from_engine(hmac) } + +#[cfg(async_payments)] +pub(crate) fn verify_async_recipient_invreq_context( + nonce: Nonce, hmac: Hmac, expanded_key: &ExpandedKey, +) -> Result<(), ()> { + if hmac_for_async_recipient_invreq_context(nonce, expanded_key) == hmac { + Ok(()) + } else { + Err(()) + } +} From 1090b86a03f62dd4b28e4885cf77c2c18f8348d7 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 4 Jun 2025 18:26:10 -0400 Subject: [PATCH 14/16] Async payments tests: stop hardcoding keysend bytes We're about to add a bunch more async payments tests, so take this opportunity to clean up the existing tests by no longer hardcoding the keysend payment preimage bytes ahead of time. This previously caused an MPP test to spuriously fail because all the session_privs were the same, and is generally not ideal. Also add a few comments to an existing test and a few more trivial cleanups. --- lightning/src/ln/async_payments_tests.rs | 88 ++++++++++++------------ 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index a956f2ebae2..9b0a0352698 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -11,7 +11,9 @@ use crate::blinded_path::message::{MessageContext, OffersContext}; use crate::blinded_path::payment::PaymentContext; use crate::blinded_path::payment::{AsyncBolt12OfferContext, BlindedPaymentTlvs}; use crate::chain::channelmonitor::{HTLC_FAIL_BACK_BUFFER, LATENCY_GRACE_PERIOD_BLOCKS}; -use crate::events::{Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason}; +use crate::events::{ + Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose, +}; use crate::ln::blinded_payment_tests::{fail_blinded_htlc_backwards, get_blinded_route_parameters}; use crate::ln::channelmanager::{PaymentId, RecipientOnionFields}; use crate::ln::functional_test_utils::*; @@ -128,6 +130,25 @@ fn create_static_invoice( (offer, static_invoice) } +fn extract_payment_hash(event: &MessageSendEvent) -> PaymentHash { + match event { + MessageSendEvent::UpdateHTLCs { ref updates, .. } => { + updates.update_add_htlcs[0].payment_hash + }, + _ => panic!(), + } +} + +fn extract_payment_preimage(event: &Event) -> PaymentPreimage { + match event { + Event::PaymentClaimable { + purpose: PaymentPurpose::Bolt12OfferPayment { payment_preimage, .. }, + .. + } => payment_preimage.unwrap(), + _ => panic!(), + } +} + #[test] fn invalid_keysend_payment_secret() { let chanmon_cfgs = create_chanmon_cfgs(3); @@ -215,6 +236,7 @@ fn static_invoice_unknown_required_features() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + // Manually construct a static invoice so we can set unknown required features. let blinded_paths_to_always_online_node = nodes[1] .message_router .create_blinded_paths( @@ -237,6 +259,8 @@ fn static_invoice_unknown_required_features() { .build_and_sign(&secp_ctx) .unwrap(); + // Initiate payment to the offer corresponding to the manually-constructed invoice that has + // unknown required features. let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -264,6 +288,8 @@ fn static_invoice_unknown_required_features() { ) .unwrap(); + // Check that paying the static invoice fails as expected with + // `PaymentFailureReason::UnknownRequiredFeatures`. let static_invoice_om = nodes[1] .onion_messenger .next_onion_message_for_peer(nodes[0].node.get_our_node_id()) @@ -404,12 +430,6 @@ fn async_receive_flow_success() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); - // Set the random bytes so we can predict the payment preimage and hash. - let hardcoded_random_bytes = [42; 32]; - let keysend_preimage = PaymentPreimage(hardcoded_random_bytes); - let payment_hash: PaymentHash = keysend_preimage.into(); - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(hardcoded_random_bytes); - let relative_expiry = Duration::from_secs(1000); let (offer, static_invoice) = create_static_invoice(&nodes[1], &nodes[2], Some(relative_expiry), &secp_ctx); @@ -433,6 +453,7 @@ fn async_receive_flow_success() { let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let payment_hash = extract_payment_hash(&ev); check_added_monitors!(nodes[0], 1); // Receiving a duplicate release_htlc message doesn't result in duplicate payment. @@ -442,9 +463,9 @@ fn async_receive_flow_success() { assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage); - do_pass_along_path(args); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let claimable_ev = do_pass_along_path(args).unwrap(); + let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); assert!(res.is_some()); @@ -556,9 +577,6 @@ fn async_receive_mpp() { let (offer, static_invoice) = create_static_invoice(&nodes[1], &nodes[3], None, &secp_ctx); - // In other tests we hardcode the sender's random bytes so we can predict the keysend preimage to - // check later in the test, but that doesn't work for MPP because it causes the session_privs for - // the different MPP parts to not be unique. let amt_msat = 15_000_000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -593,8 +611,8 @@ fn async_receive_mpp() { let args = PassAlongPathArgs::new(&nodes[0], expected_route[1], amt_msat, payment_hash, ev); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = match claimable_ev { - crate::events::Event::PaymentClaimable { - purpose: crate::events::PaymentPurpose::Bolt12OfferPayment { payment_preimage, .. }, + Event::PaymentClaimable { + purpose: PaymentPurpose::Bolt12OfferPayment { payment_preimage, .. }, .. } => payment_preimage.unwrap(), _ => panic!(), @@ -643,13 +661,6 @@ fn amount_doesnt_match_invreq() { connect_blocks(&nodes[3], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[3].best_block_info().1); let (offer, static_invoice) = create_static_invoice(&nodes[1], &nodes[3], None, &secp_ctx); - - // Set the random bytes so we can predict the payment preimage and hash. - let hardcoded_random_bytes = [42; 32]; - let keysend_preimage = PaymentPreimage(hardcoded_random_bytes); - let payment_hash: PaymentHash = keysend_preimage.into(); - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(hardcoded_random_bytes); - let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -696,10 +707,10 @@ fn amount_doesnt_match_invreq() { let mut ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); assert!(matches!( ev, MessageSendEvent::UpdateHTLCs { ref updates, .. } if updates.update_add_htlcs.len() == 1)); + let payment_hash = extract_payment_hash(&ev); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]]]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage) .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); do_pass_along_path(args); @@ -725,9 +736,9 @@ fn amount_doesnt_match_invreq() { ev, MessageSendEvent::UpdateHTLCs { ref updates, .. } if updates.update_add_htlcs.len() == 1)); check_added_monitors!(nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[2], &nodes[3]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage); - do_pass_along_path(args); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let claimable_ev = do_pass_along_path(args).unwrap(); + let keysend_preimage = extract_payment_preimage(&claimable_ev); claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); } @@ -882,12 +893,6 @@ fn invalid_async_receive_with_retry( .build_and_sign(&secp_ctx) .unwrap(); - // Set the random bytes so we can predict the payment preimage and hash. - let hardcoded_random_bytes = [42; 32]; - let keysend_preimage = PaymentPreimage(hardcoded_random_bytes); - let payment_hash: PaymentHash = keysend_preimage.into(); - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(hardcoded_random_bytes); - let params = RouteParametersConfig::default(); nodes[0] .node @@ -906,10 +911,10 @@ fn invalid_async_receive_with_retry( let mut ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); assert!(matches!( ev, MessageSendEvent::UpdateHTLCs { ref updates, .. } if updates.update_add_htlcs.len() == 1)); + let payment_hash = extract_payment_hash(&ev); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); do_pass_along_path(args); // Fail the HTLC backwards to enable us to more easily modify the now-Retryable outbound to test @@ -935,7 +940,6 @@ fn invalid_async_receive_with_retry( check_added_monitors!(nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage) .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); do_pass_along_path(args); @@ -949,9 +953,9 @@ fn invalid_async_receive_with_retry( let mut ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); check_added_monitors!(nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; - let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage); - do_pass_along_path(args); + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let claimable_ev = do_pass_along_path(args).unwrap(); + let keysend_preimage = extract_payment_preimage(&claimable_ev); claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); } @@ -1031,12 +1035,6 @@ fn expired_static_invoice_payment_path() { connect_blocks(&nodes[1], node_max_height - nodes[1].best_block_info().1); connect_blocks(&nodes[2], node_max_height - nodes[2].best_block_info().1); - // Set the random bytes so we can predict the payment preimage and hash. - let hardcoded_random_bytes = [42; 32]; - let keysend_preimage = PaymentPreimage(hardcoded_random_bytes); - let payment_hash: PaymentHash = keysend_preimage.into(); - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(hardcoded_random_bytes); - // Hardcode the blinded payment path returned by the router so we can expire it via mining blocks. let (_, static_invoice_expired_paths) = create_static_invoice(&nodes[1], &nodes[2], None, &secp_ctx); @@ -1097,11 +1095,11 @@ fn expired_static_invoice_payment_path() { let mut events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(events.len(), 1); let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let payment_hash = extract_payment_hash(&ev); check_added_monitors!(nodes[0], 1); let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev) - .with_payment_preimage(keysend_preimage) .without_claimable_event() .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); do_pass_along_path(args); From d88f61398823fdad6a1517cc4d0e5c42a1e9466b Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 27 May 2025 13:34:33 -0700 Subject: [PATCH 15/16] Adapt async payments tests for static invoice server We were manually creating the static invoice in tests, but now we can use the static invoice server protocol to interactively build the invoice. --- lightning/src/ln/async_payments_tests.rs | 528 ++++++++++++++++------ lightning/src/ln/functional_test_utils.rs | 1 + lightning/src/offers/flow.rs | 3 + lightning/src/onion_message/messenger.rs | 7 +- lightning/src/util/test_utils.rs | 31 +- 5 files changed, 433 insertions(+), 137 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 9b0a0352698..da90ebc6766 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -25,10 +25,11 @@ use crate::ln::offers_tests; use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::PendingOutboundPayment; use crate::ln::outbound_payment::Retry; +use crate::offers::flow::TEST_OFFERS_MESSAGE_REQUEST_LIMIT; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::offer::Offer; -use crate::offers::static_invoice::StaticInvoice; +use crate::offers::static_invoice::{StaticInvoice, DEFAULT_RELATIVE_EXPIRY}; use crate::onion_message::async_payments::{AsyncPaymentsMessage, AsyncPaymentsMessageHandler}; use crate::onion_message::messenger::{Destination, MessageRouter, MessageSendInstructions}; use crate::onion_message::offers::OffersMessage; @@ -39,6 +40,7 @@ use crate::sign::NodeSigner; use crate::sync::Mutex; use crate::types::features::Bolt12InvoiceFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; +use crate::util::ser::Writeable; use bitcoin::constants::ChainHash; use bitcoin::network::Network; use bitcoin::secp256k1; @@ -47,6 +49,160 @@ use bitcoin::secp256k1::Secp256k1; use core::convert::Infallible; use core::time::Duration; +// Reload the recipient node, now configured with blinded paths to reach the static invoice +// server. +macro_rules! reload_payee_with_async_receive_cfg { + ($server_node: expr, $payee_node: expr, $new_persister: ident, $new_chain_monitor: ident, + $payee_node_deserialized: ident, $chan_ids: expr + ) => {{ + reload_payee_with_async_receive_cfg!( + $server_node, + $payee_node, + $new_persister, + $new_chain_monitor, + $payee_node_deserialized, + $chan_ids, + None + ) + }}; + ($server_node: expr, $payee_node: expr, $new_persister: ident, $new_chain_monitor: ident, + $payee_node_deserialized: ident, $chan_ids: expr, $paths_relative_expiry: expr + ) => {{ + let (offer_paths_request_paths, recipient_id_nonce) = + $server_node.node.blinded_paths_for_async_recipient($paths_relative_expiry).unwrap(); + let mut async_payee_cfg = test_default_channel_config(); + async_payee_cfg.paths_to_static_invoice_server = offer_paths_request_paths; + + $server_node.node.peer_disconnected($payee_node.node.get_our_node_id()); + + let mut serialized_monitor_vecs = Vec::with_capacity($chan_ids.len()); + for chan_id in $chan_ids { + serialized_monitor_vecs.push(get_monitor!($payee_node, *chan_id).encode()); + } + let mut serialized_monitors = Vec::with_capacity($chan_ids.len()); + for vec in serialized_monitor_vecs.iter() { + serialized_monitors.push(&vec[..]); + } + + reload_node!( + $payee_node, + async_payee_cfg, + $payee_node.node.encode(), + &serialized_monitors[..], + $new_persister, + $new_chain_monitor, + $payee_node_deserialized + ); + + let mut reconnect_args = ReconnectArgs::new(&$server_node, &$payee_node); + reconnect_args.send_channel_ready = (true, true); + reconnect_nodes(reconnect_args); + + recipient_id_nonce + }}; +} + +struct StaticInvoiceServerFlowResult { + invoice: StaticInvoice, + + // Returning messages that were sent along the way allows us to test handling duplicate messages. + offer_paths_request: msgs::OnionMessage, + static_invoice_persisted_message: msgs::OnionMessage, +} + +// Go through the flow of interactively building a `StaticInvoice` and storing it with the static +// invoice server, returning the invoice and messages that were exchanged along the way at the end. +fn pass_static_invoice_server_messages( + server: &Node, recipient: &Node, recipient_id: Nonce, +) -> StaticInvoiceServerFlowResult { + // Force the server and recipient to send OMs directly to each other for testing simplicity. + server.message_router.peers_override.lock().unwrap().push(recipient.node.get_our_node_id()); + recipient.message_router.peers_override.lock().unwrap().push(server.node.get_our_node_id()); + + let num_cached_offers_before_flow = recipient.node.get_cached_async_receive_offers().len(); + + // First provide an OfferPathsRequest from the recipient to the server. + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + server.onion_messenger.handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req); + + // Check that the right number of requests were queued and that they were only queued for the + // server node. + let mut pending_oms = recipient.onion_messenger.release_pending_msgs(); + let mut offer_paths_req_msgs = pending_oms.remove(&server.node.get_our_node_id()).unwrap(); + assert!(offer_paths_req_msgs.len() <= TEST_OFFERS_MESSAGE_REQUEST_LIMIT); + for (_, msgs) in pending_oms { + assert!(msgs.is_empty()); + } + + // The server responds with OfferPaths. + let offer_paths = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + recipient.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &offer_paths); + + // Only one OfferPaths response should be queued. + let mut pending_oms = server.onion_messenger.release_pending_msgs(); + for (_, msgs) in pending_oms { + assert!(msgs.is_empty()); + } + + // After receiving the offer paths, the recipient constructs the static invoice and sends + // ServeStaticInvoice to the server. + let serve_static_invoice_om = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice_om); + + // Upon handling the ServeStaticInvoice message, the server's node surfaces an event indicating + // that the static invoice should be persisted. + let mut events = server.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let (static_invoice, ack_path) = match events.pop().unwrap() { + Event::PersistStaticInvoice { invoice, invoice_persisted_path, recipient_id_nonce } => { + assert_eq!(recipient_id, recipient_id_nonce); + (invoice, invoice_persisted_path) + }, + _ => panic!(), + }; + assert_eq!( + recipient.node.get_cached_async_receive_offers().len(), + num_cached_offers_before_flow + ); + + // Once the static invoice is persisted, the server needs to call `static_invoice_persisted` with + // the reply path to the ServeStaticInvoice message, to tell the recipient that their offer is + // ready to be used for async payments. + server.node.static_invoice_persisted(ack_path); + let invoice_persisted_om = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + recipient + .onion_messenger + .handle_onion_message(server.node.get_our_node_id(), &invoice_persisted_om); + assert_eq!( + recipient.node.get_cached_async_receive_offers().len(), + num_cached_offers_before_flow + 1 + ); + + // Remove the peer restriction added above. + server.message_router.peers_override.lock().unwrap().clear(); + recipient.message_router.peers_override.lock().unwrap().clear(); + + StaticInvoiceServerFlowResult { + offer_paths_request: offer_paths_req, + static_invoice_persisted_message: invoice_persisted_om, + invoice: static_invoice, + } +} + // Goes through the async receive onion message flow, returning the final release_held_htlc OM. // // Assumes the held_htlc_available message will be sent: @@ -55,28 +211,30 @@ use core::time::Duration; // Returns: (held_htlc_available_om, release_held_htlc_om) fn pass_async_payments_oms( static_invoice: StaticInvoice, sender: &Node, always_online_recipient_counterparty: &Node, - recipient: &Node, + recipient: &Node, recipient_id: Nonce, ) -> (msgs::OnionMessage, msgs::OnionMessage) { let sender_node_id = sender.node.get_our_node_id(); let always_online_node_id = always_online_recipient_counterparty.node.get_our_node_id(); - // Don't forward the invreq since we don't support retrieving the static invoice from the - // recipient's LSP yet, instead manually construct the response. let invreq_om = sender.onion_messenger.next_onion_message_for_peer(always_online_node_id).unwrap(); - let invreq_reply_path = - offers_tests::extract_invoice_request(always_online_recipient_counterparty, &invreq_om).1; - always_online_recipient_counterparty .onion_messenger - .send_onion_message( - ParsedOnionMessageContents::::Offers(OffersMessage::StaticInvoice( - static_invoice, - )), - MessageSendInstructions::WithoutReplyPath { - destination: Destination::BlindedPath(invreq_reply_path), - }, - ) + .handle_onion_message(sender_node_id, &invreq_om); + + let mut events = always_online_recipient_counterparty.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let reply_path = match events.pop().unwrap() { + Event::StaticInvoiceRequested { recipient_id_nonce, reply_path } => { + assert_eq!(recipient_id, recipient_id_nonce); + reply_path + }, + _ => panic!(), + }; + + always_online_recipient_counterparty + .node + .send_static_invoice(static_invoice, reply_path) .unwrap(); let static_invoice_om = always_online_recipient_counterparty .onion_messenger @@ -97,10 +255,9 @@ fn pass_async_payments_oms( .onion_messenger .handle_onion_message(always_online_node_id, &held_htlc_available_om_1_2); - ( - held_htlc_available_om_1_2, - recipient.onion_messenger.next_onion_message_for_peer(sender_node_id).unwrap(), - ) + let release_held_htlc = + recipient.onion_messenger.next_onion_message_for_peer(sender_node_id).unwrap(); + (held_htlc_available_om_1_2, release_held_htlc) } fn create_static_invoice( @@ -269,8 +426,8 @@ fn static_invoice_unknown_required_features() { .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) .unwrap(); - // Don't forward the invreq since we don't support retrieving the static invoice from the - // recipient's LSP yet, instead manually construct the response. + // Don't forward the invreq since the invoice was created outside of the normal flow, instead + // manually construct the response. let invreq_om = nodes[0] .onion_messenger .next_onion_message_for_peer(nodes[1].node.get_our_node_id()) @@ -313,17 +470,30 @@ fn static_invoice_unknown_required_features() { fn ignore_unexpected_static_invoice() { // Test that we'll ignore unexpected static invoices, invoices that don't match our invoice // request, and duplicate invoices. - let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); - let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); // Initiate payment to the sender's intended offer. - let (offer, valid_static_invoice) = - create_static_invoice(&nodes[1], &nodes[2], None, &secp_ctx); + let valid_static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id).invoice; + + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -332,30 +502,30 @@ fn ignore_unexpected_static_invoice() { .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) .unwrap(); - // Don't forward the invreq since we don't support retrieving the static invoice from the - // recipient's LSP yet, instead manually construct the responses below. let invreq_om = nodes[0] .onion_messenger .next_onion_message_for_peer(nodes[1].node.get_our_node_id()) .unwrap(); - let invreq_reply_path = offers_tests::extract_invoice_request(&nodes[1], &invreq_om).1; + nodes[1].onion_messenger.handle_onion_message(nodes[0].node.get_our_node_id(), &invreq_om); + + let mut events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let reply_path = match events.pop().unwrap() { + Event::StaticInvoiceRequested { recipient_id_nonce, reply_path } => { + assert_eq!(recipient_id, recipient_id_nonce); + reply_path + }, + _ => panic!(), + }; // Create a static invoice to be sent over the reply path containing the original payment_id, but // the static invoice corresponds to a different offer than was originally paid. - let unexpected_static_invoice = create_static_invoice(&nodes[1], &nodes[2], None, &secp_ctx).1; + nodes[2].node.timer_tick_occurred(); + let unexpected_static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id).invoice; // Check that we'll ignore the unexpected static invoice. - nodes[1] - .onion_messenger - .send_onion_message( - ParsedOnionMessageContents::::Offers(OffersMessage::StaticInvoice( - unexpected_static_invoice, - )), - MessageSendInstructions::WithoutReplyPath { - destination: Destination::BlindedPath(invreq_reply_path.clone()), - }, - ) - .unwrap(); + nodes[1].node.send_static_invoice(unexpected_static_invoice, reply_path.clone()).unwrap(); let unexpected_static_invoice_om = nodes[1] .onion_messenger .next_onion_message_for_peer(nodes[0].node.get_our_node_id()) @@ -369,17 +539,7 @@ fn ignore_unexpected_static_invoice() { // A valid static invoice corresponding to the correct offer will succeed and cause us to send a // held_htlc_available onion message. - nodes[1] - .onion_messenger - .send_onion_message( - ParsedOnionMessageContents::::Offers(OffersMessage::StaticInvoice( - valid_static_invoice.clone(), - )), - MessageSendInstructions::WithoutReplyPath { - destination: Destination::BlindedPath(invreq_reply_path.clone()), - }, - ) - .unwrap(); + nodes[1].node.send_static_invoice(valid_static_invoice.clone(), reply_path.clone()).unwrap(); let static_invoice_om = nodes[1] .onion_messenger .next_onion_message_for_peer(nodes[0].node.get_our_node_id()) @@ -394,17 +554,7 @@ fn ignore_unexpected_static_invoice() { .all(|(msg, _)| matches!(msg, AsyncPaymentsMessage::HeldHtlcAvailable(_)))); // Receiving a duplicate invoice will have no effect. - nodes[1] - .onion_messenger - .send_onion_message( - ParsedOnionMessageContents::::Offers(OffersMessage::StaticInvoice( - valid_static_invoice, - )), - MessageSendInstructions::WithoutReplyPath { - destination: Destination::BlindedPath(invreq_reply_path), - }, - ) - .unwrap(); + nodes[1].node.send_static_invoice(valid_static_invoice, reply_path).unwrap(); let dup_static_invoice_om = nodes[1] .onion_messenger .next_onion_message_for_peer(nodes[0].node.get_our_node_id()) @@ -419,23 +569,36 @@ fn ignore_unexpected_static_invoice() { #[test] fn async_receive_flow_success() { // Test that an always-online sender can successfully pay an async receiver. - let secp_ctx = Secp256k1::new(); + let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]); - let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let chan_id_1_2 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_2] + ); - let relative_expiry = Duration::from_secs(1000); - let (offer, static_invoice) = - create_static_invoice(&nodes[1], &nodes[2], Some(relative_expiry), &secp_ctx); + let invoice_flow_res = pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id); + let static_invoice = invoice_flow_res.invoice; assert!(static_invoice.invoice_features().supports_basic_mpp()); - assert_eq!(static_invoice.relative_expiry(), relative_expiry); - + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -443,8 +606,14 @@ fn async_receive_flow_success() { .node .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) .unwrap(); - let release_held_htlc_om = - pass_async_payments_oms(static_invoice.clone(), &nodes[0], &nodes[1], &nodes[2]).1; + let release_held_htlc_om = pass_async_payments_oms( + static_invoice.clone(), + &nodes[0], + &nodes[1], + &nodes[2], + recipient_id, + ) + .1; nodes[0] .onion_messenger .handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om); @@ -468,7 +637,6 @@ fn async_receive_flow_success() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); - assert!(res.is_some()); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } @@ -476,18 +644,29 @@ fn async_receive_flow_success() { #[test] fn expired_static_invoice_fail() { // Test that if we receive an expired static invoice we'll fail the payment. - let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); - let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let chan_id_1_2 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_2] + ); - const INVOICE_EXPIRY_SECS: u32 = 10; - let relative_expiry = Duration::from_secs(INVOICE_EXPIRY_SECS as u64); - let (offer, static_invoice) = - create_static_invoice(&nodes[1], &nodes[2], Some(relative_expiry), &secp_ctx); + let static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id).invoice; + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); @@ -501,20 +680,16 @@ fn expired_static_invoice_fail() { .onion_messenger .next_onion_message_for_peer(nodes[1].node.get_our_node_id()) .unwrap(); - let invreq_reply_path = offers_tests::extract_invoice_request(&nodes[1], &invreq_om).1; - // TODO: update to not manually send here when we add support for being the recipient's - // always-online counterparty - nodes[1] - .onion_messenger - .send_onion_message( - ParsedOnionMessageContents::::Offers(OffersMessage::StaticInvoice( - static_invoice, - )), - MessageSendInstructions::WithoutReplyPath { - destination: Destination::BlindedPath(invreq_reply_path), - }, - ) - .unwrap(); + nodes[1].onion_messenger.handle_onion_message(nodes[0].node.get_our_node_id(), &invreq_om); + + let mut events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let reply_path = match events.pop().unwrap() { + Event::StaticInvoiceRequested { recipient_id_nonce: _, reply_path } => reply_path, + _ => panic!(), + }; + + nodes[1].node.send_static_invoice(static_invoice.clone(), reply_path).unwrap(); let static_invoice_om = nodes[1] .onion_messenger .next_onion_message_for_peer(nodes[0].node.get_our_node_id()) @@ -523,7 +698,7 @@ fn expired_static_invoice_fail() { // Wait until the static invoice expires before providing it to the sender. let block = create_dummy_block( nodes[0].best_block_hash(), - nodes[0].node.duration_since_epoch().as_secs() as u32 + INVOICE_EXPIRY_SECS + 1, + (static_invoice.created_at() + static_invoice.relative_expiry()).as_secs() as u32 + 1u32, Vec::new(), ); connect_block(&nodes[0], &block); @@ -540,15 +715,17 @@ fn expired_static_invoice_fail() { }, _ => panic!(), } - // The sender doesn't reply with InvoiceError right now because the always-online node doesn't - // currently provide them with a reply path to do so. + // TODO: the sender doesn't reply with InvoiceError right now because the always-online node + // doesn't currently provide them with a reply path to do so. } #[test] fn async_receive_mpp() { - let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; let node_chanmgrs = create_node_chanmgrs( @@ -556,7 +733,8 @@ fn async_receive_mpp() { &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg.clone()), Some(allow_priv_chan_fwds_cfg), None], ); - let nodes = create_network(4, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + let mut nodes = create_network(4, &node_cfgs, &node_chanmgrs); // Create this network topology: // n1 @@ -566,8 +744,10 @@ fn async_receive_mpp() { // n2 create_announced_chan_between_nodes(&nodes, 0, 1); create_announced_chan_between_nodes(&nodes, 0, 2); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 3, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0); + let chan_id_1_3 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 3, 1_000_000, 0).0.channel_id; + let chan_id_2_3 = + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0).0.channel_id; // Ensure all nodes start at the same height. connect_blocks(&nodes[0], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[0].best_block_info().1); @@ -575,7 +755,22 @@ fn async_receive_mpp() { connect_blocks(&nodes[2], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[2].best_block_info().1); connect_blocks(&nodes[3], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[3].best_block_info().1); - let (offer, static_invoice) = create_static_invoice(&nodes[1], &nodes[3], None, &secp_ctx); + nodes[2].node.peer_disconnected(nodes[3].node.get_our_node_id()); + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[3], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_3, chan_id_2_3] + ); + let mut reconnect_args = ReconnectArgs::new(&nodes[2], &nodes[3]); + reconnect_args.send_channel_ready = (true, true); + reconnect_nodes(reconnect_args); + + let static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[3], recipient_id).invoice; + let offer = nodes[3].node.get_cached_async_receive_offers().pop().unwrap(); let amt_msat = 15_000_000; let payment_id = PaymentId([1; 32]); @@ -585,7 +780,7 @@ fn async_receive_mpp() { .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(1), params) .unwrap(); let release_held_htlc_om_3_0 = - pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[3]).1; + pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[3], recipient_id).1; nodes[0] .onion_messenger .handle_onion_message(nodes[3].node.get_our_node_id(), &release_held_htlc_om_3_0); @@ -631,6 +826,9 @@ fn amount_doesnt_match_invreq() { let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(4); let node_cfgs = create_node_cfgs(4, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; // Make one blinded path's fees slightly higher so they are tried in a deterministic order. @@ -641,7 +839,8 @@ fn amount_doesnt_match_invreq() { &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), Some(higher_fee_chan_cfg), None], ); - let nodes = create_network(4, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + let mut nodes = create_network(4, &node_cfgs, &node_chanmgrs); // Create this network topology so nodes[0] has a blinded route hint to retry over. // n1 @@ -651,8 +850,10 @@ fn amount_doesnt_match_invreq() { // n2 create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); create_announced_chan_between_nodes_with_value(&nodes, 0, 2, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 3, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0); + let chan_id_1_3 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 3, 1_000_000, 0).0.channel_id; + let chan_id_2_3 = + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0).0.channel_id; // Ensure all nodes start at the same height. connect_blocks(&nodes[0], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[0].best_block_info().1); @@ -660,7 +861,23 @@ fn amount_doesnt_match_invreq() { connect_blocks(&nodes[2], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[2].best_block_info().1); connect_blocks(&nodes[3], 4 * CHAN_CONFIRM_DEPTH + 1 - nodes[3].best_block_info().1); - let (offer, static_invoice) = create_static_invoice(&nodes[1], &nodes[3], None, &secp_ctx); + nodes[2].node.peer_disconnected(nodes[3].node.get_our_node_id()); + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[3], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_3, chan_id_2_3] + ); + let mut reconnect_args = ReconnectArgs::new(&nodes[2], &nodes[3]); + reconnect_args.send_channel_ready = (true, true); + reconnect_nodes(reconnect_args); + + let static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[3], recipient_id).invoice; + let offer = nodes[3].node.get_cached_async_receive_offers().pop().unwrap(); + let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -669,7 +886,7 @@ fn amount_doesnt_match_invreq() { .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(1), params) .unwrap(); let release_held_htlc_om_3_0 = - pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[3]).1; + pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[3], recipient_id).1; // Replace the invoice request contained within outbound_payments before sending so the invreq // amount doesn't match the onion amount when the HTLC gets to the recipient. @@ -841,14 +1058,32 @@ fn invalid_async_receive_with_retry( let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]); - let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let chan_id_1_2 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_2] + ); + + // Set the random bytes so we can predict the offer nonce. + let hardcoded_random_bytes = [42; 32]; + *nodes[2].keys_manager.override_random_bytes.lock().unwrap() = Some(hardcoded_random_bytes); // Ensure all nodes start at the same height. connect_blocks(&nodes[0], 2 * CHAN_CONFIRM_DEPTH + 1 - nodes[0].best_block_info().1); @@ -886,12 +1121,9 @@ fn invalid_async_receive_with_retry( } nodes[2].router.expect_blinded_payment_paths(static_invoice_paths); - let static_invoice = nodes[2] - .node - .create_static_invoice_builder(&offer, offer_nonce, None) - .unwrap() - .build_and_sign(&secp_ctx) - .unwrap(); + let static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id).invoice; + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); let params = RouteParametersConfig::default(); nodes[0] @@ -899,7 +1131,7 @@ fn invalid_async_receive_with_retry( .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(2), params) .unwrap(); let release_held_htlc_om_2_0 = - pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[2]).1; + pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[2], recipient_id).1; nodes[0] .onion_messenger .handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om_2_0); @@ -959,27 +1191,35 @@ fn invalid_async_receive_with_retry( claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); } -#[cfg(not(feature = "std"))] +#[cfg_attr(feature = "std", ignore)] #[test] fn expired_static_invoice_message_path() { // Test that if we receive a held_htlc_available message over an expired blinded path, we'll // ignore it. - let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); - let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); - - const INVOICE_EXPIRY_SECS: u32 = 10; - let (offer, static_invoice) = create_static_invoice( - &nodes[1], - &nodes[2], - Some(Duration::from_secs(INVOICE_EXPIRY_SECS as u64)), - &secp_ctx, + let chan_id_1_2 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_2] ); + let static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id).invoice; + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); + let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -990,12 +1230,12 @@ fn expired_static_invoice_message_path() { // While the invoice is unexpired, respond with release_held_htlc. let (held_htlc_available_om, _release_held_htlc_om) = - pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[2]); + pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[2], recipient_id); // After the invoice is expired, ignore inbound held_htlc_available messages over the path. let path_absolute_expiry = crate::ln::inbound_payment::calculate_absolute_expiry( nodes[2].node.duration_since_epoch().as_secs(), - INVOICE_EXPIRY_SECS, + DEFAULT_RELATIVE_EXPIRY.as_secs() as u32, ); let block = create_dummy_block( nodes[2].best_block_hash(), @@ -1020,13 +1260,28 @@ fn expired_static_invoice_payment_path() { let secp_ctx = Secp256k1::new(); let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]); - let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let payee_node_deserialized; + + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); - create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + let chan_id_1_2 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_2] + ); // Make sure all nodes are at the same block height in preparation for CLTV timeout things. let node_max_height = @@ -1078,7 +1333,10 @@ fn expired_static_invoice_payment_path() { ); connect_blocks(&nodes[2], final_max_cltv_expiry - nodes[2].best_block_info().1); - let (offer, static_invoice) = create_static_invoice(&nodes[1], &nodes[2], None, &secp_ctx); + let static_invoice = + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id).invoice; + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); + let amt_msat = 5000; let payment_id = PaymentId([1; 32]); let params = RouteParametersConfig::default(); @@ -1087,7 +1345,7 @@ fn expired_static_invoice_payment_path() { .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) .unwrap(); let release_held_htlc_om = - pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[2]).1; + pass_async_payments_oms(static_invoice, &nodes[0], &nodes[1], &nodes[2], recipient_id).1; nodes[0] .onion_messenger .handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 3ff2a045d28..15130d0a64f 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -1211,6 +1211,7 @@ macro_rules! reload_node { $new_channelmanager = _reload_node(&$node, $new_config, &chanman_encoded, $monitors_encoded); $node.node = &$new_channelmanager; $node.onion_messenger.set_offers_handler(&$new_channelmanager); + $node.onion_messenger.set_async_payments_handler(&$new_channelmanager); }; ($node: expr, $chanman_encoded: expr, $monitors_encoded: expr, $persister: ident, $new_chain_monitor: ident, $new_channelmanager: ident) => { reload_node!($node, $crate::util::config::UserConfig::default(), $chanman_encoded, $monitors_encoded, $persister, $new_chain_monitor, $new_channelmanager); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 996b2d3deef..7508b8b8dd0 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -224,6 +224,9 @@ where /// even if multiple invoices are received. const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10; +#[cfg(all(async_payments, test))] +pub(crate) const TEST_OFFERS_MESSAGE_REQUEST_LIMIT: usize = OFFERS_MESSAGE_REQUEST_LIMIT; + /// The default relative expiry for reply paths where a quick response is expected and the reply /// path is single-use. #[cfg(async_payments)] diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 891c222236a..f333ab97dfc 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1324,6 +1324,11 @@ where self.offers_handler = offers_handler; } + #[cfg(any(test, feature = "_test_utils"))] + pub fn set_async_payments_handler(&mut self, async_payments_handler: APH) { + self.async_payments_handler = async_payments_handler; + } + /// Sends an [`OnionMessage`] based on its [`MessageSendInstructions`]. pub fn send_onion_message( &self, contents: T, instructions: MessageSendInstructions, @@ -1538,7 +1543,7 @@ where } #[cfg(test)] - pub(super) fn release_pending_msgs(&self) -> HashMap> { + pub(crate) fn release_pending_msgs(&self) -> HashMap> { let mut message_recipients = self.message_recipients.lock().unwrap(); let mut msgs = new_hash_map(); // We don't want to disconnect the peers by removing them entirely from the original map, so we diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 00b85cc1ef8..b7c82c970ac 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -319,13 +319,17 @@ pub struct TestMessageRouter<'a> { &'a TestLogger, &'a TestKeysInterface, >, + pub peers_override: Mutex>, } impl<'a> TestMessageRouter<'a> { pub fn new( network_graph: Arc>, entropy_source: &'a TestKeysInterface, ) -> Self { - Self { inner: DefaultMessageRouter::new(network_graph, entropy_source) } + Self { + inner: DefaultMessageRouter::new(network_graph, entropy_source), + peers_override: Mutex::new(Vec::new()), + } } } @@ -333,6 +337,13 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { fn find_path( &self, sender: PublicKey, peers: Vec, destination: Destination, ) -> Result { + let mut peers = peers; + { + let peers_override = self.peers_override.lock().unwrap(); + if !peers_override.is_empty() { + peers = peers_override.clone(); + } + } self.inner.find_path(sender, peers, destination) } @@ -340,6 +351,13 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { &self, recipient: PublicKey, context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { + let mut peers = peers; + { + let peers_override = self.peers_override.lock().unwrap(); + if !peers_override.is_empty() { + peers = peers_override.clone(); + } + } self.inner.create_blinded_paths(recipient, context, peers, secp_ctx) } @@ -347,6 +365,17 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { &self, recipient: PublicKey, context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { + let mut peers = peers; + { + let peers_override = self.peers_override.lock().unwrap(); + if !peers_override.is_empty() { + peers = peers_override + .clone() + .iter() + .map(|pk| MessageForwardNode { node_id: *pk, short_channel_id: None }) + .collect(); + } + } self.inner.create_compact_blinded_paths(recipient, context, peers, secp_ctx) } } From 706e2774f51920a9d3bd1fcd2d811c3eda14b953 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 4 Jun 2025 18:32:25 -0400 Subject: [PATCH 16/16] Test static invoice server protocol --- lightning/src/ln/async_payments_tests.rs | 953 +++++++++++++++++- .../src/offers/async_receive_offer_cache.rs | 22 + lightning/src/offers/flow.rs | 15 +- 3 files changed, 976 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index da90ebc6766..28ad7c5747f 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -25,13 +25,23 @@ use crate::ln::offers_tests; use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::PendingOutboundPayment; use crate::ln::outbound_payment::Retry; -use crate::offers::flow::TEST_OFFERS_MESSAGE_REQUEST_LIMIT; +use crate::offers::async_receive_offer_cache::{ + TEST_MAX_CACHE_SIZE, TEST_MAX_OFFERS, TEST_MAX_UPDATE_ATTEMPTS, + TEST_MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS, TEST_NUM_CACHED_OFFERS_TARGET, + TEST_OFFER_EXPIRES_SOON_THRESHOLD_PERCENT, TEST_PATHS_REQUESTS_RESET_INTERVAL, +}; +use crate::offers::flow::{ + TEST_DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY, TEST_OFFERS_MESSAGE_REQUEST_LIMIT, + TEST_TEMP_REPLY_PATH_RELATIVE_EXPIRY, +}; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::offer::Offer; use crate::offers::static_invoice::{StaticInvoice, DEFAULT_RELATIVE_EXPIRY}; use crate::onion_message::async_payments::{AsyncPaymentsMessage, AsyncPaymentsMessageHandler}; -use crate::onion_message::messenger::{Destination, MessageRouter, MessageSendInstructions}; +use crate::onion_message::messenger::{ + Destination, MessageRouter, MessageSendInstructions, PeeledOnion, +}; use crate::onion_message::offers::OffersMessage; use crate::onion_message::packet::ParsedOnionMessageContents; use crate::prelude::*; @@ -110,17 +120,14 @@ struct StaticInvoiceServerFlowResult { static_invoice_persisted_message: msgs::OnionMessage, } -// Go through the flow of interactively building a `StaticInvoice` and storing it with the static -// invoice server, returning the invoice and messages that were exchanged along the way at the end. -fn pass_static_invoice_server_messages( - server: &Node, recipient: &Node, recipient_id: Nonce, -) -> StaticInvoiceServerFlowResult { - // Force the server and recipient to send OMs directly to each other for testing simplicity. - server.message_router.peers_override.lock().unwrap().push(recipient.node.get_our_node_id()); - recipient.message_router.peers_override.lock().unwrap().push(server.node.get_our_node_id()); - - let num_cached_offers_before_flow = recipient.node.get_cached_async_receive_offers().len(); - +// Go through the flow of interactively building a `StaticInvoice`, returning the +// AsyncPaymentsMessage::ServeStaticInvoice that has yet to be provided to the server node. +// Assumes that the sender and recipient are only peers with each other. +// +// Returns (offer_paths_req, serve_static_invoice) +fn invoice_flow_up_to_send_serve_static_invoice( + server: &Node, recipient: &Node, +) -> (msgs::OnionMessage, msgs::OnionMessage) { // First provide an OfferPathsRequest from the recipient to the server. let offer_paths_req = recipient .onion_messenger @@ -156,6 +163,22 @@ fn pass_static_invoice_server_messages( .onion_messenger .next_onion_message_for_peer(server.node.get_our_node_id()) .unwrap(); + (offer_paths_req, serve_static_invoice_om) +} + +// Go through the flow of interactively building a `StaticInvoice` and storing it with the static +// invoice server, returning the invoice and messages that were exchanged along the way at the end. +fn pass_static_invoice_server_messages( + server: &Node, recipient: &Node, recipient_id: Nonce, +) -> StaticInvoiceServerFlowResult { + // Force the server and recipient to send OMs directly to each other for testing simplicity. + server.message_router.peers_override.lock().unwrap().push(recipient.node.get_our_node_id()); + recipient.message_router.peers_override.lock().unwrap().push(server.node.get_our_node_id()); + + let num_cached_offers_before_flow = recipient.node.get_cached_async_receive_offers().len(); + + let (offer_paths_req, serve_static_invoice_om) = + invoice_flow_up_to_send_serve_static_invoice(server, recipient); server .onion_messenger .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice_om); @@ -598,6 +621,14 @@ fn async_receive_flow_success() { let invoice_flow_res = pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id); let static_invoice = invoice_flow_res.invoice; assert!(static_invoice.invoice_features().supports_basic_mpp()); + + // Check that the recipient will ignore duplicate offers received. + nodes[2].onion_messenger.handle_onion_message( + nodes[1].node.get_our_node_id(), + &invoice_flow_res.static_invoice_persisted_message, + ); + assert_eq!(nodes[2].node.get_cached_async_receive_offers().len(), 1); + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); @@ -1368,3 +1399,899 @@ fn expired_static_invoice_payment_path() { 1, ); } + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn ignore_expired_offer_paths_request() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + + const OFFER_PATHS_REQ_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60); + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id], + Some(OFFER_PATHS_REQ_RELATIVE_EXPIRY) + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Retrieve the offer paths request, and check that before the path that the recipient was + // configured with expires the server will respond to it, and after the config path expires they + // won't. + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + server.onion_messenger.peel_onion_message(&offer_paths_req).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) + )); + recipient.onion_messenger.release_pending_msgs(); // Ignore redundant paths requests + + // Prior to the config path expiry the server will respond with offer_paths: + server.onion_messenger.handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req); + let offer_paths = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + recipient.onion_messenger.peel_onion_message(&offer_paths).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPaths(_), _, _) + )); + server.onion_messenger.release_pending_msgs(); // Ignore redundant offer_paths + + // After the config path expiry the offer paths request will be ignored: + let configured_path_absolute_expiry = + (server.node.duration_since_epoch() + OFFER_PATHS_REQ_RELATIVE_EXPIRY).as_secs() as u32; + let block = create_dummy_block( + server.best_block_hash(), + configured_path_absolute_expiry + 1u32, + Vec::new(), + ); + connect_block(&server, &block); + connect_block(&recipient, &block); + + server.onion_messenger.handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req); + assert!(server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .is_none()); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn ignore_expired_offer_paths_message() { + // If the recipient receives an offer_paths message over an expired reply path, it should be ignored. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // First retrieve the offer_paths_request and corresponding offer_paths response from the server. + recipient.node.timer_tick_occurred(); + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + recipient.onion_messenger.release_pending_msgs(); // Ignore redundant paths requests + server.onion_messenger.handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req); + let offer_paths = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + recipient.onion_messenger.peel_onion_message(&offer_paths).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPaths(_), _, _) + )); + + // Prior to expiry of the offer_paths_request reply path, the recipient will respond to + // offer_paths with serve_static_invoice. + recipient.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &offer_paths); + let serve_static_invoice = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + server.onion_messenger.peel_onion_message(&serve_static_invoice).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::ServeStaticInvoice(_), _, _) + )); + + // Manually advance time for the recipient so they will perceive the offer_paths message as being + // sent over an expired reply path, and not respond with serve_static_invoice. + let offer_paths_request_reply_path_exp = + (recipient.node.duration_since_epoch() + TEST_TEMP_REPLY_PATH_RELATIVE_EXPIRY).as_secs(); + let block = create_dummy_block( + recipient.best_block_hash(), + offer_paths_request_reply_path_exp as u32 + 1u32, + Vec::new(), + ); + connect_block(&recipient, &block); + + recipient.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &offer_paths); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn ignore_expired_serve_static_invoice_message() { + // If the server receives a serve_static_invoice message over an expired reply path, it should be + // ignored. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // First retrieve the serve_static_invoice message. + recipient.node.timer_tick_occurred(); + let serve_static_invoice = invoice_flow_up_to_send_serve_static_invoice(server, recipient).1; + + // Manually advance time for the server so they will perceive the serve_static_invoice message as being + // sent over an expired reply path, and not respond with serve_static_invoice. + let block = create_dummy_block( + server.best_block_hash(), + (server.node.duration_since_epoch() + TEST_DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY).as_secs() + as u32 + 1u32, + Vec::new(), + ); + connect_block(&server, &block); + + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice); + assert!(server.node.get_and_clear_pending_events().is_empty()); + assert!(server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .is_none()); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn ignore_expired_static_invoice_persisted_message() { + // If the recipient receives a static_invoice_persisted message over an expired reply path, it + // should be ignored. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Exchange messages until we can extract the final static_invoice_persisted OM. + recipient.node.timer_tick_occurred(); + let serve_static_invoice = invoice_flow_up_to_send_serve_static_invoice(server, recipient).1; + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice); + let mut events = server.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let ack_path = match events.pop().unwrap() { + Event::PersistStaticInvoice { invoice_persisted_path, .. } => invoice_persisted_path, + _ => panic!(), + }; + + server.node.static_invoice_persisted(ack_path); + let invoice_persisted = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + recipient.onion_messenger.peel_onion_message(&invoice_persisted).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::StaticInvoicePersisted(_), _, _) + )); + + let block = create_dummy_block( + recipient.best_block_hash(), + (recipient.node.duration_since_epoch() + TEST_TEMP_REPLY_PATH_RELATIVE_EXPIRY).as_secs() + as u32 + 1u32, + Vec::new(), + ); + connect_block(&server, &block); + connect_block(&recipient, &block); + recipient + .onion_messenger + .handle_onion_message(server.node.get_our_node_id(), &invoice_persisted); + assert!(recipient.node.get_cached_async_receive_offers().is_empty()); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn limit_offer_paths_requests() { + // Limit the number of offer_paths_requests sent to the server if they aren't responding. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Up to TEST_MAX_UPDATE_ATTEMPTS offer_paths_requests are allowed to be sent out before the async + // recipient should give up. + for _ in 0..TEST_MAX_UPDATE_ATTEMPTS { + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + server.onion_messenger.peel_onion_message(&offer_paths_req).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) + )); + recipient.onion_messenger.release_pending_msgs(); // Ignore redundant paths requests + recipient.node.timer_tick_occurred(); + } + + // After the recipient runs out of attempts to request offer paths, they will give up for a time. + recipient.node.timer_tick_occurred(); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); + + // After some time, more offer paths requests should be allowed to go through. + let block = create_dummy_block( + recipient.best_block_hash(), + (recipient.node.duration_since_epoch() + TEST_PATHS_REQUESTS_RESET_INTERVAL).as_secs() + as u32 + 1u32, + Vec::new(), + ); + connect_block(&recipient, &block); + + recipient.node.timer_tick_occurred(); + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + assert!(matches!( + server.onion_messenger.peel_onion_message(&offer_paths_req).unwrap(), + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) + )); +} + +#[test] +fn limit_serve_static_invoice_requests() { + // If we have enough async receive offers cached already, the recipient should stop sending out + // offer_paths_requests. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister_1; + let new_persister_2; + let new_chain_monitor_1; + let new_chain_monitor_2; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized_1; + let payee_node_deserialized_2; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister_1, + new_chain_monitor_1, + payee_node_deserialized_1, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Build the target number of offers interactively with the static invoice server. + let mut offer_paths_req = None; + for _ in 0..TEST_NUM_CACHED_OFFERS_TARGET { + let flow_res = pass_static_invoice_server_messages(server, recipient, recipient_id); + offer_paths_req = Some(flow_res.offer_paths_request); + + // Trigger a cache refresh + recipient.node.timer_tick_occurred(); + } + assert_eq!( + recipient.node.get_cached_async_receive_offers().len(), + TEST_NUM_CACHED_OFFERS_TARGET + ); + + // Force allowing more offer paths request attempts so we can check that the recipient will not + // attempt to build any further offers. + recipient.node.flow.test_reset_more_offer_paths_request_attempts(); + + recipient.node.timer_tick_occurred(); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); + + // If the recipient now receives new offer_paths, they should not attempt to build new offers as + // they already have enough. + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req.unwrap()); + let offer_paths = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + recipient.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &offer_paths); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); + + // Check that round trip serialization of the ChannelManager will result in identical stored + // offers. + let cached_offers_pre_ser = recipient.node.get_cached_async_receive_offers(); + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister_2, + new_chain_monitor_2, + payee_node_deserialized_2, + &[chan_id] + ); + let recipient = &nodes[1]; + let cached_offers_post_ser = recipient.node.get_cached_async_receive_offers(); + assert_eq!(cached_offers_pre_ser, cached_offers_post_ser); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn refresh_expiring_static_invoices() { + // Check that if we have a longer-lived offer but the corresponding static invoice is expiring + // soon, we'll refresh the offer that is persisted with the static invoice server. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); + allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; + let node_chanmgrs = + create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]); + let payee_node_deserialized; + + let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs); + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let chan_id_1_2 = + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.channel_id; + + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[1], + nodes[2], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id_1_2] + ); + let server = &nodes[1]; + let recipient = &nodes[2]; + + // Set up the recipient to have one offer and an invoice with the static invoice server. + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id); + + // Advance time for the recipient so they they view the static invoice corresponding to their + // offer as expired. + let block = create_dummy_block( + recipient.best_block_hash(), + (recipient.node.duration_since_epoch() + DEFAULT_RELATIVE_EXPIRY).as_secs() as u32 + 1u32, + Vec::new(), + ); + connect_block(&recipient, &block); + + // Exchange messages to ensure the recipient will update the static invoice that's persisted by + // the server. + + // Force the server and recipient to send OMs directly to each other for testing simplicity. + server.message_router.peers_override.lock().unwrap().push(recipient.node.get_our_node_id()); + recipient.message_router.peers_override.lock().unwrap().push(server.node.get_our_node_id()); + + recipient.node.timer_tick_occurred(); + + let serve_static_invoice_om = loop { + let msg = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + match server.onion_messenger.peel_onion_message(&msg).unwrap() { + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::ServeStaticInvoice(_), _, _) => { + break msg; + }, + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) => {}, + _ => panic!("Unexpected message"), + } + }; + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice_om); + let mut events = server.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let (updated_static_invoice, ack_path) = match events.pop().unwrap() { + Event::PersistStaticInvoice { invoice, invoice_persisted_path, recipient_id_nonce } => { + assert_eq!(recipient_id, recipient_id_nonce); + (invoice, invoice_persisted_path) + }, + _ => panic!(), + }; + server.node.static_invoice_persisted(ack_path); + let invoice_persisted_om = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + recipient + .onion_messenger + .handle_onion_message(server.node.get_our_node_id(), &invoice_persisted_om); + assert_eq!(recipient.node.get_cached_async_receive_offers().len(), 1); + + // Remove the peer restriction added above. + server.message_router.peers_override.lock().unwrap().clear(); + recipient.message_router.peers_override.lock().unwrap().clear(); + + // Complete a payment to the new invoice. + let offer = nodes[2].node.get_cached_async_receive_offers().pop().unwrap(); + let amt_msat = 5000; + let payment_id = PaymentId([1; 32]); + let params = RouteParametersConfig::default(); + nodes[0] + .node + .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) + .unwrap(); + + let release_held_htlc_om = pass_async_payments_oms( + updated_static_invoice.clone(), + &nodes[0], + &nodes[1], + &nodes[2], + recipient_id, + ) + .1; + nodes[0] + .onion_messenger + .handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let payment_hash = extract_payment_hash(&ev); + check_added_monitors!(nodes[0], 1); + + let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]]; + let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); + let claimable_ev = do_pass_along_path(args).unwrap(); + let keysend_preimage = extract_payment_preimage(&claimable_ev); + let res = + claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); + assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(updated_static_invoice))); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn limit_static_invoice_update_requests() { + // If a recipient tries to update the static invoice that is persisted with the server several + // times and the server is unresponsive, they should give up. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Set up the recipient to have one offer and an invoice with the static invoice server. + pass_static_invoice_server_messages(server, recipient, recipient_id); + + // Advance time for the recipient so they they view the static invoice corresponding to their + // offer as expired. + let block = create_dummy_block( + recipient.best_block_hash(), + (recipient.node.duration_since_epoch() + DEFAULT_RELATIVE_EXPIRY).as_secs() as u32 + 1u32, + Vec::new(), + ); + connect_block(&recipient, &block); + + // Force the server and recipient to send OMs directly to each other for testing simplicity. + server.message_router.peers_override.lock().unwrap().push(recipient.node.get_our_node_id()); + recipient.message_router.peers_override.lock().unwrap().push(server.node.get_our_node_id()); + + // Make sure the recipient will try MAX_UPDATE_ATTEMPTS to update their invoice. + for _ in 0..TEST_MAX_UPDATE_ATTEMPTS { + recipient.node.timer_tick_occurred(); + loop { + let msg = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + match server.onion_messenger.peel_onion_message(&msg).unwrap() { + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::ServeStaticInvoice(_), _, _) => { + break + }, + PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) => {}, + _ => panic!("Unexpected message"), + } + } + recipient.onion_messenger.release_pending_msgs(); // Clear all messages + } + + // After reaching the maximum number of update attempts, the recipient should give up updating + // their invoice. + recipient.node.timer_tick_occurred(); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); + // Check that we won't return offers from our API where the invoice is expired. + assert!(recipient.node.get_cached_async_receive_offers().is_empty()); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn limit_cached_offers() { + // While the cache size limit should never be hit in practice, check that the recipient will limit + // the number of offers stored. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Retrieve an offer_paths_request from the recipient. + recipient.message_router.peers_override.lock().unwrap().push(server.node.get_our_node_id()); + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + + // Send many paths requests to the server, who should respond to all of them, then send many + // static invoices to the server for persistence. Indicate to the recipient that all of these + // static invoices were persisted and make sure the recipient will cache many offers but will + // refuse to store any more when the cache is full. + let mut static_invoice_persisted_oms = Vec::new(); + for _ in 0..TEST_MAX_OFFERS + 1 { + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req); + let offer_paths = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + recipient.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &offer_paths); + + // Advance time by for each new offer generated, so the recipient thinks each offer is + // newer than the last. + let block = create_dummy_block( + recipient.best_block_hash(), + recipient.node.duration_since_epoch().as_secs() as u32 + 1u32, + Vec::new(), + ); + connect_block(recipient, &block); + connect_block(server, &block); + + let serve_static_invoice_om = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice_om); + let mut events = server.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let (_static_invoice, ack_path) = match events.pop().unwrap() { + Event::PersistStaticInvoice { invoice, invoice_persisted_path, recipient_id_nonce } => { + assert_eq!(recipient_id, recipient_id_nonce); + (invoice, invoice_persisted_path) + }, + _ => panic!(), + }; + server.node.static_invoice_persisted(ack_path); + let invoice_persisted_om = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + static_invoice_persisted_oms.push(invoice_persisted_om); + } + for msg in static_invoice_persisted_oms { + let offers_pre_new_invoice_persist = recipient.node.get_cached_async_receive_offers(); + recipient.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &msg); + // Every new invoice persist should result in the recipient's list of offers being updated, + // since even if the cache is full they will swap out their soonest-expiring offer for the new + // one. + assert_ne!( + offers_pre_new_invoice_persist, + recipient.node.get_cached_async_receive_offers() + ); + } + assert!(recipient.node.get_cached_async_receive_offers().len() <= TEST_MAX_OFFERS); + assert!(recipient.node.get_cached_async_receive_offers().len() > TEST_MAX_OFFERS / 2); + assert!( + recipient.node.flow.writeable_async_receive_offer_cache().serialized_length() + <= TEST_MAX_CACHE_SIZE + ); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn ignore_expired_static_invoice() { + // If a server receives an expired static invoice to persist, they should ignore it and not + // generate an event. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + let (_, serve_static_invoice_om) = + invoice_flow_up_to_send_serve_static_invoice(server, recipient); + + // Advance time for the server so that by the time it receives the serve_static_invoice message, + // the invoice within has expired. + let block = create_dummy_block( + server.best_block_hash(), + server.node.duration_since_epoch().as_secs() as u32 + + DEFAULT_RELATIVE_EXPIRY.as_secs() as u32 + + 1, + Vec::new(), + ); + connect_block(server, &block); + + server + .onion_messenger + .handle_onion_message(recipient.node.get_our_node_id(), &serve_static_invoice_om); + let mut events = server.node.get_and_clear_pending_events(); + assert!(events.is_empty()); +} + +#[test] +fn ignore_offer_paths_expiry_too_soon() { + // Recipents should ignore received offer_paths that expire too soon. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // Get a legit offer_paths message from the server. + let offer_paths_req = recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + recipient.onion_messenger.release_pending_msgs(); + server.onion_messenger.handle_onion_message(recipient.node.get_our_node_id(), &offer_paths_req); + let offer_paths = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + + // Get the blinded path use when manually sending the modified offer_paths message to the + // recipient. + let offer_paths_req_reply_path = + match server.onion_messenger.peel_onion_message(&offer_paths_req) { + Ok(PeeledOnion::AsyncPayments( + AsyncPaymentsMessage::OfferPathsRequest(_), + _, + reply_path, + )) => reply_path.unwrap(), + _ => panic!(), + }; + + // Modify the offer_paths message from the server to indicate that the offer paths expire too + // soon. + let (mut offer_paths_unwrapped, ctx) = match recipient + .onion_messenger + .peel_onion_message(&offer_paths) + { + Ok(PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPaths(msg), ctx, _)) => (msg, ctx), + _ => panic!(), + }; + let too_soon_expiry_secs = recipient + .node + .duration_since_epoch() + .as_secs() + .saturating_add(TEST_MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS - 1); + offer_paths_unwrapped.paths_absolute_expiry = Some(Duration::from_secs(too_soon_expiry_secs)); + + // Deliver the expired paths to the recipient and make sure they don't construct a + // serve_static_invoice message in response. + server + .onion_messenger + .send_onion_message( + ParsedOnionMessageContents::::AsyncPayments( + AsyncPaymentsMessage::OfferPaths(offer_paths_unwrapped), + ), + MessageSendInstructions::WithReplyPath { + destination: Destination::BlindedPath(offer_paths_req_reply_path), + // This context isn't used because the recipient doesn't reply to the message + context: MessageContext::AsyncPayments(ctx), + }, + ) + .unwrap(); + let offer_paths_expiry_too_soon = server + .onion_messenger + .next_onion_message_for_peer(recipient.node.get_our_node_id()) + .unwrap(); + recipient + .onion_messenger + .handle_onion_message(server.node.get_our_node_id(), &offer_paths_expiry_too_soon); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); +} + +#[cfg_attr(feature = "std", ignore)] +#[test] +fn request_more_offer_paths_when_offers_expire_soon() { + // Check that an async recipient will build new offers it has the target number of offers cached + // but doesn't have enough offers that don't expire soon. + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let new_persister; + let new_chain_monitor; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let payee_node_deserialized; + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0).0.channel_id; + let recipient_id = reload_payee_with_async_receive_cfg!( + nodes[0], + nodes[1], + new_persister, + new_chain_monitor, + payee_node_deserialized, + &[chan_id] + ); + let server = &nodes[0]; + let recipient = &nodes[1]; + + // First fill the recipient's offer cache with the target number of offers. + for _ in 0..TEST_NUM_CACHED_OFFERS_TARGET { + pass_static_invoice_server_messages(server, recipient, recipient_id); + recipient.node.timer_tick_occurred(); + } + + // If the recipient already has the target number of offers cached, they shouldn't request any + // more offer paths. + recipient.node.timer_tick_occurred(); + assert!(recipient + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .is_none()); + + // Manually advance time for the recipient so they will perceive their offers as expiring soon. + let offers_expire_soon_relative_duration = (TEST_DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY + * TEST_OFFER_EXPIRES_SOON_THRESHOLD_PERCENT as u32) + / 100; + let block = create_dummy_block( + recipient.best_block_hash(), + (recipient.node.duration_since_epoch() + offers_expire_soon_relative_duration).as_secs() + as u32 + 1u32, + Vec::new(), + ); + connect_block(&recipient, &block); + + recipient.node.timer_tick_occurred(); + pass_static_invoice_server_messages(server, recipient, recipient_id); +} diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 9407f399f10..8a957b66c51 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -118,6 +118,23 @@ const OFFER_EXPIRES_SOON_THRESHOLD_PERCENT: u64 = 90; #[cfg(async_payments)] const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 60 * 60; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_NUM_CACHED_OFFERS_TARGET: usize = NUM_CACHED_OFFERS_TARGET; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_MAX_OFFERS: usize = MAX_OFFERS; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_MAX_CACHE_SIZE: usize = MAX_CACHE_SIZE; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_MAX_UPDATE_ATTEMPTS: u8 = MAX_UPDATE_ATTEMPTS; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_PATHS_REQUESTS_RESET_INTERVAL: Duration = PATHS_REQUESTS_RESET_INTERVAL; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_OFFER_EXPIRES_SOON_THRESHOLD_PERCENT: u64 = + OFFER_EXPIRES_SOON_THRESHOLD_PERCENT; +#[cfg(all(test, async_payments))] +pub(crate) const TEST_MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = + MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS; + #[cfg(async_payments)] impl AsyncReceiveOfferCache { /// Retrieve our cached [`Offer`]s for receiving async payments as an often-offline recipient. @@ -234,6 +251,11 @@ impl AsyncReceiveOfferCache { self.last_offer_paths_request_timestamp = Duration::from_secs(0); } + #[cfg(test)] + pub(super) fn test_reset_offer_paths_request_attempts(&mut self) { + self.reset_offer_paths_request_attempts() + } + /// 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( diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 7508b8b8dd0..964fcc4c821 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -230,12 +230,19 @@ pub(crate) const TEST_OFFERS_MESSAGE_REQUEST_LIMIT: usize = OFFERS_MESSAGE_REQUE /// 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); +const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(2 * 60 * 60); + +#[cfg(all(async_payments, test))] +pub(crate) const TEST_TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = TEMP_REPLY_PATH_RELATIVE_EXPIRY; // Default to async receive offers and the paths used to update them lasting 1 year. #[cfg(async_payments)] const DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY: Duration = Duration::from_secs(365 * 24 * 60 * 60); +#[cfg(all(async_payments, test))] +pub(crate) const TEST_DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY: Duration = + DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY; + impl OffersMessageFlow where MR::Target: MessageRouter, @@ -1705,4 +1712,10 @@ where pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ { &self.async_receive_offer_cache } + + #[cfg(all(test, async_payments))] + pub(crate) fn test_reset_more_offer_paths_request_attempts(&self) { + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.test_reset_offer_paths_request_attempts(); + } }