Add currency conversion support for BOLT 12 offers#3833
Add currency conversion support for BOLT 12 offers#3833shaavan wants to merge 8 commits intolightningdevkit:mainfrom
Conversation
|
👋 Thanks for assigning @jkczyz as a reviewer! |
|
cc @jkczyz |
|
🔔 1st Reminder Hey @joostjager! This PR has been waiting for your review. |
|
Is this proposed change a response to a request from a specific user/users? |
|
Hi @joostjager! This PR is actually a continuation of the original thread that led to the The motivation behind it was to provide users with the ability to handle So, as a first step, we worked on refactoring most of the Offers-related code out of 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! |
|
Another use case is Fedimint, where they'll want to include their own payment hash in the |
Does Fedimint plan to use the |
I believe with one. |
|
🔔 2nd Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 3rd Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 4th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 5th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 6th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 7th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 8th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 9th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 10th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
🔔 11th Reminder Hey @joostjager! This PR has been waiting for your review. |
|
Removing @joostjager for now to stop bot spam. @shaavan and I have been working through some variations of this approach. |
vincenzopalazzo
left a comment
There was a problem hiding this comment.
Concept ACK for me
I was just looking around to sync with this Offer Flow
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3833 +/- ##
==========================================
+ Coverage 89.34% 89.37% +0.02%
==========================================
Files 180 180
Lines 138480 140045 +1565
Branches 138480 140045 +1565
==========================================
+ Hits 123730 125164 +1434
- Misses 12129 12295 +166
+ Partials 2621 2586 -35
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| .inner | ||
| .offer | ||
| .resolve_offer_amount(currency_conversion)? | ||
| .ok_or(Bolt12SemanticError::UnsupportedCurrency)?; |
There was a problem hiding this comment.
Bug: Wrong error variant. When resolve_offer_amount returns Ok(None) (meaning the offer has no amount set at all), this maps it to Bolt12SemanticError::UnsupportedCurrency. The correct error is Bolt12SemanticError::MissingAmount — the issue is that neither the invoice request nor the offer specifies an amount, not that currency conversion failed.
| .ok_or(Bolt12SemanticError::UnsupportedCurrency)?; | |
| .ok_or(Bolt12SemanticError::MissingAmount)?; |
| pub(crate) fn amount_msats<CC: CurrencyConversion>( | ||
| invoice_request: &InvoiceRequest, currency_conversion: &CC, | ||
| ) -> Result<u64, Bolt12SemanticError> { | ||
| match invoice_request.contents.inner.amount_msats() { | ||
| Some(amount_msats) => Ok(amount_msats), | ||
| None => match invoice_request.contents.inner.offer.amount() { | ||
| Some(Amount::Bitcoin { amount_msats }) => amount_msats | ||
| .checked_mul(invoice_request.quantity().unwrap_or(1)) | ||
| .ok_or(Bolt12SemanticError::InvalidAmount), | ||
| Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), | ||
| None => Err(Bolt12SemanticError::MissingAmount), | ||
| }, | ||
| let quantity = invoice_request.quantity().unwrap_or(1); | ||
| let requested_msats = invoice_request.amount_msats(currency_conversion)?; | ||
|
|
||
| let minimum_offer_msats = match invoice_request | ||
| .resolve_offer_amount(currency_conversion)? | ||
| { | ||
| Some(unit_msats) => Some( | ||
| unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)?, | ||
| ), | ||
| None => None, | ||
| }; | ||
|
|
||
| if let Some(minimum) = minimum_offer_msats { | ||
| if requested_msats < minimum { | ||
| return Err(Bolt12SemanticError::InsufficientAmount); | ||
| } | ||
| } | ||
|
|
||
| if requested_msats > MAX_VALUE_MSAT { | ||
| return Err(Bolt12SemanticError::InvalidAmount); | ||
| } | ||
|
|
||
| Ok(requested_msats) | ||
| } |
There was a problem hiding this comment.
TOCTOU risk: This method calls currency_conversion twice independently — once via invoice_request.amount_msats(currency_conversion) at line 405, and again via invoice_request.resolve_offer_amount(currency_conversion) at line 408. If the CurrencyConversion implementation returns live exchange rates, the two calls may get different rates, causing the requested_msats < minimum check at line 417 to operate on inconsistent data.
Consider resolving the offer amount once and reusing it, or documenting that CurrencyConversion implementations must be stable within a single call context.
lightning/src/offers/currency.rs
Outdated
| /// Returns the acceptable tolerance, expressed as a percentage, used when | ||
| /// deriving conversion ranges. | ||
| /// | ||
| /// This represents a user-level policy (e.g., allowance for exchange-rate | ||
| /// drift or cached data) and does not directly affect fiat-to-msat conversion | ||
| /// outside of range computation. | ||
| fn tolerance_percent(&self) -> u8; |
There was a problem hiding this comment.
tolerance_percent() is defined in the trait but never called anywhere in the codebase. It's dead code in this PR. If the intent is for downstream code to use it, consider documenting where/how, or remove it until it's actually needed to keep the API surface minimal.
lightning/src/ln/channelmanager.rs
Outdated
| match verified_invreq | ||
| .amount_msats(&self.flow.currency_conversion) | ||
| { | ||
| if payment_data.total_msat < invreq_amt_msat { | ||
| Ok(invreq_amt_msat) => { | ||
| if payment_data.total_msat < invreq_amt_msat { | ||
| fail_htlc!(claimable_htlc, payment_hash); | ||
| } |
There was a problem hiding this comment.
At HTLC claim time, amount_msats() resolves the currency-denominated offer amount using the current exchange rate, which may differ significantly from the rate used when the invoice was created. This could cause legitimate payments to be failed (if the rate increased) or allow underpayments (if the rate decreased).
For currency-denominated offers where the invoice request has no explicit amount_msats set by a remote payer, the check here becomes rate-dependent in a way that may not match the invoice amount. The comment at lines 8551-8558 acknowledges this can only happen for our own offers, but the logic should ideally compare against the invoice's amount_msats (which was locked in at invoice creation) rather than re-resolving from the exchange rate.
| match offer.check_amount_msats_for_quantity(&DefaultCurrencyConversion, amount, quantity) { | ||
| // If the offer amount is currency-denominated, we intentionally skip the | ||
| // amount check here, as currency conversion is not available at this stage. | ||
| // The corresponding validation is performed when handling the Invoice Request, | ||
| // i.e., during InvoiceBuilder creation. | ||
| Ok(()) | Err(Bolt12SemanticError::UnsupportedCurrency) => (), | ||
| Err(err) => return Err(err), | ||
| } |
There was a problem hiding this comment.
This catch of UnsupportedCurrency is fragile: it silently swallows any UnsupportedCurrency error, but this error can also be produced by into_msats when the currency conversion legitimately fails (not just because DefaultCurrencyConversion doesn't support it). If in the future DefaultCurrencyConversion starts supporting some currencies, the semantics of this catch change silently.
Consider using a distinct sentinel (e.g., a dedicated method like is_currency_denominated()) to check whether to skip validation, rather than catching a specific error variant that has multiple possible causes.
fuzz/src/invoice_request_deser.rs
Outdated
| fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result<u64, ()> { | ||
| unreachable!() | ||
| } | ||
|
|
||
| fn tolerance_percent(&self) -> u8 { | ||
| unreachable!() | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Using unreachable!() in a fuzz target is dangerous — if the fuzzer ever manages to construct an input that reaches this code (e.g., a valid currency-denominated offer), it will panic and be treated as a crash/finding rather than gracefully handling the case. Consider returning Err(()) and 0 instead, matching the pattern used in FuzzCurrencyConversion in full_stack.rs.
fuzz/src/offer_deser.rs
Outdated
| let payment_id = PaymentId([1; 32]); | ||
| let conversion = DefaultCurrencyConversion; | ||
|
|
||
| let mut builder = offer.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)?; |
There was a problem hiding this comment.
Bug: Missing currency_conversion argument. request_invoice now requires a &CC parameter as the last argument, but &conversion is not passed here. This won't compile.
| let mut builder = offer.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)?; | |
| let mut builder = offer.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion)?; |
lightning/src/ln/channelmanager.rs
Outdated
| /// fee_estimator, chain_monitor, tx_broadcaster, router, message_router, currency_conversion, | ||
| /// logger, entropy_source, node_signer, signer_provider, config.clone(), params, current_timestamp, |
There was a problem hiding this comment.
Nit: Mixed indentation — line 2101 uses spaces, line 2102 uses a tab. Should be consistent.
| /// fee_estimator, chain_monitor, tx_broadcaster, router, message_router, currency_conversion, | |
| /// logger, entropy_source, node_signer, signer_provider, config.clone(), params, current_timestamp, | |
| /// fee_estimator, chain_monitor, tx_broadcaster, router, message_router, currency_conversion, | |
| /// logger, entropy_source, node_signer, signer_provider, config.clone(), params, current_timestamp, |
Review SummaryThis is a second-pass review of PR #3833. Most significant issues were identified in my prior review. This pass confirms those findings and adds two new inline comments. New inline comments posted:
Previously flagged issues (from prior review — not repeated inline):
Cross-cutting concerns:
|
|
🔔 7th Reminder Hey @jkczyz! This PR has been waiting for your review. |
|
🔔 8th Reminder Hey @jkczyz! This PR has been waiting for your review. |
|
🔔 9th Reminder Hey @jkczyz! This PR has been waiting for your review. |
|
🔔 10th Reminder Hey @jkczyz! This PR has been waiting for your review. |
|
🔔 11th Reminder Hey @jkczyz! This PR has been waiting for your review. |
|
Updated .13 → .14 Changes:
|
| if let Some(ref invoice_request) = invoice_request { | ||
| let requested_amount_msats = invoice_request | ||
| .amount_msats(&self.currency_conversion) | ||
| .map_err(|_| Bolt12PaymentError::UnexpectedInvoice)?; | ||
| // A returned invoice quotes the amount the payee expects to receive. Make | ||
| // sure it matches the payer's locally expected amount before recording the | ||
| // invoice as received or initiating payment. | ||
| if invoice.amount_msats() != requested_amount_msats { | ||
| return Err(Bolt12PaymentError::UnexpectedInvoice); | ||
| } |
There was a problem hiding this comment.
Bug: For currency-denominated offers where the payer's invoice request does not set an explicit amount_msats, this exact-match check (!=) will almost always reject the invoice. Here's why:
- The payer sends an invoice request without an explicit
amount_msats(because the offer is currency-denominated and the payer defers amount resolution to the payee). - The payee resolves the currency amount at their exchange rate and sets the invoice amount.
- When
check_bolt12_invoice_amountruns on the payer side,invoice_request.amount_msats(&self.currency_conversion)resolves the same currency amount at the payer's current exchange rate, which will differ from the payee's rate. - The
!=check rejects the invoice because two independent rate lookups will almost never match exactly.
For case (2), you likely want a tolerance-based comparison (e.g., using the tolerance_percent from the trait), or skip the check when the invoice request has no explicit payer-provided amount_msats and the offer is currency-denominated.
| if let Some(ref invoice_request) = invoice_request { | |
| let requested_amount_msats = invoice_request | |
| .amount_msats(&self.currency_conversion) | |
| .map_err(|_| Bolt12PaymentError::UnexpectedInvoice)?; | |
| // A returned invoice quotes the amount the payee expects to receive. Make | |
| // sure it matches the payer's locally expected amount before recording the | |
| // invoice as received or initiating payment. | |
| if invoice.amount_msats() != requested_amount_msats { | |
| return Err(Bolt12PaymentError::UnexpectedInvoice); | |
| } | |
| if let Some(ref invoice_request) = invoice_request { | |
| if invoice_request.has_amount_msats() { | |
| let requested_amount_msats = invoice_request | |
| .amount_msats(&self.currency_conversion) | |
| .map_err(|_| Bolt12PaymentError::UnexpectedInvoice)?; | |
| // A returned invoice quotes the amount the payee expects to receive. Make | |
| // sure it matches the payer's locally expected amount before recording the | |
| // invoice as received or initiating payment. | |
| if invoice.amount_msats() != requested_amount_msats { | |
| return Err(Bolt12PaymentError::UnexpectedInvoice); | |
| } | |
| } | |
| } |
Add a `CurrencyConversion` trait for resolving currency-denominated amounts into millisatoshis. LDK cannot supply exchange rates itself, so applications provide this conversion logic as the foundation for fiat-denominated offer support. Co-Authored-By: OpenAI Codex <codex@openai.com>
Exercise the core currency-to-msat conversion helper directly. Cover supported conversions, unsupported currencies, and non-finite conversion outputs so the new trait has focused unit coverage. Co-Authored-By: OpenAI Codex <codex@openai.com>
Store the `CurrencyConversion` implementation on `ChannelManager`. This keeps the conversion dependency owned by the higher-level payment state machine while leaving `OffersMessageFlow` focused on message coordination and path construction. Co-Authored-By: OpenAI Codex <codex@openai.com>
Allow `OfferBuilder` to set currency-denominated amounts when the caller provides a `CurrencyConversion` implementation. Move amount validation into the setters so fiat offers are checked when they are configured and `build()` no longer repeats that work. Co-Authored-By: OpenAI Codex <codex@openai.com>
Retain a `CurrencyConversion` reference on `InvoiceRequestBuilder`. Use that reference for both explicit payer-provided amounts and offer-derived amount lookups, while passing conversion explicitly through `OffersMessageFlow` from `ChannelManager`. Requests that omit `amount_msats` now stay omitted during request construction. The payee resolves those amounts later when building the invoice, so request-time checks only validate explicit payer-supplied amounts. Co-Authored-By: OpenAI Codex <codex@openai.com>
Extend invoice request parsing tests for currency-denominated offers to cover explicit serialized msat amounts. Verify that well-formed explicit amounts still parse and out-of-range values are rejected. Co-Authored-By: OpenAI Codex <codex@openai.com>
Use CurrencyConversion while responding to an InvoiceRequest. This lets payees resolve currency-denominated offer amounts into the millisatoshi amount carried by the invoice. Requests that omit amount_msats remain omitted, and the invoice preserves the original request bytes instead of normalizing them into an explicit request amount. Deterministically-resolvable omitted request amounts are still validated before signing. On the payer side, the returned invoice amount is checked against the locally expected amount before payment starts. Receiver-side lower-bound checks still apply only to explicit payer-provided amounts, and async static invoices bind their payment secrets to the resolved offer amount when one exists. Co-Authored-By: OpenAI Codex <codex@openai.com>
Add targeted tests for the currency-denominated offer and invoice handling paths introduced by the new conversion flow. Cover end-to-end offer flows, parsed fiat request amounts below an offer minimum, deferred invoice replay identity, and async amount resolution failures. This keeps the invoice-quoting change pinned across its success and rejection paths without spreading closely related test updates across multiple commits. Co-Authored-By: OpenAI Codex <codex@openai.com>
|
Rebased .14 → .15 |
| let amount_msats = match $self.invoice_request.amount_msats { | ||
| Some(amount_msats) => Some(amount_msats), | ||
| None => match $self.invoice_request.offer.resolve_offer_amount($self.currency_conversion) { | ||
| Ok(Some(unit_msats)) => { | ||
| let quantity = $self.invoice_request.quantity.unwrap_or(1); | ||
| Some( | ||
| unit_msats | ||
| .checked_mul(quantity) | ||
| .filter(|amount_msats| *amount_msats <= MAX_VALUE_MSAT) | ||
| .ok_or(Bolt12SemanticError::InvalidAmount)?, | ||
| ) | ||
| }, | ||
| Ok(None) | Err(Bolt12SemanticError::UnsupportedCurrency) => None, | ||
| Err(err) => return Err(err), | ||
| }, | ||
| }; | ||
| if let Some(amount_msats) = amount_msats { | ||
| // Preserve the omitted amount on the wire, while still rejecting requests whose | ||
| // effective amount is already deterministically invalid at signing time. | ||
| $self.invoice_request.offer.check_amount_msats_for_quantity( | ||
| $self.currency_conversion, Some(amount_msats), $self.invoice_request.quantity | ||
| )?; | ||
| } |
There was a problem hiding this comment.
Bug: Double currency conversion with potential TOCTOU. When the payer omits amount_msats and the offer is currency-denominated:
- Line 291:
resolve_offer_amount(currency_conversion)is called → returnsunit_msats(conversion try! instead of ? #1) - Line 308:
check_amount_msats_for_quantity(currency_conversion, ...)is called → internally callsresolve_offer_amount(currency_conversion)again (conversion Fix typos #2) to obtain the minimum
If the CurrencyConversion implementation returns live exchange rates, the second call may get a different rate than the first. If the rate increased between calls, the resolved amount from call #1 could be less than the minimum from call #2, causing a spurious InsufficientAmount error even though both values derive from the same offer amount.
Consider passing the already-resolved amount_msats to skip the redundant conversion:
| let amount_msats = match $self.invoice_request.amount_msats { | |
| Some(amount_msats) => Some(amount_msats), | |
| None => match $self.invoice_request.offer.resolve_offer_amount($self.currency_conversion) { | |
| Ok(Some(unit_msats)) => { | |
| let quantity = $self.invoice_request.quantity.unwrap_or(1); | |
| Some( | |
| unit_msats | |
| .checked_mul(quantity) | |
| .filter(|amount_msats| *amount_msats <= MAX_VALUE_MSAT) | |
| .ok_or(Bolt12SemanticError::InvalidAmount)?, | |
| ) | |
| }, | |
| Ok(None) | Err(Bolt12SemanticError::UnsupportedCurrency) => None, | |
| Err(err) => return Err(err), | |
| }, | |
| }; | |
| if let Some(amount_msats) = amount_msats { | |
| // Preserve the omitted amount on the wire, while still rejecting requests whose | |
| // effective amount is already deterministically invalid at signing time. | |
| $self.invoice_request.offer.check_amount_msats_for_quantity( | |
| $self.currency_conversion, Some(amount_msats), $self.invoice_request.quantity | |
| )?; | |
| } | |
| let amount_msats = match $self.invoice_request.amount_msats { | |
| Some(amount_msats) => Some(amount_msats), | |
| None => match $self.invoice_request.offer.resolve_offer_amount($self.currency_conversion) { | |
| Ok(Some(unit_msats)) => { | |
| let quantity = $self.invoice_request.quantity.unwrap_or(1); | |
| Some( | |
| unit_msats | |
| .checked_mul(quantity) | |
| .filter(|amount_msats| *amount_msats <= MAX_VALUE_MSAT) | |
| .ok_or(Bolt12SemanticError::InvalidAmount)?, | |
| ) | |
| }, | |
| Ok(None) | Err(Bolt12SemanticError::UnsupportedCurrency) => None, | |
| Err(err) => return Err(err), | |
| }, | |
| }; | |
| if let Some(amount_msats) = amount_msats { | |
| // Preserve the omitted amount on the wire, while still rejecting requests whose | |
| // effective amount is already deterministically invalid at signing time. | |
| if amount_msats > MAX_VALUE_MSAT { | |
| return Err(Bolt12SemanticError::InvalidAmount); | |
| } | |
| } |
| if let Some(ref invoice_request) = invoice_request { | ||
| let requested_amount_msats = invoice_request | ||
| .amount_msats(&self.currency_conversion) | ||
| .map_err(|_| Bolt12PaymentError::UnexpectedInvoice)?; | ||
| // A returned invoice quotes the amount the payee expects to receive. Make | ||
| // sure it matches the payer's locally expected amount before recording the | ||
| // invoice as received or initiating payment. | ||
| if invoice.amount_msats() != requested_amount_msats { | ||
| return Err(Bolt12PaymentError::UnexpectedInvoice); | ||
| } | ||
| } |
There was a problem hiding this comment.
Bug: This check has two failure modes for currency-denominated offers:
Case 1 (payer can't convert at all): build_with_checks at invoice_request.rs:301 tolerates UnsupportedCurrency and creates an invoice request without amount_msats. But here, invoice_request.amount_msats(...) tries to resolve from the offer and fails with UnsupportedCurrency, which maps to UnexpectedInvoice. The payment was allowed to start but can never complete.
Case 2 (payer converts at a different rate): Both nodes can convert, but the payer resolves the offer amount at their local rate while the payee used their own rate for the invoice. The strict != comparison rejects all invoices where the rates don't match exactly, which is virtually guaranteed for live exchange rates.
Both cases leave the payment stuck in AwaitingInvoice with no user-facing event. Consider:
| if let Some(ref invoice_request) = invoice_request { | |
| let requested_amount_msats = invoice_request | |
| .amount_msats(&self.currency_conversion) | |
| .map_err(|_| Bolt12PaymentError::UnexpectedInvoice)?; | |
| // A returned invoice quotes the amount the payee expects to receive. Make | |
| // sure it matches the payer's locally expected amount before recording the | |
| // invoice as received or initiating payment. | |
| if invoice.amount_msats() != requested_amount_msats { | |
| return Err(Bolt12PaymentError::UnexpectedInvoice); | |
| } | |
| } | |
| if let Some(ref invoice_request) = invoice_request { | |
| if invoice_request.has_amount_msats() { | |
| // When the payer explicitly set amount_msats, it was validated at | |
| // build time and must match the invoice exactly. | |
| let requested_amount_msats = invoice_request | |
| .amount_msats(&self.currency_conversion) | |
| .map_err(|_| Bolt12PaymentError::UnexpectedInvoice)?; | |
| if invoice.amount_msats() != requested_amount_msats { | |
| return Err(Bolt12PaymentError::UnexpectedInvoice); | |
| } | |
| } | |
| // When the payer omitted amount_msats (e.g. for currency-denominated | |
| // offers), the payee resolved the amount. We cannot reliably re-derive | |
| // the same value, so we trust the payee's quote. | |
| } |
|
🔔 12th Reminder Hey @jkczyz! This PR has been waiting for your review. |
This PR adds support for currency-denominated Offers in LDK’s BOLT 12 offer-handling flow.
Previously, Offers could only specify their amount in millisatoshis. However, BOLT 12 allows Offers to be denominated in other currencies such as fiat. Supporting this requires converting those currency amounts into millisatoshis at runtime when validating payments and constructing invoices.
Because exchange rates are external, time-dependent, and application-specific, LDK cannot perform these conversions itself. Instead, this PR introduces a
CurrencyConversiontrait which allows applications to provide their own logic for resolving currency-denominated amounts into millisatoshis. LDK remains exchange-rate agnostic and simply invokes this trait whenever a currency amount must be resolved.To make this conversion logic available throughout the BOLT 12 flow,
OffersMessageFlowis parameterized over aCurrencyConversionimplementation and the abstraction is threaded through the offer handling pipeline.With this in place:
OfferBuildercan now create Offers whose amounts are denominated in currencies instead of millisatoshis•
InvoiceRequesthandling can resolve Offer amounts when validating requests•
InvoiceBuilderenforces that the final invoice amount satisfies the Offer’s requirements after resolving any currency denominationCurrency validation is intentionally deferred until invoice construction when necessary, keeping earlier stages focused on structural validation while ensuring the final payable amount is correct.
Tests are added to cover the complete Offer → InvoiceRequest → Invoice flow when the original Offer amount is specified in a currency.