-
Notifications
You must be signed in to change notification settings - Fork 411
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
base: main
Are you sure you want to change the base?
Async recipient-side of static invoice server #3618
Conversation
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 |
There was a problem hiding this 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.
I think that makes sense, so they would interactively build and cache a few and then randomly(?) return one of them on It seems reasonable to save for follow-up although I could adapt the |
lightning/src/ln/channelmanager.rs
Outdated
// 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() | ||
)); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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].
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
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.
Yea, I dunno what to do for the fetch'er, maybe we just expose the whole list?
Makes sense, tho I imagine it would be a rather trivial diff, no? |
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); |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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...
Going to base this on #3640. Will finish updating the ser macros based on those changes and push updates here after finishing some review. |
There was a problem hiding this 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
5cf3585
to
5455d55
Compare
Pushed some updates after moving the async receive offer cache into the new |
There was a problem hiding this 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.
5455d55
to
f8023ca
Compare
Rebased on the latest version of #3639 |
Codecov ReportAttention: Patch coverage is
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. 🚀 New features to boost your workflow:
|
d1cc154
to
68fd751
Compare
Pushed some minor fixes for CI. |
Clarified a few more docs I noticed. Link to diff Edit: pushed another CI fix |
66d7b4f
to
6adac0b
Compare
There was a problem hiding this 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, |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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.
6adac0b
to
128208f
Compare
Rebased after #3767 landed, haven't addressed feedback yet |
128208f
to
4f8d15c
Compare
4f8d15c
to
6fe5f86
Compare
// Update the invoice if it expires in less than a day, as long as the offer has a longer | ||
// expiry than that. |
There was a problem hiding this comment.
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?
There was a problem hiding this 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)), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)] |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
6fe5f86
to
bfab61c
Compare
Addressed feedback with this diff |
bfab61c
to
9c3ae3d
Compare
There was a problem hiding this 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)), |
There was a problem hiding this comment.
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 + '_ { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 Offer
s and InvoiceRequest
s. 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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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'
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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?
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 |
There was a problem hiding this comment.
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 Offer
s?
/// [`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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 🤷♂️
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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_server
s 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).
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
// We can't fail past this point, so indicate to the cache that we've requested an invoice | ||
// update. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
17f3f5a
to
ae93200
Compare
Rebased on due to conflicts |
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