Skip to content

Async recipient-side of static invoice server #3618

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

valentinewallace
Copy link
Contributor

@valentinewallace valentinewallace commented Feb 24, 2025

As part of being an async recipient, we need to interactively build an offer and static invoice with an always-online node that will serve static invoices on our behalf in response to invoice requests from payers.

While users could build this invoice manually, the plan is for LDK to automatically build it for them using onion messages. See this doc for more details on the protocol. Here we implement the client side of the linked protocol.

See lightning/bolts#1149 for more information on async payments.

Partially addresses #2298

@valentinewallace
Copy link
Contributor Author

Will go through the commits in a bit more detail before taking this out of draft, but conceptual feedback or feedback on the protocol itself is welcome, or the way the code is organized overall. It does add a significant amount of code to ChannelManager currently.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, I wonder if we shouldn't allow the client to cache N offers rather than only 1. I worry a bit about the privacy implications of having One Offer that gets reused across different contexts.

@valentinewallace
Copy link
Contributor Author

Hmmm, I wonder if we shouldn't allow the client to cache N offers rather than only 1. I worry a bit about the privacy implications of having One Offer that gets reused across different contexts.

I think that makes sense, so they would interactively build and cache a few and then randomly(?) return one of them on get_cached_async_receive_offer?

It seems reasonable to save for follow-up although I could adapt the AsyncReceiveOffer cache struct serialization for this now.

Comment on lines 12709 to 12714
// Expire the offer at the same time as the static invoice so we automatically refresh both
// at the same time.
let offer_and_invoice_absolute_expiry = Duration::from_secs(core::cmp::min(
offer_paths_absolute_expiry.as_secs(),
duration_since_epoch.saturating_add(STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY).as_secs()
));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I want to address eventually (but maybe not in this PR) is that right now we cap the expiry of our offer/static invoice at 2 weeks, which doesn't work well for the "offer in Twitter bio" use case. Probably we can add something to UserConfig for this, and expose a method for users to proactively rotate their offer if it never expires?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if for that we shouldn't try to come up with a scheme to allow the offer to last longer than the static invoice? I mean ideally an offer lasts at least a few years, but it kinda could cause you just care about the storage server being reliable, you don't care much about the static invoice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. We could include another set of long-lived paths in the OfferPaths message that allows the recipient to refresh their invoice later while keeping the same offer [paths].

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean maybe the OffersPath paths should just be super long-lived? I don't see a strong reason to have some concept of long-lived paths?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if the OfferPaths offer_paths are super long lived, we still need a way for the recipient to update their static invoice later. So the additional paths would be for that purpose, is my thinking.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh vs just having the original paths be long-lived? I guess we could, but it seems like we could just make all the paths long-lived?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. In the current PR, the recipient sends PersistStaticInvoice over the reply path to the OfferPaths message, and that reply path is short-lived.

So we could make that reply path long-lived instead and have the recipient cache that reply path to update their invoice later. Just to confirm, that's what you're suggesting?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, that's what I was thinking. Basically just make it a "multi-reply path"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. It means we won't get extra message attempts over alternate blinded paths, but that might be a premature optimization anyway, hard to tell.

@TheBlueMatt
Copy link
Collaborator

TheBlueMatt commented Feb 25, 2025

I think that makes sense, so they would interactively build and cache a few and then randomly(?) return one of them on get_cached_async_receive_offer?

Yea, I dunno what to do for the fetch'er, maybe we just expose the whole list?

It seems reasonable to save for follow-up although I could adapt the AsyncReceiveOffer cache struct serialization for this now.

Makes sense, tho I imagine it would be a rather trivial diff, no?

@jkczyz jkczyz self-requested a review February 27, 2025 18:10
@valentinewallace valentinewallace added the weekly goal Someone wants to land this this week label Feb 27, 2025
Comment on lines +568 to +592
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to include path_absolute_expiry?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the nonce/IV was sufficient but I'm not certain. @TheBlueMatt would it be an improvement to commit to the expiry in the hmac? IIUC the path still can't be re-purposed...

@valentinewallace valentinewallace marked this pull request as ready for review March 4, 2025 21:14
@valentinewallace
Copy link
Contributor Author

Going to base this on #3640. Will finish updating the ser macros based on those changes and push updates here after finishing some review.

@valentinewallace valentinewallace removed the weekly goal Someone wants to land this this week label Mar 4, 2025
Copy link
Contributor

@vincenzopalazzo vincenzopalazzo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a couple of comments that I find out while working on the CI failure in #3593

@valentinewallace
Copy link
Contributor Author

Pushed some updates after moving the async receive offer cache into the new OffersMessageFlow struct added in #3639.

Copy link
Contributor

@elnosh elnosh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New to the codebase but interested in following async payments. From reading the explanation in the commit messages, the protocol/flow between the async recipient and the always-online node to build the static invoice and offer made sense. Overall the code changes look good to me.

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from 5455d55 to f8023ca Compare May 21, 2025 00:11
@valentinewallace
Copy link
Contributor Author

Rebased on the latest version of #3639

Copy link

codecov bot commented May 21, 2025

Codecov Report

Attention: Patch coverage is 29.76190% with 118 lines in your changes missing coverage. Please review.

Project coverage is 89.84%. Comparing base (b8673f3) to head (ae93200).
Report is 25 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/channelmanager.rs 21.53% 51 Missing ⚠️
lightning/src/onion_message/async_payments.rs 0.00% 29 Missing ⚠️
lightning/src/onion_message/functional_tests.rs 0.00% 20 Missing ⚠️
lightning/src/ln/peer_handler.rs 0.00% 17 Missing ⚠️
lightning/src/offers/async_receive_offer_cache.rs 95.45% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3618      +/-   ##
==========================================
- Coverage   89.88%   89.84%   -0.04%     
==========================================
  Files         162      164       +2     
  Lines      130415   131443    +1028     
  Branches   130415   131443    +1028     
==========================================
+ Hits       117217   118094     +877     
- Misses      10509    10658     +149     
- Partials     2689     2691       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch 3 times, most recently from d1cc154 to 68fd751 Compare May 22, 2025 22:52
@valentinewallace
Copy link
Contributor Author

Pushed some minor fixes for CI.

@valentinewallace
Copy link
Contributor Author

valentinewallace commented Jun 2, 2025

Clarified a few more docs I noticed. Link to diff

Edit: pushed another CI fix

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from 66d7b4f to 6adac0b Compare June 2, 2025 18:01
Copy link
Contributor

@joostjager joostjager left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, this is well executed. However, as we also discussed offline, I still can't shake the feeling that this approach may be too far ahead of where we are right now.

The use case is spontaneous payments of variable amounts to offline recipients. If the current state of Lightning is LSPs with client nodes without support for blinded paths, and this is intended for that environment, then there must be a simpler way to achieve the same goal. Ultimately, the payer only needs to know the LSP node and the recipient node. With that information, they can send an onion message to the LSP, wait for the release, and then send the HTLC.

@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit message reads very well. Some of that valuable info might even be moved into code comments?

/// 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 fn get_cached_async_receive_offers(&self) -> Vec<Offer> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests in the follow up look good indeed.

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from 6adac0b to 128208f Compare June 4, 2025 20:11
@valentinewallace
Copy link
Contributor Author

Rebased after #3767 landed, haven't addressed feedback yet

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from 128208f to 4f8d15c Compare June 5, 2025 22:48
@valentinewallace
Copy link
Contributor Author

valentinewallace commented Jun 5, 2025

Mostly updated, need to fix linting. I added fixups for a few things to call attention/add a short explanation. Diff

Edit: fixed linting, diff

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from 4f8d15c to 6fe5f86 Compare June 5, 2025 23:00
Comment on lines +250 to +261
// Update the invoice if it expires in less than a day, as long as the offer has a longer
// expiry than that.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for reviewers: I'm not sure it makes sense to only update a static invoice if it expires in less than a day. That doesn't seem like enough time. But it only lives for 2 weeks anyway... Should we maybe make it longer lived, or replace it once it expires in a week, or?

Copy link
Contributor

@joostjager joostjager left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did another pass. Still not at the bottom of this PR in terms of understanding all the details though.

@@ -14264,6 +14266,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)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cache seems so far away from channel manager core responsibilities. Can't it be persisted elsewhere / separately?

I also thought that for fixing the force closures, we wanted to move away from channel mgr persistence completely and reconstruct everything from the monitors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it crossed my mind that this might come up when we address removing ChannelManager persistence :( Will start an offline discussion for this next week, not sure what the best solution is. We could have a separate method to retrieve the cache for persistence. Don't think this should be a blocker since it's an optional field that can easily be removed, but it'd be nice to have a path forward.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that it can be removed easily. And also that it is nice to have an idea about where to take this eventually.

fn write<W: Writer>(&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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reasoning in https://github.com/lightningdevkit/rust-lightning/pull/3819/files#r2126342283 also applies here. In this code base, do we want to separate runtime state from persistent state, or does it not matter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really following that reasoning tbh, we have other places in the codebase where things reset on restart within persisted state.

@@ -12842,7 +12842,30 @@ where
fn handle_offer_paths(
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
None
#[cfg(async_payments)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I am still surprised that this lives in channelmanager. Didn't look into the flow refactoring yet, but I thought the goal was to keep high level stuff like offers out of chan mgr?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, from my view this this just calls into the flow so nothing substantial is really living in ChannelManager. The manager still receives offers messages in general because it needs to add to the outbound_payments module when we initiate a payment to an offer, but the bulk of the logic now lives in the OffersMessageFlow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is true indeed, makes sense then. Perhaps payments and offers as a while could be more separate from low-level channel management, but that is probably a whole different thing.

@valentinewallace
Copy link
Contributor Author

Addressed feedback with this diff

@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from bfab61c to 9c3ae3d Compare June 9, 2025 18:01
Copy link
Contributor

@joostjager joostjager left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left comments, but moving towards final review stages.

@@ -14264,6 +14266,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)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that it can be removed easily. And also that it is nice to have an idea about where to take this eventually.

@@ -1082,4 +1104,9 @@ where
) -> Vec<(DNSResolverMessage, MessageSendInstructions)> {
core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap())
}

/// Get the `AsyncReceiveOfferCache` for persistence.
pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if there is more persistent data expected around offers in the future, but perhaps OffersMessageFlow could be made serializable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Historically we've treated offers messages as ephemeral, i.e. users have to start a new payment on restart if their node shut down while exchanging offers messages but before locking in an HTLC. AFAIK, we don't plan on changing that, meaning there shouldn't be any reason to serialize the OffersMessageFlow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But there is now because of the cache?

nonce: Nonce,
/// Authentication code for the [`OfferPaths`] message.
///
/// Prevents nodes from creating their own blinded path to us and causing us to cache an
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Us' is again the recipient here right?

I thought the nonce is already the authentication? Maybe explain why an hmac is also necessary. I am still missing some context on the onion message reply system, so maybe this is general knowledge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'Us' is again the recipient here right?

Fixed these docs!

The nonce is a unique input into the hmac, while the hmac is the actual authentication primitive. Generally you'd never want to just use a nonce for authentication, that's what an hmac (hash-based message authentication code) is for.

self.async_receive_offer_cache
.lock()
.unwrap()
.new_offers_requested(duration_since_epoch);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By locking twice in this fn, can there be a race condition that for example pushes offer request attempts above the max?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly, but it doesn't really matter if we send extra requests because we'll ignore any redundant paths/offers that we receive (and on the server's end, creating + sending new paths is very cheap). My thinking here is not to risk introducing a lock order dependency by holding the lock for any longer than necessary.

// 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];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain why adding static data to the hmacs is necessary? There is already something message specific in the IV

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not clear why the IV isn't sufficient :( Just being consistent with the rest of the hmac methods here. It looks like the additional static data is needed in the MetadataMaterial::derive_metadata method, so we probably just do this for consistency?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't the original author of this clarify?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is related to MetadataMaterial::derive_metadata. That is for the {payer_}metadata included in Offers and InvoiceRequests. It's probably not strictly necessary as per #3192 (comment). (cc: @TheBlueMatt)

// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the case, doesn't the recipient enter a loop where they keep re-requesting and get no new paths every time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you be more specific about the scenario you're thinking of? If we get here, either we don't need any more offers (in which case we won't keep re-requesting paths either), or the paths we're receiving from the server expire too soon. In the latter case we will keep requesting offer paths but not sure what else we can do in that situation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking of the scenario where the server only ever returns the same paths, and we still want to fill up the cache with a number of offers. Maybe this doesn't make sense?

{
let should_persist = self.flow.handle_static_invoice_persisted(_context);
if should_persist {
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of taking the total_consistency_lock in channel manager now? Or should, because the cache is part of channel manager, that lock have been obtained earlier, and always when manipulating the cache? Make it part of the 'lock order tree'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, why would we introduce lock order deps between the OffersMessageFlow and the manager? Not sure I follow...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why even take a lock on channel manager at all then here? Only need to fire the notification. Maybe I am mistaking?

@valentinewallace
Copy link
Contributor Author

Updated in response to feedback. Github isn't giving me a nice diff link so made a pastebin: https://pastebin.com/JyLVcLWZ

@@ -884,6 +886,14 @@ pub struct UserConfig {
///
/// Default value: `false`
pub enable_dual_funded_channels: bool,
/// [`BlindedMessagePath`]s to reach an always-online node that will serve [`StaticInvoice`]s on
/// our behalf. Useful if we are an often-offline recipient that wants to receive async payments.
/// Payers will send [`InvoiceRequest`]s over these paths, and receive a [`StaticInvoice`] in
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be used to send the OffersPathRequest not actually placed in Offers?

/// [`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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these not belong in a new handler? handling HTLC freeing is a fairly separate concept from serving static invoices, and ISTM it would be reasonable for someone to want that to be a separate thing. I guess I can see an argument for the client side of this logic being in the same handler (tho even that not sure of), but the service side seems weird.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a separate concept, but in what situation would async recipients not use the static invoice part of the protocol? Server side maybe, but afaik we don't plan on supporting receiving async payments without also using the static invoice protocol so I'm not sure what this would accomplish besides more trait parameters for ChannelManager.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, going to keep the existing trait as-is for now because the server still needs access to channels to create blinded paths. It's not ideal to keep throwing everything in ChannelManager, but it'd require a larger refactor to get around it in this case.

Comment on lines 101 to 107
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_BUFFER: Duration = Duration::from_secs(3 * 60 * 60);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, while an initial burst makes sense, waiting 3 hours to send the next burst is a longggg time if the wallet can't build offers during that time. Instead, does it make sense to implement some kind of trivial exponential backoff with a max (which honestly I'd put in the 10 minute range, not 3 hours)? I worry that we'll send the burst and hit some temporary network disruption and then not be able to generate an offer, in that context even a few seconds would be too long for the initial re-request.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 hours was mainly to add a buffer for no-std users. Would exponential backoff with 10 minutes work for them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline -- going to add a timer_tick_occurred method to OffersMessageFlow and just request new paths once a minute for several hours, maybe with some backoff.

// 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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand why we want to go with a %-of-life approach vs just hard-coding a naive "request N-before expiry". The advantage of a naive approach is that we always get a regular time before expiry to request (eg if we're getting static invoices that only last a month, we still want to update them a week before they expire because we're often offline for a week, and the same applies even if the static invoice lasts a year). The risk, of course, is that we pick a constant conservatively (eg 2 weeks? a month?) and then we are aggressively refreshing constantly. We could set some requirement that we always keep an offer for an hour or something in that case, I suppose, but AFAICT we currently always just set offers to expire whenever the Static Invoice Server tells us the paths expire, and we can just reject paths that expire too quickly 🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, gonna go with this approach.

For context:
The expectation is for users to come online no more often than once a month. So we'll require any offer paths we receive to last at least 3 months (or so), and refresh invoices more aggressively, possibly every time we come online with some limits, and/or whenever a channel opens or closes. Then when an offer is expiring within a month, we'll build a new one with the server and send it to them.

///
/// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
nonce: Nonce,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to go ahead and remove the auth tokens in anticipation of #3845?

Some(offer.offer.clone())
}
})
.collect()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since this is pub(crate) and we don't have to worry about bindings, why not just return impl Iterator instead of collect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently this PR is more or less set up for static invoice protocol support to be exposed for non-ChannelManager OffersMessageFlowUsers in the future. So to maintain that, I can avoid the collect here and then add back it in the OffersMessageFlow instead, let me know if that's off.

///
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
#[derive(Clone, Debug)]
pub struct ServeStaticInvoice {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this need some kind of authentication token? While we could say that ever wallet needs to fetch unique paths_to_static_invoice_servers and then the server can authenticate all downstream messages from that, it seems like it'd be much simpler to include a public key in OfferPathsRequest (and maybe an opaque wallet_id?) and sign the ServeStaticInvoice with it so that servers can trivially authenticate invoices and tie them together by user (or was this deliberately left out? I'm not sure what the anti-DoS story is otherwise tho).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently we require every wallet to have unique paths_to_static_invoice_servers, and the server includes the auth token for a particular user in those paths (see #3628). If the server doesn't supply unique paths per-wallet, how do they know which wallet is supplying a particular public key in the OfferPathsRequest? 🤔 In the follow-up the server threads the auth token from the original unique paths through each message so they always know which user they're receiving a message from.

pub(super) fn should_build_offer_with_paths(
&self, message: &OfferPaths, duration_since_epoch: Duration,
) -> bool {
if !self.needs_new_offers(duration_since_epoch) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely convinced that we need this? If we got new paths, build a new offer, why not (with the one exception that we already have an offer with the exact paths or the message was replayed so the offers we have have newer paths)? There's some argument that always replying to a OfferPaths message with a ServeStaticInvoice message gives the static invoice server the ability to ask if the often-offline client is currently online, which is maybe bad? But in the usual case they're peers so its not exactly important.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking is that the server only wants to service a limited number of offers on our behalf, and they may silently drop support for some of our offers if we send them too many, so it'd be nice to limit the number of offers we send them to begin with. It seems like there could be major UX issues if the recipient and the server aren't on the same page with what offers are available for payments, so this seems a nudge towards ensuring that doesn't happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, gonna add a few more fields to the offer paths message from the server so they can indicate how many invoices they're willing to store on the recipient's behalf. Then if we get new paths, we can see if there's room for another invoice on the server's end and send them a new offer/invoice based on that.

Comment on lines +1254 to +1255
// We can't fail past this point, so indicate to the cache that we've requested an invoice
// update.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead should we have the server ACK the serve message? Seems worth doing IMO?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server does ACK it, this is in the follow-up #3628

/// 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<ES: Deref, R: Deref>(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we prefer to have static invoices that expire sooner than the issued offers? I mean I guess its good to support it, but its a bunch of machinery to, and its not clear to me why we'd ever have that - the offers are the things that have expire-able data (in the form of the blinded paths in them), the static invoices much less so, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally had the invoices and offers expiring at the same time, I thought you requested this lol #3618 (comment). I'm happy to get rid of it but I see the invoice's payment paths as much more likely than the offer's message paths to become outdated because they contain fee/channel info, let me know what I'm missing there.

As part of being an async recipient, we need to support interactively building
an offer and static invoice with an always-online node that will serve static
invoices on our behalf.

Add a config field containing blinded message paths that async recipients can
use to request blinded paths that will be included in their offer. Payers will
forward invoice requests over the paths returned by the server, and receive a
static invoice in response if the recipient is offline.
Because async recipients are not online to respond to invoice requests,
the plan is for another node on the network that is always-online to serve
static invoices on their behalf.

The protocol is as follows:
- Recipient is configured with blinded message paths to reach the static invoice
  server
- On startup, recipient requests blinded message paths for inclusion in their
  offer from the static invoice server over the configured paths
- Server replies with offer paths for the recipient
- Recipient builds their offer using these paths and the corresponding static
  invoice and replies with the invoice
- Server persists the invoice and confirms that they've persisted it, causing
  the recipient to cache the interactively built offer for use

At pay-time, the payer sends an invoice request to the static invoice server,
who replies with the static invoice after forwarding the invreq to the
recipient (to give them a chance to provide a fresh invoice in case they're
online).

Here we add the requisite trait methods and onion messages to support this
protocol.

An alterate design could be for the async recipient to publish static invoices
directly without a preceding offer, e.g. on their website. Some drawbacks of
this design include:
1) No fallback to regular BOLT 12 in the case that the recipient happens to be online
at pay-time. Falling back to regular BOLT 12 allows the recipient to provide a fresh
invoice and regain the proof-of-payment property
2) Static invoices don't fit in a QR code
3) No automatic rotation of the static invoice, which is useful in the case that payment
paths become outdated due to changing fees, etc
In future commits, as part of being an async recipient, we will interactively
build offers and static invoices with an always-online node that will serve
static invoices on our behalf.

Once an offer is built and the static invoice is confirmed as persisted by the
server, we will use the new offer cache added here to save the invoice metadata
and the offer in ChannelManager, though the OffersMessageFlow is responsible
for keeping the cache updated.

We want to cache and persist these offers so we always have them at the ready,
we don't want to begin the process of interactively building an offer the
moment it is needed. The offers are likely to be long-lived so caching them
avoids having to keep interactively rebuilding them after every restart.
As an async recipient, we need to interactively build static invoices that an
always-online node will serve to payers on our behalf.

At the start of this process, we send a requests for paths to include in our
offers to the always-online node on startup and refresh the cached offers when
they expire.
As an async recipient, we need to interactively build a static invoice that an
always-online node will serve to payers on our behalf.

As part of this process, the static invoice server sends us blinded message
paths to include in our offer so they'll receive invoice requests from senders
trying to pay us while we're offline. On receipt of these paths, create an
offer and static invoice and send the invoice back to the server so they can
provide the invoice to payers.
As an async recipient, we need to interactively build a static invoice that an
always-online node will serve on our behalf.

Once this invoice is built and persisted by the static invoice server, they
will send us a confirmation onion message. At this time, cache the
corresponding offer and mark it as ready to receive async payments.
As an async recipient, we need to interactively build offers and corresponding
static invoices, the latter of which an always-online node will serve to payers
on our behalf.

Offers may be very long-lived and have a longer expiration than their
corresponding static invoice. Therefore, persist a fresh invoice with the
static invoice server when the current invoice gets close to expiration.
Over the past several commits we've implemented interactively building an async
receive offer with a static invoice server that will service invoice requests
on our behalf as an async recipient.

Here we add an API to retrieve the resulting offers so we can receive payments
when we're offline.
@valentinewallace valentinewallace force-pushed the 2025-02-static-inv-server-client branch from 17f3f5a to ae93200 Compare June 12, 2025 15:18
@valentinewallace
Copy link
Contributor Author

Rebased on due to conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants