Skip to content

Support Currency-Based Offers and Async Invoice Handling via FlowEvents #3833

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 13 commits into
base: main
Choose a base branch
from

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.

Comment on lines 421 to 423
Some(Amount::Currency { iso4217_code, amount }) => {
let unit_msats = currency_conversion.fiat_to_msats(iso4217_code)? * amount;
unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)
Copy link
Contributor

Choose a reason for hiding this comment

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

The multiplication of currency_conversion.fiat_to_msats(iso4217_code) * amount could overflow before the subsequent checked_mul(quantity) catches it. Consider using checked_mul for both operations to ensure safe arithmetic:

let unit_msats = currency_conversion.fiat_to_msats(iso4217_code)
    .checked_mul(amount)
    .ok_or(Bolt12SemanticError::InvalidAmount)?;
unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)

This guards against overflow in both multiplication steps.

Suggested change
Some(Amount::Currency { iso4217_code, amount }) => {
let unit_msats = currency_conversion.fiat_to_msats(iso4217_code)? * amount;
unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)
Some(Amount::Currency { iso4217_code, amount }) => {
let unit_msats = currency_conversion.fiat_to_msats(iso4217_code)?
.checked_mul(amount).ok_or(Bolt12SemanticError::InvalidAmount)?;
unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)

Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.

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 also chain all calls using and_then where needed.

@@ -2502,6 +2518,7 @@ pub struct ChannelManager<
F::Target: FeeEstimator,
R::Target: Router,
MR::Target: MessageRouter,
CC::Target: CurrencyConversion,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not quite remembering our conversations, but why do we need to add a type parameter to ChannelManager? I thought we could have methods on OffersMessageFlow that take a CurrencyConversion and have ChannelManager pass it DefaultCurrencyConversion. Then if a user wants currency conversion, they can request events and pass in their own CurrencyConversion.

where
MR::Target: MessageRouter,
CC::Target: CurrencyConversion,
Copy link
Contributor

Choose a reason for hiding this comment

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

Likewise here. Though I seem to recall chatting about an option where OffersMessageFlow is parameterized by a CurrencyConversion like you have it, but it also would have methods that can be passed a CurrencyConversion for use in the asynchronous event model. In that world, ChannelManager would not be parameterized and would instead hardcode its OffersMessageFlow with DefaultCurrencyConversion.

created_at: Duration, payment_hash: PaymentHash, signing_pubkey: PublicKey,
) -> Result<Self, Bolt12SemanticError> {
let amount_msats = Self::amount_msats(invoice_request)?;
pub(super) fn for_offer<CC: core::ops::Deref>(
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 import Deref.

pub fn respond_with(
&$self, payment_paths: Vec<BlindedPaymentPath>, payment_hash: PaymentHash
) -> Result<$builder, Bolt12SemanticError> {
pub fn respond_with<CC: core::ops::Deref>(
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 import Deref here, too.

Comment on lines 421 to 423
Some(Amount::Currency { iso4217_code, amount }) => {
let unit_msats = currency_conversion.fiat_to_msats(iso4217_code)? * amount;
unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)
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 also chain all calls using and_then where needed.

Comment on lines 578 to 584
/// A trait for converting fiat currencies into millisatoshi values.
///
/// This is used for handling conversions between fiat currencies and Bitcoin denominated in millisatoshis
/// when working with Bolt12 invoice requests.
///
/// Implementors must provide a method to convert from a specified fiat currency (using ISO 4217 currency codes)
/// to millisatoshis, handling any potential conversion errors.
Copy link
Contributor

Choose a reason for hiding this comment

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

Please wrap all documentation at 100 characters.

/// 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
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