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..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}; @@ -404,6 +406,84 @@ 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 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. @@ -486,6 +566,21 @@ 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), + }, + (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 50ef6d70464..f0f4e53fe86 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -86,6 +86,7 @@ use crate::ln::outbound_payment::{ SendAlongPathArgs, StaleExpiration, }; use crate::ln::types::ChannelId; +use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::OffersMessageFlow; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, @@ -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::{ @@ -5137,6 +5139,23 @@ where ) } + #[cfg(async_payments)] + fn check_refresh_async_receive_offers(&self) { + let peers = self.get_peers_for_blinded_path(); + 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, + "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, @@ -7131,6 +7150,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. @@ -10720,9 +10742,23 @@ where #[cfg(c_bindings)] create_refund_builder!(self, RefundMaybeWithDerivedMetadataBuilder); + /// Retrieve our cached [`Offer`]s for receiving async payments as an often-offline recipient. + /// Will only be set if [`UserConfig::paths_to_static_invoice_server`] is set and we succeeded in + /// interactively building a [`StaticInvoice`] with the static invoice server. + /// + /// Useful for posting offers to receive payments later, such as posting an offer on a website. + #[cfg(async_payments)] + pub fn get_cached_async_receive_offers(&self) -> Vec { + self.flow.get_cached_async_receive_offers() + } + /// Create an offer for receiving async payments as an often-offline recipient. /// - /// Because we may be offline when the payer attempts to request an invoice, you MUST: + /// Instead of using this method, it is preferable to set + /// [`UserConfig::paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_cached_async_receive_offers`]. + /// + /// If you want to build the [`StaticInvoice`] manually using this method instead, you MUST: /// 1. Provide at least 1 [`BlindedMessagePath`] terminating at an always-online node that will /// serve the [`StaticInvoice`] created from this offer on our behalf. /// 2. Use [`Self::create_static_invoice_builder`] to create a [`StaticInvoice`] from this @@ -10739,6 +10775,10 @@ where /// Creates a [`StaticInvoiceBuilder`] from the corresponding [`Offer`] and [`Nonce`] that were /// created via [`Self::create_async_receive_offer_builder`]. If `relative_expiry` is unset, the /// invoice's expiry will default to [`STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY`]. + /// + /// Instead of using this method to manually build the invoice, it is preferable to set + /// [`UserConfig::paths_to_static_invoice_server`] and retrieve the automatically built offer via + /// [`Self::get_cached_async_receive_offers`]. #[cfg(async_payments)] pub fn create_static_invoice_builder<'a>( &self, offer: &'a Offer, offer_nonce: Nonce, relative_expiry: Option, @@ -11652,6 +11692,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 } @@ -12995,6 +13042,60 @@ 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, + }; + 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, @@ -13856,6 +13957,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(()) @@ -14429,6 +14531,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), @@ -14446,6 +14549,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); @@ -15132,6 +15236,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/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 a989d172687..c036a2b82aa 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -30,7 +30,7 @@ use crate::util::ser::{VecWriter, Writeable, Writer}; use crate::ln::peer_channel_encryptor::{PeerChannelEncryptor, NextNoiseStep, MessageBuf, MSG_BUF_ALLOC_SIZE}; use crate::ln::wire; use crate::ln::wire::{Encode, Type}; -use crate::onion_message::async_payments::{AsyncPaymentsMessageHandler, HeldHtlcAvailable, ReleaseHeldHtlc}; +use crate::onion_message::async_payments::{AsyncPaymentsMessageHandler, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, ReleaseHeldHtlc, StaticInvoicePersisted}; use crate::onion_message::dns_resolution::{DNSResolverMessageHandler, DNSResolverMessage, DNSSECProof, DNSSECQuery}; use crate::onion_message::messenger::{CustomOnionMessageHandler, Responder, ResponseInstruction, MessageSendInstructions}; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; @@ -148,6 +148,23 @@ impl OffersMessageHandler for IgnoringMessageHandler { } } impl AsyncPaymentsMessageHandler for IgnoringMessageHandler { + fn handle_offer_paths_request( + &self, _message: OfferPathsRequest, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(OfferPaths, ResponseInstruction)> { + None + } + fn handle_offer_paths( + &self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option, + ) -> Option<(ServeStaticInvoice, ResponseInstruction)> { + None + } + fn handle_serve_static_invoice( + &self, _message: ServeStaticInvoice, _context: AsyncPaymentsContext, + _responder: Option, + ) {} + fn handle_static_invoice_persisted( + &self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext, + ) {} fn handle_held_htlc_available( &self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext, _responder: Option, diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs new file mode 100644 index 00000000000..9407f399f10 --- /dev/null +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -0,0 +1,421 @@ +// 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; +#[cfg(async_payments)] +use { + crate::blinded_path::message::AsyncPaymentsContext, crate::offers::offer::OfferId, + crate::onion_message::async_payments::OfferPaths, +}; + +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), + } + } +} + +// 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; + +// 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)] +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)] +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. + 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 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 + // 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); + } + + /// 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. + /// + /// 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 { + 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..d030ddf8b29 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 { @@ -63,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, + 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")] @@ -98,6 +106,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 +145,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 @@ -195,6 +223,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 +1115,350 @@ where ) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { 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. + /// + /// # 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, 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() { + 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.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(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(), + ); + } + + 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. + /// + /// 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)) + } + + /// 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/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/offers/signer.rs b/lightning/src/offers/signer.rs index 329b90d2076..66aa1d4229e 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -55,6 +55,16 @@ 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]; + +// 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)] @@ -570,3 +580,51 @@ 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) +} + +#[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) +} + +#[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(()) + } +} 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..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. @@ -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 diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index e98b237691c..472ab464316 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -10,8 +10,10 @@ //! Various user-configurable channel limits and settings which ChannelManager //! applies for you. +use crate::blinded_path::message::BlindedMessagePath; use crate::ln::channel::MAX_FUNDING_SATOSHIS_NO_WUMBO; use crate::ln::channelmanager::{BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT}; +use crate::prelude::*; #[cfg(fuzzing)] use crate::util::ser::Readable; @@ -923,6 +925,14 @@ pub struct UserConfig { /// /// Default value: `false` pub enable_dual_funded_channels: bool, + /// [`BlindedMessagePath`]s to reach an always-online node that will serve [`StaticInvoice`]s on + /// our behalf. Useful if we are an often-offline recipient that wants to receive async payments. + /// Payers will send [`InvoiceRequest`]s over these paths, and receive a [`StaticInvoice`] in + /// response from the always-online node. + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + pub paths_to_static_invoice_server: Vec, } impl Default for UserConfig { @@ -937,6 +947,7 @@ impl Default for UserConfig { accept_intercept_htlcs: false, manually_handle_bolt12_invoices: false, enable_dual_funded_channels: false, + paths_to_static_invoice_server: Vec::new(), } } } @@ -957,6 +968,7 @@ impl Readable for UserConfig { accept_intercept_htlcs: Readable::read(reader)?, manually_handle_bolt12_invoices: Readable::read(reader)?, enable_dual_funded_channels: Readable::read(reader)?, + paths_to_static_invoice_server: Vec::new(), }) } } diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 737a558946e..3bb47bfdb25 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1080,6 +1080,7 @@ impl_for_vec!(crate::chain::channelmonitor::ChannelMonitorUpdate); impl_for_vec!(crate::ln::channelmanager::MonitorUpdateCompletionAction); impl_for_vec!(crate::ln::channelmanager::PaymentClaimDetails); impl_for_vec!(crate::ln::msgs::SocketAddress); +impl_for_vec!(crate::blinded_path::message::BlindedMessagePath); impl_for_vec!((A, B), A, B); impl_for_vec!(SerialId); impl_for_vec!(InteractiveTxInput);