diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index f2da1a316fc..03903abbf6b 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -15,7 +15,8 @@ use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::ln::script::ShutdownScript; use lightning::offers::invoice::UnsignedBolt12Invoice; use lightning::onion_message::async_payments::{ - AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc, + AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ReleaseHeldHtlc, + ServeStaticInvoice, StaticInvoicePersisted, }; use lightning::onion_message::messenger::{ CustomOnionMessageHandler, Destination, MessageRouter, MessageSendInstructions, @@ -124,6 +125,30 @@ impl OffersMessageHandler for TestOffersMessageHandler { struct TestAsyncPaymentsMessageHandler {} impl AsyncPaymentsMessageHandler for TestAsyncPaymentsMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + let responder = match responder { + Some(resp) => resp, + None => return None, + }; + Some((OfferPaths { paths: Vec::new(), paths_absolute_expiry: None }, responder.respond())) + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, responder: Option, diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 164cfcfb1ad..99dd4fa667c 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -23,6 +23,7 @@ use crate::ln::channelmanager::PaymentId; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::offers::nonce::Nonce; +use crate::offers::offer::OfferId; use crate::onion_message::packet::ControlTlvs; use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph}; use crate::sign::{EntropySource, NodeSigner, Recipient}; @@ -404,6 +405,40 @@ pub enum OffersContext { /// [`AsyncPaymentsMessage`]: crate::onion_message::async_payments::AsyncPaymentsMessage #[derive(Clone, Debug)] pub enum AsyncPaymentsContext { + /// Context used by a reply path to an [`OfferPathsRequest`], provided back to us as an async + /// recipient in corresponding [`OfferPaths`] messages from the static invoice server. + /// + /// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + OfferPaths { + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. + /// + /// This avoids the situation where the [`OfferPaths`] message is very delayed and thus + /// outdated. + /// + /// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths + path_absolute_expiry: core::time::Duration, + }, + /// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in + /// corresponding [`StaticInvoicePersisted`] messages. + /// + /// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + StaticInvoicePersisted { + /// The id of the offer in the cache corresponding to the [`StaticInvoice`] that has been + /// persisted. This invoice is now ready to be provided by the static invoice server in response + /// to [`InvoiceRequest`]s, so the corresponding offer can be marked as ready to receive + /// payments. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + offer_id: OfferId, + /// The time as duration since the Unix epoch at which this path expires and messages sent over + /// it should be ignored. If we receive confirmation of an invoice over this path after its + /// expiry, it may be outdated and a new invoice update should be sent instead. + path_absolute_expiry: core::time::Duration, + }, /// Context contained within the reply [`BlindedMessagePath`] we put in outbound /// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`] /// messages. @@ -486,6 +521,13 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext, (2, hmac, required), (4, path_absolute_expiry, required), }, + (2, OfferPaths) => { + (0, path_absolute_expiry, required), + }, + (3, StaticInvoicePersisted) => { + (0, offer_id, required), + (2, path_absolute_expiry, required), + }, ); /// Contains a simple nonce for use in a blinded path's context. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9e7031bf8cb..bfeb181e755 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -86,6 +86,7 @@ use crate::ln::outbound_payment::{ StaleExpiration, }; use crate::ln::types::ChannelId; +use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::OffersMessageFlow; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, @@ -98,7 +99,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::{ @@ -5246,6 +5248,30 @@ where ) } + #[cfg(async_payments)] + fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { + let peers = self.get_peers_for_blinded_path(); + let channels = self.list_usable_channels(); + let entropy = &*self.entropy_source; + let router = &*self.router; + let refresh_res = self.flow.check_refresh_async_receive_offer_cache( + peers, + channels, + entropy, + router, + timer_tick_occurred, + ); + match refresh_res { + Err(()) => { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }, + Ok(()) => {}, + } + } + #[cfg(async_payments)] fn initiate_async_payment( &self, invoice: &StaticInvoice, payment_id: PaymentId, @@ -7240,6 +7266,9 @@ where duration_since_epoch, &self.pending_events ); + #[cfg(async_payments)] + self.check_refresh_async_receive_offer_cache(true); + // Technically we don't need to do this here, but if we have holding cell entries in a // channel that need freeing, it's better to do that here and block a background task // than block the message queueing pipeline. @@ -10999,9 +11028,29 @@ where #[cfg(c_bindings)] create_refund_builder!(self, RefundMaybeWithDerivedMetadataBuilder); + /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. Will only + /// return an offer if [`Self::set_paths_to_static_invoice_server`] was called and we succeeded in + /// interactively building a [`StaticInvoice`] with the static invoice server. + /// + /// Useful for posting offers to receive payments later, such as posting an offer on a website. + #[cfg(async_payments)] + pub fn get_async_receive_offer(&self) -> Result { + let (offer, needs_persist) = self.flow.get_async_receive_offer()?; + if needs_persist { + // We need to re-persist the cache if a fresh offer was just marked as used to ensure we + // continue to keep this offer's invoice updated and don't replace it with the server. + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + } + Ok(offer) + } + /// Create an offer for receiving async payments as an often-offline recipient. /// - /// Because we may be offline when the payer attempts to request an invoice, you MUST: + /// Instead of using this method, it is preferable to call + /// [`Self::set_paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_async_receive_offer`]. + /// + /// If you want to build the [`StaticInvoice`] manually using this method instead, you MUST: /// 1. Provide at least 1 [`BlindedMessagePath`] terminating at an always-online node that will /// serve the [`StaticInvoice`] created from this offer on our behalf. /// 2. Use [`Self::create_static_invoice_builder`] to create a [`StaticInvoice`] from this @@ -11018,6 +11067,10 @@ where /// Creates a [`StaticInvoiceBuilder`] from the corresponding [`Offer`] and [`Nonce`] that were /// created via [`Self::create_async_receive_offer_builder`]. If `relative_expiry` is unset, the /// invoice's expiry will default to [`STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY`]. + /// + /// Instead of using this method to manually build the invoice, it is preferable to set + /// [`Self::set_paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_async_receive_offer`]. #[cfg(async_payments)] pub fn create_static_invoice_builder<'a>( &self, offer: &'a Offer, offer_nonce: Nonce, relative_expiry: Option, @@ -11053,6 +11106,22 @@ where ) } + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build + /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers + /// on our behalf when we're offline. + /// + /// This method only needs to be called once when the server first takes on the recipient as a + /// client, or when the paths change, e.g. if the paths are set to expire at a particular time. + #[cfg(async_payments)] + pub fn set_paths_to_static_invoice_server( + &self, paths_to_static_invoice_server: Vec, + ) -> Result<(), ()> { + self.flow.set_paths_to_static_invoice_server(paths_to_static_invoice_server)?; + + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + Ok(()) + } + /// Pays for an [`Offer`] using the given parameters by creating an [`InvoiceRequest`] and /// enqueuing it to be sent via an onion message. [`ChannelManager`] will pay the actual /// [`Bolt12Invoice`] once it is received. @@ -11960,6 +12029,13 @@ where return NotifyOption::SkipPersistHandleEvents; //TODO: Also re-broadcast announcement_signatures }); + + // While we usually refresh the AsyncReceiveOfferCache on a timer, we also want to start + // interactively building offers as soon as we can after startup. We can't start building offers + // until we have some peer connection(s) to send onion messages over, so as a minor optimization + // refresh the cache when a peer connects. + #[cfg(async_payments)] + self.check_refresh_async_receive_offer_cache(false); res } @@ -13374,6 +13450,64 @@ 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)> { + #[cfg(async_payments)] + { + let responder = match _responder { + Some(responder) => responder, + None => return None, + }; + let (serve_static_invoice, reply_context) = match self.flow.handle_offer_paths( + _message, + _context, + responder.clone(), + self.get_peers_for_blinded_path(), + self.list_usable_channels(), + &*self.entropy_source, + &*self.router, + ) { + Some((msg, ctx)) => (msg, ctx), + None => return None, + }; + + // We cached a new pending offer, so persist the cache. + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + + let response_instructions = responder.respond_with_reply_path(reply_context); + return Some((serve_static_invoice, response_instructions)); + } + + #[cfg(not(async_payments))] + return None; + } + + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + #[cfg(async_payments)] + { + let should_persist = self.flow.handle_static_invoice_persisted(_context); + if should_persist { + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + } + } + } + fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, @@ -14239,6 +14373,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(()) @@ -14818,6 +14953,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), @@ -14835,6 +14971,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); @@ -15521,7 +15658,7 @@ where chain_hash, best_block, our_network_pubkey, highest_seen_timestamp, expanded_inbound_key, secp_ctx.clone(), args.message_router - ); + ).with_async_payments_offers_cache(async_receive_offer_cache); let channel_manager = ChannelManager { chain_hash, diff --git a/lightning/src/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/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index d5643604041..338007032ab 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -31,7 +31,8 @@ use crate::ln::types::ChannelId; use crate::ln::wire; use crate::ln::wire::{Encode, Type}; use crate::onion_message::async_payments::{ - AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc, + AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ReleaseHeldHtlc, + ServeStaticInvoice, StaticInvoicePersisted, }; use crate::onion_message::dns_resolution::{ DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, @@ -212,6 +213,26 @@ impl OffersMessageHandler for IgnoringMessageHandler { } } impl AsyncPaymentsMessageHandler for IgnoringMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + _responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + None + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs new file mode 100644 index 00000000000..dd10fb099c2 --- /dev/null +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -0,0 +1,486 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Data structures and methods for caching offers that we interactively build with a static invoice +//! server as an async recipient. The static invoice server will serve the resulting invoices to +//! payers on our behalf when we're offline. + +use crate::blinded_path::message::BlindedMessagePath; +use crate::io; +use crate::io::Read; +use crate::ln::msgs::DecodeError; +use crate::offers::nonce::Nonce; +use crate::offers::offer::Offer; +use crate::onion_message::messenger::Responder; +use crate::prelude::*; +use crate::util::ser::{Readable, Writeable, Writer}; +use core::time::Duration; + +#[cfg(async_payments)] +use crate::blinded_path::message::AsyncPaymentsContext; + +/// The status of this offer in the cache. +#[derive(Clone, PartialEq)] +enum OfferStatus { + /// This offer has been returned to the user from the cache, so it needs to be stored until it + /// expires and its invoice needs to be kept updated. + Used, + /// This offer has not yet been returned to the user, and is safe to replace to ensure we always + /// have a maximally fresh offer. We always want to have at least 1 offer in this state, + /// preferably a few so we can respond to user requests for new offers without returning the same + /// one multiple times. Returning a new offer each time is better for privacy. + Ready { + /// If this offer's invoice has been persisted for some time, it's safe to replace to ensure we + /// always have the freshest possible offer available when the user goes to pull an offer from + /// the cache. + invoice_confirmed_persisted_at: Duration, + }, + /// This offer's invoice is not yet confirmed as persisted by the static invoice server, so it is + /// not yet ready to receive payments. + Pending, +} + +#[derive(Clone)] +struct AsyncReceiveOffer { + offer: Offer, + /// Whether this offer is used, ready for use, or pending invoice persistence with the static + /// invoice server. + status: OfferStatus, + + /// The below fields are used to generate and persist a new static invoice with the invoice + /// server. We support automatically rotating the invoice for long-lived offers so users don't + /// have to update the offer they've posted on e.g. their website if fees change or the invoices' + /// payment paths become otherwise outdated. + offer_nonce: Nonce, + update_static_invoice_path: Responder, +} + +impl_writeable_tlv_based_enum!(OfferStatus, + (0, Used) => {}, + (1, Ready) => { + (0, invoice_confirmed_persisted_at, required), + }, + (2, Pending) => {}, +); + +impl_writeable_tlv_based!(AsyncReceiveOffer, { + (0, offer, required), + (2, offer_nonce, required), + (4, status, required), + (6, update_static_invoice_path, required), +}); + +/// If we are an often-offline recipient, we'll want to interactively build offers and static +/// invoices with an always-online node that will serve those static invoices to payers on our +/// behalf when we are offline. +/// +/// This struct is used to cache those interactively built offers, and should be passed into +/// [`OffersMessageFlow`] on startup as well as persisted whenever an offer or invoice is updated. +/// +/// ## Lifecycle of a cached offer +/// +/// 1. On initial startup, recipients will request offer paths from the static invoice server +/// 2. Once a set of offer paths is received, recipients will build an offer and corresponding +/// static invoice, cache the offer as pending, and send the invoice to the server for +/// persistence +/// 3. Once the invoice is confirmed as persisted by the server, the recipient will mark the +/// corresponding offer as ready to receive payments +/// 4. If the offer is later returned to the user, it will be kept cached and its invoice will be +/// kept up-to-date until the offer expires +/// 5. If the offer does not get returned to the user within a certain timeframe, it will be +/// replaced with a new one using fresh offer paths requested from the static invoice server +/// +/// ## Staying in sync with the Static Invoice Server +/// +/// * Pending offers: for a given cached offer where a corresponding invoice is not yet confirmed as +/// persisted by the static invoice server, we will retry persisting an invoice for that offer until +/// it succeeds, once per timer tick +/// * Confirmed offers that have not yet been returned to the user: we will periodically replace an +/// unused confirmed offer with a new one, to try to always have a fresh offer available. We wait +/// several hours in between replacements to ensure the new offer replacement doesn't conflict with +/// the old one +/// * Confirmed offers that have been returned to the user: we will send the server a fresh invoice +/// corresponding to each used offer once per timer tick until the offer expires +/// +/// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow +pub struct AsyncReceiveOfferCache { + /// The cache is allocated up-front with a fixed number of slots for offers, where each slot is + /// filled in with an AsyncReceiveOffer as they are interactively built. + /// + /// We only want to store a limited number of static invoices with the server, and those stored + /// invoices need to regularly be replaced with new ones. When sending a replacement invoice to + /// the server, we indicate which invoice is being replaced by the invoice's "slot number", + /// see [`ServeStaticInvoice::invoice_slot`]. So rather than internally tracking which cached + /// offer corresponds to what invoice slot number on the server's end, we always set the slot + /// number to the index of the offer in the cache. + /// + /// [`ServeStaticInvoice::invoice_slot`]: crate::onion_message::async_payments::ServeStaticInvoice + offers: Vec>, + /// Used to limit the number of times we request paths for our offer from the static invoice + /// server. + #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag + offer_paths_request_attempts: u8, + /// Blinded paths used to request offer paths from the static invoice server. + #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag + paths_to_static_invoice_server: Vec, +} + +impl AsyncReceiveOfferCache { + /// Creates an empty [`AsyncReceiveOfferCache`] to be passed into [`OffersMessageFlow`]. + /// + /// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow + pub fn new() -> Self { + Self { + offers: Vec::new(), + offer_paths_request_attempts: 0, + paths_to_static_invoice_server: Vec::new(), + } + } + + pub(super) fn paths_to_static_invoice_server(&self) -> Vec { + self.paths_to_static_invoice_server.clone() + } + + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build + /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers + /// on our behalf when we're offline. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + #[cfg(async_payments)] + pub fn set_paths_to_static_invoice_server( + &mut self, paths_to_static_invoice_server: Vec, + ) -> Result<(), ()> { + if paths_to_static_invoice_server.is_empty() { + return Err(()); + } + + self.paths_to_static_invoice_server = paths_to_static_invoice_server; + if self.offers.is_empty() { + // See `AsyncReceiveOfferCache::offers`. + self.offers = vec![None; MAX_CACHED_OFFERS_TARGET]; + } + Ok(()) + } +} + +// The target number of offers we want to have cached at any given time, to mitigate too much +// reuse of the same offer while also limiting the amount of space our offers take up on the +// server's end. +#[cfg(async_payments)] +const MAX_CACHED_OFFERS_TARGET: usize = 10; + +// The max number of times we'll attempt to request offer paths per timer tick. +#[cfg(async_payments)] +const MAX_UPDATE_ATTEMPTS: u8 = 3; + +// If we have an offer that is replaceable and its invoice was confirmed as persisted more than 2 +// hours ago, we can go ahead and refresh it because we always want to have the freshest offer +// possible when a user goes to retrieve a cached offer. +// +// We avoid replacing unused offers too quickly -- this prevents the case where we send multiple +// invoices from different offers competing for the same slot to the server, messages are received +// delayed or out-of-order, and we end up providing an offer to the user that the server just +// deleted and replaced. +#[cfg(async_payments)] +const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60); + +// Require offer paths that we receive to last at least 3 months. +#[cfg(async_payments)] +const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 30 * 24 * 60 * 60; + +#[cfg(async_payments)] +impl AsyncReceiveOfferCache { + /// Retrieve a cached [`Offer`] for receiving async payments as an often-offline recipient, as + /// well as returning a bool indicating whether the cache needs to be re-persisted. + /// + // We need to re-persist the cache if a fresh offer was just marked as used to ensure we continue + // to keep this offer's invoice updated and don't replace it with the server. + pub fn get_async_receive_offer( + &mut self, duration_since_epoch: Duration, + ) -> Result<(Offer, bool), ()> { + self.prune_expired_offers(duration_since_epoch, false); + + // Find the freshest unused offer, where "freshness" is based on when the invoice was confirmed + // persisted by the server. See `OfferStatus::Ready`. + let newest_unused_offer_opt = self + .unused_offers() + .max_by(|(_, _, persisted_at_a), (_, _, persisted_at_b)| { + persisted_at_a.cmp(&persisted_at_b) + }) + .map(|(idx, offer, _)| (idx, offer.offer.clone())); + if let Some((idx, newest_ready_offer)) = newest_unused_offer_opt { + self.offers[idx].as_mut().map(|offer| offer.status = OfferStatus::Used); + return Ok((newest_ready_offer, true)); + } + + // If no unused offers are available, return the used offer with the latest absolute expiry + self.offers_with_idx() + .filter(|(_, offer)| matches!(offer.status, OfferStatus::Used)) + .max_by(|a, b| { + let abs_expiry_a = a.1.offer.absolute_expiry().unwrap_or(Duration::MAX); + let abs_expiry_b = b.1.offer.absolute_expiry().unwrap_or(Duration::MAX); + abs_expiry_a.cmp(&abs_expiry_b) + }) + .map(|(_, cache_offer)| (cache_offer.offer.clone(), false)) + .ok_or(()) + } + + /// Remove expired offers from the cache, returning whether new offers are needed. + pub(super) fn prune_expired_offers( + &mut self, duration_since_epoch: Duration, force_reset_request_attempts: bool, + ) -> bool { + // Remove expired offers from the cache. + let mut offer_was_removed = false; + for offer_opt in self.offers.iter_mut() { + let offer_is_expired = offer_opt + .as_ref() + .map_or(false, |offer| offer.offer.is_expired_no_std(duration_since_epoch)); + if offer_is_expired { + offer_opt.take(); + offer_was_removed = true; + } + } + + // Allow up to `MAX_UPDATE_ATTEMPTS` offer paths requests to be sent out roughly once per + // minute, or if an offer was removed. + if force_reset_request_attempts || offer_was_removed { + self.reset_offer_paths_request_attempts() + } + + self.needs_new_offer_idx(duration_since_epoch).is_some() + && self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS + } + + /// Returns whether the new paths we've just received from the static invoice server should be used + /// to build a new offer. + pub(super) fn should_build_offer_with_paths( + &self, offer_paths: &[BlindedMessagePath], offer_paths_absolute_expiry_secs: Option, + duration_since_epoch: Duration, + ) -> bool { + if self.needs_new_offer_idx(duration_since_epoch).is_none() { + return false; + } + + // Require the offer that would be built using these paths to last at least + // `MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS`. + let min_offer_paths_absolute_expiry = + duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS); + let offer_paths_absolute_expiry = offer_paths_absolute_expiry_secs.unwrap_or(u64::MAX); + if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry { + return false; + } + + // Check that we don't have any current offers that already contain these paths + self.offers_with_idx().all(|(_, offer)| offer.offer.paths() != offer_paths) + } + + /// We've sent a static invoice to the static invoice server for persistence. Cache the + /// corresponding pending offer so we can retry persisting a corresponding invoice with the server + /// until it succeeds, see [`AsyncReceiveOfferCache`] docs. + pub(super) fn cache_pending_offer( + &mut self, offer: Offer, offer_paths_absolute_expiry_secs: Option, offer_nonce: Nonce, + update_static_invoice_path: Responder, duration_since_epoch: Duration, + ) -> Result { + self.prune_expired_offers(duration_since_epoch, false); + + if !self.should_build_offer_with_paths( + offer.paths(), + offer_paths_absolute_expiry_secs, + duration_since_epoch, + ) { + return Err(()); + } + + let idx = match self.needs_new_offer_idx(duration_since_epoch) { + Some(idx) => idx, + None => return Err(()), + }; + + match self.offers.get_mut(idx) { + Some(offer_opt) => { + *offer_opt = Some(AsyncReceiveOffer { + offer, + offer_nonce, + status: OfferStatus::Pending, + update_static_invoice_path, + }); + }, + None => return Err(()), + } + + Ok(idx.try_into().map_err(|_| ())?) + } + + /// If we have any empty slots in the cache or offers that can and should be replaced with a fresh + /// offer, here we return the index of the slot that needs a new offer. The index is used for + /// setting [`ServeStaticInvoice::invoice_slot`] when sending the corresponding new static invoice + /// to the server, so the server knows which existing persisted invoice is being replaced, if any. + /// + /// Returns `None` if the cache is full and no offers can currently be replaced. + /// + /// [`ServeStaticInvoice::invoice_slot`]: crate::onion_message::async_payments::ServeStaticInvoice::invoice_slot + fn needs_new_offer_idx(&self, duration_since_epoch: Duration) -> Option { + // If we have any empty offer slots, return the first one we find + let empty_slot_idx_opt = self.offers.iter().position(|offer_opt| offer_opt.is_none()); + if empty_slot_idx_opt.is_some() { + return empty_slot_idx_opt; + } + + // If all of our offers are already used or pending, then none are available to be replaced + let no_replaceable_offers = self + .offers_with_idx() + .all(|(_, offer)| matches!(offer.status, OfferStatus::Used | OfferStatus::Pending)); + if no_replaceable_offers { + return None; + } + + // All offers are pending except for one, so we shouldn't request an update of the only usable + // offer + let num_payable_offers = self + .offers_with_idx() + .filter(|(_, offer)| { + matches!(offer.status, OfferStatus::Used | OfferStatus::Ready { .. }) + }) + .count(); + if num_payable_offers <= 1 { + return None; + } + + // Filter for unused offers where longer than OFFER_REFRESH_THRESHOLD time has passed since they + // were last updated, so they are stale enough to warrant replacement. + let awhile_ago = duration_since_epoch.saturating_sub(OFFER_REFRESH_THRESHOLD); + self.unused_offers() + .filter(|(_, _, invoice_confirmed_persisted_at)| { + *invoice_confirmed_persisted_at < awhile_ago + }) + // Get the stalest offer and return its index + .min_by(|(_, _, persisted_at_a), (_, _, persisted_at_b)| { + persisted_at_a.cmp(&persisted_at_b) + }) + .map(|(idx, _, _)| idx) + } + + /// Returns an iterator over (offer_idx, offer) + fn offers_with_idx(&self) -> impl Iterator { + self.offers.iter().enumerate().filter_map(|(idx, offer_opt)| { + if let Some(offer) = offer_opt { + Some((idx, offer)) + } else { + None + } + }) + } + + /// Returns an iterator over (offer_idx, offer, invoice_confirmed_persisted_at) + /// where all returned offers are [`OfferStatus::Ready`] + fn unused_offers(&self) -> impl Iterator { + self.offers_with_idx().filter_map(|(idx, offer)| match offer.status { + OfferStatus::Ready { invoice_confirmed_persisted_at } => { + Some((idx, offer, invoice_confirmed_persisted_at)) + }, + _ => None, + }) + } + + // Indicates that onion messages requesting new offer paths have been sent to the static invoice + // server. Calling this method allows the cache to self-limit how many requests are sent. + pub(super) fn new_offers_requested(&mut self) { + self.offer_paths_request_attempts += 1; + } + + /// Called on timer tick (roughly once per minute) to allow another [`MAX_UPDATE_ATTEMPTS`] offer + /// paths requests to go out. + fn reset_offer_paths_request_attempts(&mut self) { + self.offer_paths_request_attempts = 0; + } + + /// Returns an iterator over the list of cached offers where we need to send an updated invoice to + /// the static invoice server. + pub(super) fn offers_needing_invoice_refresh( + &self, + ) -> impl Iterator { + // For any offers which are either in use or pending confirmation by the server, we should send + // them a fresh invoice on each timer tick. + self.offers_with_idx().filter_map(|(idx, offer)| { + let needs_invoice_update = + offer.status == OfferStatus::Used || offer.status == OfferStatus::Pending; + if needs_invoice_update { + let offer_slot = idx.try_into().unwrap_or(u16::MAX); + Some(( + &offer.offer, + offer.offer_nonce, + offer_slot, + &offer.update_static_invoice_path, + )) + } else { + None + } + }) + } + + /// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice + /// server, which indicates that a new offer was persisted by the server and they are ready to + /// serve the corresponding static invoice to payers on our behalf. + /// + /// Returns a bool indicating whether an offer was added/updated and re-persistence of the cache + /// is needed. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + pub(super) fn static_invoice_persisted( + &mut self, context: AsyncPaymentsContext, duration_since_epoch: Duration, + ) -> bool { + let offer_id = match context { + AsyncPaymentsContext::StaticInvoicePersisted { path_absolute_expiry, offer_id } => { + if duration_since_epoch > path_absolute_expiry { + return false; + } + offer_id + }, + _ => return false, + }; + + let mut offers = self.offers.iter_mut(); + let offer_entry = offers.find(|o| o.as_ref().map_or(false, |o| o.offer.id() == offer_id)); + if let Some(Some(ref mut offer)) = offer_entry { + if offer.status == OfferStatus::Used { + // We succeeded in updating the invoice for a used offer, no re-persistence of the cache + // needed + return false; + } + + offer.status = + OfferStatus::Ready { invoice_confirmed_persisted_at: duration_since_epoch }; + return true; + } + + false + } +} + +impl Writeable for AsyncReceiveOfferCache { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + write_tlv_fields!(w, { + (0, self.offers, required_vec), + (2, self.paths_to_static_invoice_server, required_vec), + // offer paths request retry info always resets on restart + }); + Ok(()) + } +} + +impl Readable for AsyncReceiveOfferCache { + fn read(r: &mut R) -> Result { + _init_and_read_len_prefixed_tlv_fields!(r, { + (0, offers, required_vec), + (2, paths_to_static_invoice_server, required_vec), + }); + let offers: Vec> = offers; + Ok(Self { offers, offer_paths_request_attempts: 0, paths_to_static_invoice_server }) + } +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 38f674141b1..0990251c311 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 { @@ -64,7 +66,10 @@ 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, OfferPaths, OfferPathsRequest, ServeStaticInvoice, + }, + crate::onion_message::messenger::Responder, }; #[cfg(feature = "dnssec")] @@ -98,6 +103,10 @@ where pub(crate) pending_offers_messages: Mutex>, pending_async_payments_messages: Mutex>, + async_receive_offer_cache: Mutex, + /// Blinded paths used to request offer paths from the static invoice server, if we are an async + /// recipient. + paths_to_static_invoice_server: Mutex>, #[cfg(feature = "dnssec")] pub(crate) hrn_resolver: OMNameResolver, @@ -133,9 +142,46 @@ where hrn_resolver: OMNameResolver::new(current_timestamp, best_block.height), #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex::new(Vec::new()), + + async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + paths_to_static_invoice_server: Mutex::new(Vec::new()), } } + /// If we are an async recipient, on startup we'll interactively build offers and static invoices + /// with an always-online node that will serve static invoices on our behalf. Once the offer is + /// built and the static invoice is confirmed as persisted by the server, the underlying + /// [`AsyncReceiveOfferCache`] should be persisted so we remember the offers we've built. + pub(crate) fn with_async_payments_offers_cache( + mut self, async_receive_offer_cache: AsyncReceiveOfferCache, + ) -> Self { + self.paths_to_static_invoice_server = + Mutex::new(async_receive_offer_cache.paths_to_static_invoice_server()); + self.async_receive_offer_cache = Mutex::new(async_receive_offer_cache); + self + } + + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build + /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers + /// on our behalf when we're offline. + /// + /// This method only needs to be called once when the server first takes on the recipient as a + /// client, or when the paths change, e.g. if the paths are set to expire at a particular time. + #[cfg(async_payments)] + pub(crate) fn set_paths_to_static_invoice_server( + &self, paths_to_static_invoice_server: Vec, + ) -> Result<(), ()> { + // Store the paths in the async receive cache so they are persisted with the cache, but also + // store them in-memory in the `OffersMessageFlow` so the flow has access to them when building + // onion messages to send to the static invoice server, without introducing undesirable lock + // dependencies with the cache. + *self.paths_to_static_invoice_server.lock().unwrap() = + paths_to_static_invoice_server.clone(); + + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.set_paths_to_static_invoice_server(paths_to_static_invoice_server) + } + /// Gets the node_id held by this [`OffersMessageFlow`]` fn get_our_node_id(&self) -> PublicKey { self.our_network_pubkey @@ -195,6 +241,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, @@ -1082,4 +1133,322 @@ where ) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap()) } + + /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. Will only + /// return an offer if [`Self::set_paths_to_static_invoice_server`] was called and we succeeded in + /// interactively building a [`StaticInvoice`] with the static invoice server. + #[cfg(async_payments)] + pub(crate) fn get_async_receive_offer(&self) -> Result<(Offer, bool), ()> { + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.get_async_receive_offer(self.duration_since_epoch()) + } + + /// Sends out [`OfferPathsRequest`] and [`ServeStaticInvoice`] onion messages if we are an + /// often-offline recipient and are configured to interactively build offers and static invoices + /// with a static invoice server. + /// + /// # Usage + /// + /// This method should be called on peer connection and once per minute or so, to keep the offers + /// cache updated. When calling this method once per minute, SHOULD set `timer_tick_occurred` so + /// the cache can self-regulate the number of messages sent out. + /// + /// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message. + #[cfg(async_payments)] + pub(crate) fn check_refresh_async_receive_offer_cache( + &self, peers: Vec, usable_channels: Vec, entropy: ES, + router: R, timer_tick_occurred: bool, + ) -> Result<(), ()> + where + ES::Target: EntropySource, + R::Target: Router, + { + // Terminate early if this node does not intend to receive async payments. + if self.paths_to_static_invoice_server.lock().unwrap().is_empty() { + return Ok(()); + } + + let duration_since_epoch = self.duration_since_epoch(); + + // Update the cache to remove expired offers, and check to see whether we need new offers to be + // interactively built with the static invoice server. + let needs_new_offers = self + .async_receive_offer_cache + .lock() + .unwrap() + .prune_expired_offers(duration_since_epoch, timer_tick_occurred); + + // If we need new offers, send out offer paths request messages to the static invoice server. + if needs_new_offers { + let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { + path_absolute_expiry: duration_since_epoch + .saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY), + }); + let reply_paths = match self.create_blinded_paths(peers.clone(), context) { + Ok(paths) => paths, + Err(()) => { + return Err(()); + }, + }; + + // We can't fail past this point, so indicate to the cache that we've requested new offers. + self.async_receive_offer_cache.lock().unwrap().new_offers_requested(); + + let mut pending_async_payments_messages = + self.pending_async_payments_messages.lock().unwrap(); + let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {}); + enqueue_onion_message_with_reply_paths( + message, + &self.paths_to_static_invoice_server.lock().unwrap()[..], + reply_paths, + &mut pending_async_payments_messages, + ); + } + + if timer_tick_occurred { + self.check_refresh_static_invoices( + peers, + usable_channels, + duration_since_epoch, + entropy, + router, + ); + } + + Ok(()) + } + + /// Enqueue onion messages that will used to request invoice refresh from the static invoice + /// server, based on the offers provided by the cache. + #[cfg(async_payments)] + fn check_refresh_static_invoices( + &self, peers: Vec, usable_channels: Vec, + duration_since_epoch: Duration, entropy: ES, router: R, + ) where + ES::Target: EntropySource, + R::Target: Router, + { + let mut serve_static_invoice_msgs = Vec::new(); + { + let cache = self.async_receive_offer_cache.lock().unwrap(); + for offer_and_metadata in cache.offers_needing_invoice_refresh() { + let (offer, offer_nonce, slot_number, update_static_invoice_path) = + offer_and_metadata; + + let (invoice, forward_invreq_path) = match self.create_static_invoice_for_server( + offer, + offer_nonce, + peers.clone(), + usable_channels.clone(), + &*entropy, + &*router, + ) { + Ok((invoice, path)) => (invoice, path), + Err(()) => continue, + }; + + let reply_path_context = { + let path_absolute_expiry = + duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY); + MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted { + path_absolute_expiry, + offer_id: offer.id(), + }) + }; + + let serve_invoice_message = ServeStaticInvoice { + invoice, + forward_invoice_request_path: forward_invreq_path, + invoice_slot: slot_number, + }; + serve_static_invoice_msgs.push(( + serve_invoice_message, + update_static_invoice_path.clone(), + reply_path_context, + )); + } + } + + // Enqueue the new serve_static_invoice messages in a separate loop to avoid holding the offer + // cache lock and the pending_async_payments_messages lock at the same time. + for (serve_invoice_msg, serve_invoice_path, reply_path_ctx) in serve_static_invoice_msgs { + let reply_paths = match self.create_blinded_paths(peers.clone(), reply_path_ctx) { + Ok(paths) => paths, + Err(()) => continue, + }; + + let message = AsyncPaymentsMessage::ServeStaticInvoice(serve_invoice_msg); + enqueue_onion_message_with_reply_paths( + message, + &[serve_invoice_path.into_blinded_path()], + reply_paths, + &mut self.pending_async_payments_messages.lock().unwrap(), + ); + } + } + + /// Handles an incoming [`OfferPaths`] message from the static invoice server, sending out + /// [`ServeStaticInvoice`] onion messages in response if we've built a new async receive offer and + /// need the corresponding [`StaticInvoice`] to be persisted by the static invoice server. + /// + /// Returns `None` if we have enough offers cached already, verification of `message` fails, or we + /// fail to create blinded paths. + #[cfg(async_payments)] + pub(crate) fn handle_offer_paths( + &self, message: OfferPaths, context: AsyncPaymentsContext, responder: Responder, + peers: Vec, usable_channels: Vec, entropy: ES, + router: R, + ) -> Option<(ServeStaticInvoice, MessageContext)> + where + ES::Target: EntropySource, + R::Target: Router, + { + let duration_since_epoch = self.duration_since_epoch(); + match context { + AsyncPaymentsContext::OfferPaths { path_absolute_expiry } => { + if duration_since_epoch > path_absolute_expiry { + return None; + } + }, + _ => return None, + } + + { + // Only respond with `ServeStaticInvoice` if we actually need a new offer built. + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.prune_expired_offers(duration_since_epoch, false); + if !cache.should_build_offer_with_paths( + &message.paths[..], + message.paths_absolute_expiry, + duration_since_epoch, + ) { + return None; + } + } + + let (mut offer_builder, offer_nonce) = + match self.create_async_receive_offer_builder(&*entropy, message.paths) { + Ok((builder, nonce)) => (builder, nonce), + Err(_) => return None, // Only reachable if OfferPaths::paths is empty + }; + if let Some(paths_absolute_expiry) = message.paths_absolute_expiry { + offer_builder = + offer_builder.absolute_expiry(Duration::from_secs(paths_absolute_expiry)); + } + let (offer_id, offer) = match offer_builder.build() { + Ok(offer) => (offer.id(), offer), + Err(_) => { + debug_assert!(false); + return None; + }, + }; + + let (invoice, forward_invoice_request_path) = match self.create_static_invoice_for_server( + &offer, + offer_nonce, + peers, + usable_channels, + &*entropy, + router, + ) { + Ok(res) => res, + Err(()) => return None, + }; + + let res = self.async_receive_offer_cache.lock().unwrap().cache_pending_offer( + offer, + message.paths_absolute_expiry, + offer_nonce, + responder, + duration_since_epoch, + ); + + let invoice_slot = match res { + Ok(idx) => idx, + Err(()) => return None, + }; + + let reply_path_context = { + let path_absolute_expiry = + duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY); + MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted { + offer_id, + path_absolute_expiry, + }) + }; + + let serve_invoice_message = + ServeStaticInvoice { invoice, forward_invoice_request_path, invoice_slot }; + Some((serve_invoice_message, reply_path_context)) + } + + /// Creates a [`StaticInvoice`] and a blinded path for the server to forward invoice requests from + /// payers to our node. + #[cfg(async_payments)] + fn create_static_invoice_for_server( + &self, offer: &Offer, offer_nonce: Nonce, peers: Vec, + usable_channels: Vec, entropy: ES, router: R, + ) -> Result<(StaticInvoice, BlindedMessagePath), ()> + where + ES::Target: EntropySource, + R::Target: Router, + { + let expanded_key = &self.inbound_payment_key; + let duration_since_epoch = self.duration_since_epoch(); + let secp_ctx = &self.secp_ctx; + + let offer_relative_expiry = offer + .absolute_expiry() + .map(|exp| exp.saturating_sub(duration_since_epoch).as_secs()) + .map(|exp_u64| exp_u64.try_into().unwrap_or(u32::MAX)) + .unwrap_or(u32::MAX); + + // Set the invoice to expire at the same time as the offer. We aim to update this invoice as + // often as possible, so there shouldn't be any reason to have it expire earlier than the + // offer. + let payment_secret = inbound_payment::create_for_spontaneous_payment( + expanded_key, + None, // The async receive offers we create are always amount-less + offer_relative_expiry, + duration_since_epoch.as_secs(), + None, + )?; + + let invoice = self + .create_static_invoice_builder( + &router, + &*entropy, + &offer, + offer_nonce, + payment_secret, + offer_relative_expiry, + usable_channels, + peers.clone(), + ) + .and_then(|builder| builder.build_and_sign(secp_ctx)) + .map_err(|_| ())?; + + let nonce = Nonce::from_entropy_source(&*entropy); + let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce }); + let forward_invoice_request_path = self + .create_blinded_paths(peers, context) + .and_then(|paths| paths.into_iter().next().ok_or(()))?; + + Ok((invoice, forward_invoice_request_path)) + } + + /// Handles an incoming [`StaticInvoicePersisted`] onion message from the static invoice server. + /// Returns a bool indicating whether the async receive offer cache needs to be re-persisted. + /// + /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted + #[cfg(async_payments)] + pub(crate) fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool { + let mut cache = self.async_receive_offer_cache.lock().unwrap(); + cache.static_invoice_persisted(context, self.duration_since_epoch()) + } + + /// Get the `AsyncReceiveOfferCache` for persistence. + pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ { + &self.async_receive_offer_cache + } } 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; diff --git a/lightning/src/onion_message/async_payments.rs b/lightning/src/onion_message/async_payments.rs index 7a473c90e8f..1a1f39ff3ad 100644 --- a/lightning/src/onion_message/async_payments.rs +++ b/lightning/src/onion_message/async_payments.rs @@ -9,15 +9,20 @@ //! Message handling for async payments. -use crate::blinded_path::message::AsyncPaymentsContext; +use crate::blinded_path::message::{AsyncPaymentsContext, BlindedMessagePath}; use crate::io; use crate::ln::msgs::DecodeError; +use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::messenger::{MessageSendInstructions, Responder, ResponseInstruction}; use crate::onion_message::packet::OnionMessageContents; use crate::prelude::*; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; // TLV record types for the `onionmsg_tlv` TLV stream as defined in BOLT 4. +const OFFER_PATHS_REQ_TLV_TYPE: u64 = 65538; +const OFFER_PATHS_TLV_TYPE: u64 = 65540; +const SERVE_INVOICE_TLV_TYPE: u64 = 65542; +const INVOICE_PERSISTED_TLV_TYPE: u64 = 65544; const HELD_HTLC_AVAILABLE_TLV_TYPE: u64 = 72; const RELEASE_HELD_HTLC_TLV_TYPE: u64 = 74; @@ -25,6 +30,41 @@ const RELEASE_HELD_HTLC_TLV_TYPE: u64 = 74; /// /// [`OnionMessage`]: crate::ln::msgs::OnionMessage pub trait AsyncPaymentsMessageHandler { + /// Handle an [`OfferPathsRequest`] message. If we are a static invoice server and the message was + /// sent over paths that we previously provided to an async recipient, an [`OfferPaths`] message + /// should be returned. + fn handle_offer_paths_request( + &self, message: OfferPathsRequest, context: AsyncPaymentsContext, + responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)>; + + /// Handle an [`OfferPaths`] message. If this is in response to an [`OfferPathsRequest`] that + /// we previously sent as an async recipient, we should build an [`Offer`] containing the + /// included [`OfferPaths::paths`] and a corresponding [`StaticInvoice`], and reply with + /// [`ServeStaticInvoice`]. + /// + /// [`Offer`]: crate::offers::offer::Offer + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + fn handle_offer_paths( + &self, message: OfferPaths, context: AsyncPaymentsContext, responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)>; + + /// Handle a [`ServeStaticInvoice`] message. If this is in response to an [`OfferPaths`] message + /// we previously sent as a static invoice server, a [`StaticInvoicePersisted`] message should be + /// sent once the message is handled. + fn handle_serve_static_invoice( + &self, message: ServeStaticInvoice, context: AsyncPaymentsContext, + responder: Option, + ); + + /// Handle a [`StaticInvoicePersisted`] message. If this is in response to a + /// [`ServeStaticInvoice`] message we previously sent as an async recipient, then the offer we + /// generated on receipt of a previous [`OfferPaths`] message is now ready to be used for async + /// payments. + fn handle_static_invoice_persisted( + &self, message: StaticInvoicePersisted, context: AsyncPaymentsContext, + ); + /// Handle a [`HeldHtlcAvailable`] message. A [`ReleaseHeldHtlc`] should be returned to release /// the held funds. fn handle_held_htlc_available( @@ -50,6 +90,29 @@ pub trait AsyncPaymentsMessageHandler { /// [`OnionMessage`]: crate::ln::msgs::OnionMessage #[derive(Clone, Debug)] pub enum AsyncPaymentsMessage { + /// A request from an async recipient for [`BlindedMessagePath`]s, sent to a static invoice + /// server. + OfferPathsRequest(OfferPathsRequest), + + /// [`BlindedMessagePath`]s to be included in an async recipient's [`Offer::paths`], sent by a + /// static invoice server in response to an [`OfferPathsRequest`]. + /// + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + OfferPaths(OfferPaths), + + /// A request from an async recipient to a static invoice server that a [`StaticInvoice`] be + /// provided in response to [`InvoiceRequest`]s from payers. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + ServeStaticInvoice(ServeStaticInvoice), + + /// Confirmation from a static invoice server that a [`StaticInvoice`] was persisted and the + /// corresponding [`Offer`] is ready to be used to receive async payments. Sent to an async + /// recipient in response to a [`ServeStaticInvoice`] message. + /// + /// [`Offer`]: crate::offers::offer::Offer + StaticInvoicePersisted(StaticInvoicePersisted), + /// An HTLC is being held upstream for the often-offline recipient, to be released via /// [`ReleaseHeldHtlc`]. HeldHtlcAvailable(HeldHtlcAvailable), @@ -58,6 +121,62 @@ pub enum AsyncPaymentsMessage { ReleaseHeldHtlc(ReleaseHeldHtlc), } +/// A request from an async recipient for [`BlindedMessagePath`]s from a static invoice server. +/// These paths will be used in the async recipient's [`Offer::paths`], so payers can request +/// [`StaticInvoice`]s from the static invoice server. +/// +/// [`Offer::paths`]: crate::offers::offer::Offer::paths +#[derive(Clone, Debug)] +pub struct OfferPathsRequest {} + +/// [`BlindedMessagePath`]s to be included in an async recipient's [`Offer::paths`], sent by a +/// static invoice server in response to an [`OfferPathsRequest`]. +/// +/// [`Offer::paths`]: crate::offers::offer::Offer::paths +#[derive(Clone, Debug)] +pub struct OfferPaths { + /// The paths that should be included in the async recipient's [`Offer::paths`]. + /// + /// [`Offer::paths`]: crate::offers::offer::Offer::paths + pub paths: Vec, + /// The time as seconds since the Unix epoch at which the [`Self::paths`] expire. + pub paths_absolute_expiry: Option, +} + +/// A request from an async recipient to a static invoice server that a [`StaticInvoice`] be +/// provided in response to [`InvoiceRequest`]s from payers. +/// +/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest +#[derive(Clone, Debug)] +pub struct ServeStaticInvoice { + /// The invoice that should be served by the static invoice server. Once this invoice has been + /// persisted, the [`Responder`] accompanying this message should be used to send + /// [`StaticInvoicePersisted`] to the recipient to confirm that the offer corresponding to the + /// invoice is ready to receive async payments. + pub invoice: StaticInvoice, + /// If a static invoice server receives an [`InvoiceRequest`] for a [`StaticInvoice`], they should + /// also forward the [`InvoiceRequest`] to the async recipient so they can respond with a fresh + /// [`Bolt12Invoice`] if the recipient is online at the time. Use this path to forward the + /// [`InvoiceRequest`] to the async recipient. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + pub forward_invoice_request_path: BlindedMessagePath, + /// The "slot" in the static invoice server's database that this invoice should go into. This + /// allows recipients to replace a specific invoice that is stored by the server, which is useful + /// for limiting the number of invoices stored by the server while also keeping all the invoices + /// persisted with the server fresh. + pub invoice_slot: u16, +} + +/// Confirmation from a static invoice server that a [`StaticInvoice`] was persisted and the +/// corresponding [`Offer`] is ready to be used to receive async payments. Sent to an async +/// recipient in response to a [`ServeStaticInvoice`] message. +/// +/// [`Offer`]: crate::offers::offer::Offer +#[derive(Clone, Debug)] +pub struct StaticInvoicePersisted {} + /// An HTLC destined for the recipient of this message is being held upstream. The reply path /// accompanying this onion message should be used to send a [`ReleaseHeldHtlc`] response, which /// will cause the upstream HTLC to be released. @@ -68,6 +187,34 @@ pub struct HeldHtlcAvailable {} #[derive(Clone, Debug)] pub struct ReleaseHeldHtlc {} +impl OnionMessageContents for OfferPaths { + fn tlv_type(&self) -> u64 { + OFFER_PATHS_TLV_TYPE + } + #[cfg(c_bindings)] + fn msg_type(&self) -> String { + "Offer Paths".to_string() + } + #[cfg(not(c_bindings))] + fn msg_type(&self) -> &'static str { + "Offer Paths" + } +} + +impl OnionMessageContents for ServeStaticInvoice { + fn tlv_type(&self) -> u64 { + SERVE_INVOICE_TLV_TYPE + } + #[cfg(c_bindings)] + fn msg_type(&self) -> String { + "Serve Static Invoice".to_string() + } + #[cfg(not(c_bindings))] + fn msg_type(&self) -> &'static str { + "Serve Static Invoice" + } +} + impl OnionMessageContents for ReleaseHeldHtlc { fn tlv_type(&self) -> u64 { RELEASE_HELD_HTLC_TLV_TYPE @@ -82,6 +229,21 @@ impl OnionMessageContents for ReleaseHeldHtlc { } } +impl_writeable_tlv_based!(OfferPathsRequest, {}); + +impl_writeable_tlv_based!(OfferPaths, { + (0, paths, required_vec), + (2, paths_absolute_expiry, option), +}); + +impl_writeable_tlv_based!(ServeStaticInvoice, { + (0, invoice, required), + (2, forward_invoice_request_path, required), + (4, invoice_slot, required), +}); + +impl_writeable_tlv_based!(StaticInvoicePersisted, {}); + impl_writeable_tlv_based!(HeldHtlcAvailable, {}); impl_writeable_tlv_based!(ReleaseHeldHtlc, {}); @@ -90,7 +252,12 @@ impl AsyncPaymentsMessage { /// Returns whether `tlv_type` corresponds to a TLV record for async payment messages. pub fn is_known_type(tlv_type: u64) -> bool { match tlv_type { - HELD_HTLC_AVAILABLE_TLV_TYPE | RELEASE_HELD_HTLC_TLV_TYPE => true, + OFFER_PATHS_REQ_TLV_TYPE + | OFFER_PATHS_TLV_TYPE + | SERVE_INVOICE_TLV_TYPE + | INVOICE_PERSISTED_TLV_TYPE + | HELD_HTLC_AVAILABLE_TLV_TYPE + | RELEASE_HELD_HTLC_TLV_TYPE => true, _ => false, } } @@ -99,6 +266,10 @@ impl AsyncPaymentsMessage { impl OnionMessageContents for AsyncPaymentsMessage { fn tlv_type(&self) -> u64 { match self { + Self::OfferPathsRequest(_) => OFFER_PATHS_REQ_TLV_TYPE, + Self::OfferPaths(msg) => msg.tlv_type(), + Self::ServeStaticInvoice(msg) => msg.tlv_type(), + Self::StaticInvoicePersisted(_) => INVOICE_PERSISTED_TLV_TYPE, Self::HeldHtlcAvailable(_) => HELD_HTLC_AVAILABLE_TLV_TYPE, Self::ReleaseHeldHtlc(msg) => msg.tlv_type(), } @@ -106,6 +277,10 @@ impl OnionMessageContents for AsyncPaymentsMessage { #[cfg(c_bindings)] fn msg_type(&self) -> String { match &self { + Self::OfferPathsRequest(_) => "Offer Paths Request".to_string(), + Self::OfferPaths(msg) => msg.msg_type(), + Self::ServeStaticInvoice(msg) => msg.msg_type(), + Self::StaticInvoicePersisted(_) => "Static Invoice Persisted".to_string(), Self::HeldHtlcAvailable(_) => "Held HTLC Available".to_string(), Self::ReleaseHeldHtlc(msg) => msg.msg_type(), } @@ -113,6 +288,10 @@ impl OnionMessageContents for AsyncPaymentsMessage { #[cfg(not(c_bindings))] fn msg_type(&self) -> &'static str { match &self { + Self::OfferPathsRequest(_) => "Offer Paths Request", + Self::OfferPaths(msg) => msg.msg_type(), + Self::ServeStaticInvoice(msg) => msg.msg_type(), + Self::StaticInvoicePersisted(_) => "Static Invoice Persisted", Self::HeldHtlcAvailable(_) => "Held HTLC Available", Self::ReleaseHeldHtlc(msg) => msg.msg_type(), } @@ -122,6 +301,10 @@ impl OnionMessageContents for AsyncPaymentsMessage { impl Writeable for AsyncPaymentsMessage { fn write(&self, w: &mut W) -> Result<(), io::Error> { match self { + Self::OfferPathsRequest(message) => message.write(w), + Self::OfferPaths(message) => message.write(w), + Self::ServeStaticInvoice(message) => message.write(w), + Self::StaticInvoicePersisted(message) => message.write(w), Self::HeldHtlcAvailable(message) => message.write(w), Self::ReleaseHeldHtlc(message) => message.write(w), } @@ -131,6 +314,10 @@ impl Writeable for AsyncPaymentsMessage { impl ReadableArgs for AsyncPaymentsMessage { fn read(r: &mut R, tlv_type: u64) -> Result { match tlv_type { + OFFER_PATHS_REQ_TLV_TYPE => Ok(Self::OfferPathsRequest(Readable::read(r)?)), + OFFER_PATHS_TLV_TYPE => Ok(Self::OfferPaths(Readable::read(r)?)), + SERVE_INVOICE_TLV_TYPE => Ok(Self::ServeStaticInvoice(Readable::read(r)?)), + INVOICE_PERSISTED_TLV_TYPE => Ok(Self::StaticInvoicePersisted(Readable::read(r)?)), HELD_HTLC_AVAILABLE_TLV_TYPE => Ok(Self::HeldHtlcAvailable(Readable::read(r)?)), RELEASE_HELD_HTLC_TLV_TYPE => Ok(Self::ReleaseHeldHtlc(Readable::read(r)?)), _ => Err(DecodeError::InvalidValue), diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 087a587487a..98fd98d2086 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -9,7 +9,10 @@ //! Onion message testing and test utilities live here. -use super::async_payments::{AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc}; +use super::async_payments::{ + AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ReleaseHeldHtlc, + ServeStaticInvoice, StaticInvoicePersisted, +}; use super::dns_resolution::{ DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, }; @@ -91,6 +94,26 @@ impl OffersMessageHandler for TestOffersMessageHandler { struct TestAsyncPaymentsMessageHandler {} impl AsyncPaymentsMessageHandler for TestAsyncPaymentsMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, + _responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + None + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) { + } + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) { + } fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index fd98f78350e..3b2566119de 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -432,6 +432,12 @@ impl Responder { context: Some(context), } } + + /// Converts a [`Responder`] into its inner [`BlindedMessagePath`]. + #[cfg(async_payments)] + pub(crate) fn into_blinded_path(self) -> BlindedMessagePath { + self.reply_path + } } /// Instructions for how and where to send the response to an onion message. @@ -1938,6 +1944,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