Skip to content

Conversation

shaavan
Copy link
Member

@shaavan shaavan commented Jun 7, 2025

This PR addresses two current limitations in the LDK offer-handling flow:

  1. InvoiceRequest messages cannot be intercepted, inspected, or handled manually before responding.
  2. Offers denominated in currencies other than msats are not supported.

To solve this, we introduce a new FlowEvents interface that enables asynchronous handling of InvoiceRequests, allowing developers to inspect, validate, or delay invoice generation as needed.

We also parameterize the invoice-building flow with a CurrencyConversion trait, enabling developers to inject custom conversion logic and support offers denominated in fiat or other currencies. Developers can also supply a custom payment hash if needed.

Together, these enhancements give developers greater control and flexibility over how invoices are constructed and how offers are processed—especially in use cases involving multiple currencies or asynchronous workflows.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Jun 7, 2025

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignemnt, please click here.

@shaavan
Copy link
Member Author

shaavan commented Jun 7, 2025

cc @jkczyz

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@joostjager
Copy link
Contributor

Is this proposed change a response to a request from a specific user/users?

@shaavan
Copy link
Member Author

shaavan commented Jun 11, 2025

Hi @joostjager!

This PR is actually a continuation of the original thread that led to the OffersMessageFlow: link to thread.

The motivation behind it was to provide users with the ability to handle InvoiceRequests asynchronously—just like we already allow for Bolt12Invoices. However, adding more events into the middle of the ChannelManager flow felt suboptimal.

So, as a first step, we worked on refactoring most of the Offers-related code out of ChannelManager into the new OffersMessageFlow (#3639). Now that the refactor is complete, this PR picks up the original goal again: to let users asynchronously handle both InvoiceRequests and Invoices. This not only gives them more flexibility in analyzing these Offer messages, but also opens the door for creating custom interfaces—for example, to support Offers in different currency denominations.

Hope that gives a clear picture of the intent behind this! Let me know if you have any thoughts or suggestions—would love to hear them. Thanks a lot!

@jkczyz
Copy link
Contributor

jkczyz commented Jun 11, 2025

Another use case is Fedimint, where they'll want to include their own payment hash in the Bolt12Invoice.

@valentinewallace
Copy link
Contributor

Another use case is Fedimint, where they'll want to include their own payment hash in the Bolt12Invoice.

Does Fedimint plan to use the OffersMessageFlow without a ChannelManager?

@jkczyz
Copy link
Contributor

jkczyz commented Jun 11, 2025

Does Fedimint plan to use the OffersMessageFlow without a ChannelManager?

I believe with one.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 3rd Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 4th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 5th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 6th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 7th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 8th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 9th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 10th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 11th Reminder

Hey @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@jkczyz jkczyz removed the request for review from joostjager July 2, 2025 13:38
@jkczyz
Copy link
Contributor

jkczyz commented Jul 2, 2025

Removing @joostjager for now to stop bot spam. @shaavan and I have been working through some variations of this approach.

amount_source,
};

self.enqueue_offers_event(event)?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Just one comment - is native async in the picture too for this? We've converted other parts of LDK to be native async.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hi Joost! In pr3833.02, I’ve scoped this PR down to focus solely on introducing synchronous currency conversion support.

We’re still working through some open questions around the asynchronous path, including whether we can or want to go fully native async, so I’ve left that out for now to keep the scope clean. I’ll keep you posted as the design direction evolves.

Appreciate the nudge, thanks again!

/// A variant of [`InvoiceBuilder`] that indicates how the signing public key was set.
///
/// This is not exported to bindings users as builder patterns don't map outside of move semantics.
pub enum InvoiceBuilderVariant<'a> {
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably want to explain also in the docs what explicit and derived mean from an LDK point of view?

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.

Concept ACK for me

I was just looking around to sync with this Offer Flow

@shaavan shaavan changed the title Introduce Event Model for Offers Flow Introduce Synchronous Currency Conversion Support in Offers Aug 2, 2025
Copy link

codecov bot commented Aug 2, 2025

Codecov Report

❌ Patch coverage is 93.81107% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.92%. Comparing base (61e5819) to head (e087b63).

Files with missing lines Patch % Lines
lightning/src/offers/invoice_request.rs 87.23% 6 Missing ⚠️
lightning/src/ln/channelmanager.rs 66.66% 5 Missing ⚠️
lightning/src/offers/invoice.rs 97.61% 4 Missing and 1 partial ⚠️
lightning/src/util/test_utils.rs 50.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3833      +/-   ##
==========================================
- Coverage   88.94%   88.92%   -0.03%     
==========================================
  Files         174      174              
  Lines      124201   124409     +208     
  Branches   124201   124409     +208     
==========================================
+ Hits       110472   110626     +154     
- Misses      11251    11300      +49     
- Partials     2478     2483       +5     
Flag Coverage Δ
fuzzing 22.62% <9.00%> (-0.02%) ⬇️
tests 88.74% <93.81%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

@shaavan
Copy link
Member Author

shaavan commented Aug 2, 2025

Updated from pr3833.01 to pr3833.02 (diff):

Changes:

  • Narrows the scope of the PR to introduce only the synchronous CurrencyConversion trait, isolating it from broader architectural changes. See updated description for rationale.
  • Removes the asynchronous event-model integration, which will be explored in a future PR.

/// to millisatoshis, handling any potential conversion errors.
pub trait CurrencyConversion {
/// Converts a fiat currency specified by its ISO 4217 code to millisatoshis.
fn fiat_to_msats(&self, iso4217_code: CurrencyCode) -> Result<u64, Bolt12SemanticError>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's make sure this is well documented. The conversion needs to the adjust for the ISO 4217 currency exponent. For example, a conversion from USD needs to be in terms of msats per cent (i.e., 1/100 of a dollar) not per dollar.

@@ -1876,7 +1895,12 @@ mod tests {
.unwrap()
.build_and_sign()
.unwrap()
.respond_with_no_std(payment_paths.clone(), payment_hash, now)
.respond_with_no_std(
&DefaultCurrencyConversion {},
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we define an exchange_rate test utility to avoid (most) line wrappings?

shaavan added 13 commits August 15, 2025 23:41
In the upcoming commits, we will be phasing the current style of
VerifiedInvoiceRequest, in favour of newer version.
To keep the changes modular, and clean we rename the current
VerifiedInvoiceRequest to VerifiedInvoiceRequestLegacy.
In the following commits we will introduce `fields` function
for other types as well, so to keep code DRY we convert the
function to a macro.
This commit reintroduces `VerifiedInvoiceRequest`, now parameterized by
`SigningPubkeyStrategy`.

The key motivation is to restrict which functions can be called on a
`VerifiedInvoiceRequest` based on its strategy type. This enables
compile-time guarantees — ensuring that an incorrect `InvoiceBuilder`
cannot be constructed for a given request, and misuses are caught early.
This commit replaces the legacy `VerifiedInvoiceRequestLegacy`
with the new `InvoiceRequestVerifiedFromOffer` type in the codebase.
This change improves type safety and architectural clarity
by introducing dedicated `InvoiceBuilder` methods tied to
each variant of `VerifiedInvoiceRequestEnum`.

With this change, users are now required to match on the
enum variant before calling the corresponding builder method.
This pushes the responsibility of selecting the correct
builder to the user and ensures that invalid builder
usage is caught at compile time, rather than relying
on runtime checks.

The signing logic has also been moved from the builder
to the `ChannelManager`. This shift simplifies the
builder's role and aligns it with the rest of the API,
where builder methods return a configurable object that
can be extended before signing. The result is a more
consistent and predictable interface that separates
concerns cleanly and makes future maintenance easier.
To ensure correct Bolt12 payment flow behavior, the `amount_msats`
used for generating the `payment_hash`, `payment_secret`,
and payment path must remain consistent. Previously, these steps
could inadvertently diverge due to separate sources of `amount_msats`.

This commit refactors the interface to use a `get_payment_info` closure,
which captures the required variables and provides a single source of
truth for both payment info (payment_hash, payment_secret) and path
generation. This ensures consistency and eliminates subtle bugs
that could arise from mismatched amounts across the flow.
Adds the `CurrencyConversion` trait to allow users to define custom
logic for converting fiat amounts into millisatoshis (msat).

This abstraction lays the groundwork for supporting Offers denominated
in fiat currencies, where conversion is inherently context-dependent.
This commit updates the Bolt12Invoice amount creation logic to utilize
the `CurrencyConversion` trait, enabling more flexible and customizable
handling of fiat-to-msat conversions.

Reasoning

The `CurrencyConversion` trait is passed upstream into the invoice's amount
creation flow, where it is used to interpret the Offer’s currency amount
(if present) into millisatoshis.

This change establishes a unified mechanism for amount handling—regardless
of whether the Offer’s amount is denominated in Bitcoin or fiat, or whether
the InvoiceRequest specifies an amount or not.
We introduce this check in pay_for_offer, to ensure that
if the offer amount is specified in currency, a corresponding amount
to be used in invoice request must be provided.

**Reasoning:** When responding to an offer with currency, we enforce
that the invoice request must always include an amount. This ensures we
never receive an invoice tied to a currency-denominated offer without
a corresponding request amount.

By moving currency conversion upfront into the invoice request creation
where the user can supply their own conversion logic — we avoid pushing
conversion concerns into invoice parsing. This significantly reduces
complexity during invoice verification.
Previously, the `enqueue_invoice` function in the `Flow` component
accepted a `Refund` as input and dispatched the invoice either directly
to a known `PublicKey` or via `BlindedMessagePath`s, depending on what
was available within the `Refund`.

While this worked for the refund-based flow, it tightly coupled invoice
dispatch logic to the `Refund` abstraction, limiting its general
usability outside of that context.

The upcoming commits will introduce support for constructing and
enqueuing invoices from manually handled `InvoiceRequest`s—decoupled
from the `Refund` flow. To enable this, we are preemptively introducing
more flexible, destination-specific variants of the enqueue function.

Specifically, the `Flow` now exposes two dedicated methods:

- `enqueue_invoice_using_node_id`: For sending an invoice directly to a
  known `PublicKey`.
- `enqueue_invoice_using_reply_paths`: For sending an invoice over a
  set of explicitly provided `BlindedMessagePath`s.

This separation improves clarity, enables reuse in broader contexts,
and lays the groundwork for more composable invoice handling across the
Offers/Refund flow.
Adds an API to send an `InvoiceError` to the counterparty via the flow.

This becomes useful with the introduction of Flow events in upcoming
commits, where the user can choose to either respond to Offers Messages
or return an `InvoiceError`.

Note:
Given the small scope of changes in this commit, we also take the
opportunity to perform minor documentation cleanups in `flow.rs`.
Until now, offers messages were processed internally without exposing
intermediate steps. This made it harder for callers to intercept or
analyse offer messages before deciding how to respond to them.

`FlowEvents` provide an optional mechanism to surface these events back
to the user. With events enabled, the caller can manually inspect an
incoming message, choose to construct and sign an invoice, or send back
an InvoiceError. This shifts control to the user where needed, while
keeping the default automatic flow unchanged.
@shaavan
Copy link
Member Author

shaavan commented Aug 22, 2025

Updated from pr3833.02 to pr3833.03 (diff)

Addressed @jkczyz comments

Changes:

@shaavan shaavan changed the title Introduce Synchronous Currency Conversion Support in Offers Support Currency-Based Offers and Async Invoice Handling via FlowEvents Aug 22, 2025
let (builder, _) = alice
.node
.flow
.create_invoice_builder_from_invoice_request_with_keys(
Copy link
Contributor

Choose a reason for hiding this comment

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

Just to be completely clear - is this the place where the strong types introduced in #3964 make a difference?

And to understand it better, if the keys Option would still have existed, and there would have been a single create_invoice_builder_from_invoice_request method that switches based on whether keys are present that is called here, would anything go wrong?

Isn't a slightly different runtime check now needed to pattern match the FlowEvent btw (above), to get the stronger type?

Copy link
Member Author

Choose a reason for hiding this comment

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

Discussed offline, but summarizing here for completeness.

In main, the flow looked like this:

  • ChannelManager calls OffersMessageFlow’s builder without checking whether keys are present.
  • OffersMessageFlow decides at runtime which method to call: respond_using_derived_keys if Some(key), or respond_with if None.
  • The respond_using... methods then verify at runtime whether they were correctly called. Reference:
    let keys = match $self.keys {
    None => return Err(Bolt12SemanticError::InvalidMetadata),
    Some(keys) => keys,
    };

This was fine in the native (LDK with ChannelManager) synchronous case, where OffersMessageFlow is only used internally by ChannelManager, and there's no risk of misuse from external callers.

However, this PR introduces support for FlowEvents and allows manual responses to InvoiceRequests using OffersMessageFlow. That creates an entry point, for the native case where we now want compile-time guarantees that the correct builder is used with the correct key type.

So the updated flow becomes:

  • At runtime: the caller (ChannelManager or a user) chooses which OffersMessageFlow builder to call.
  • At compile time: the builder enforces that the correct key-type combo is passed to respond_using....
  • At compile time: respond_using... requires the correct type and no longer needs internal runtime checks.

This preserves behavior from main for the synchronous ChannelManager case, while providing stronger type safety for:

  1. Users manually creating InvoiceRequest responses via FlowEvent, and
  2. Users building on top of OffersMessageFlow without ChannelManager (i.e., non-native LDK usage).

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