Skip to content

Add currency conversion support for BOLT 12 offers#3833

Open
shaavan wants to merge 8 commits intolightningdevkit:mainfrom
shaavan:currency
Open

Add currency conversion support for BOLT 12 offers#3833
shaavan wants to merge 8 commits intolightningdevkit:mainfrom
shaavan:currency

Conversation

@shaavan
Copy link
Copy Markdown
Member

@shaavan shaavan commented Jun 7, 2025

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 CurrencyConversion trait 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, OffersMessageFlow is parameterized over a CurrencyConversion implementation and the abstraction is threaded through the offer handling pipeline.

With this in place:

  • OfferBuilder can now create Offers whose amounts are denominated in currencies instead of millisatoshis

InvoiceRequest handling can resolve Offer amounts when validating requests

InvoiceBuilder enforces that the final invoice amount satisfies the Offer’s requirements after resolving any currency denomination

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

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Jun 7, 2025

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Jun 7, 2025

cc @jkczyz

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 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
Copy Markdown
Contributor

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

@shaavan
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown

🔔 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
Copy Markdown
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.

Copy link
Copy Markdown
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
@codecov
Copy link
Copy Markdown

codecov bot commented Aug 2, 2025

Codecov Report

❌ Patch coverage is 90.32258% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.37%. Comparing base (6749bc6) to head (a4742bd).
⚠️ Report is 85 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/offers/flow.rs 79.01% 16 Missing and 1 partial ⚠️
lightning/src/offers/offer.rs 61.53% 5 Missing ⚠️
lightning/src/offers/invoice.rs 94.36% 3 Missing and 1 partial ⚠️
lightning/src/offers/invoice_request.rs 94.44% 3 Missing ⚠️
lightning/src/ln/channelmanager.rs 92.30% 1 Missing and 1 partial ⚠️
lightning/src/ln/offers_tests.rs 97.70% 1 Missing and 1 partial ⚠️
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     
Flag Coverage Δ
fuzzing 35.13% <4.10%> (-0.84%) ⬇️
tests 88.71% <90.32%> (+<0.01%) ⬆️

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.

.inner
.offer
.resolve_offer_amount(currency_conversion)?
.ok_or(Bolt12SemanticError::UnsupportedCurrency)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Suggested change
.ok_or(Bolt12SemanticError::UnsupportedCurrency)?;
.ok_or(Bolt12SemanticError::MissingAmount)?;

Comment on lines +401 to 427
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)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +32 to +38
/// 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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +8542 to +8548
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);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +1494 to +1501
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),
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +68 to +76
fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result<u64, ()> {
unreachable!()
}

fn tolerance_percent(&self) -> u8 {
unreachable!()
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

let payment_id = PaymentId([1; 32]);
let conversion = DefaultCurrencyConversion;

let mut builder = offer.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Suggested change
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)?;

Comment on lines +2101 to +2102
/// fee_estimator, chain_monitor, tx_broadcaster, router, message_router, currency_conversion,
/// logger, entropy_source, node_signer, signer_provider, config.clone(), params, current_timestamp,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: Mixed indentation — line 2101 uses spaces, line 2102 uses a tab. Should be consistent.

Suggested change
/// 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,

@ldk-claude-review-bot
Copy link
Copy Markdown
Collaborator

ldk-claude-review-bot commented Mar 21, 2026

Review Summary

This 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:

  1. lightning/src/offers/invoice_request.rs:289-311 — Double currency conversion in build_with_checks: Lines 291 and 308 independently call resolve_offer_amount(currency_conversion). If the implementation returns live exchange rates, the second call may observe a higher rate than the first, causing a spurious InsufficientAmount error for amounts that were just resolved from the same offer.

  2. lightning/src/ln/channelmanager.rs:5796-5806check_bolt12_invoice_amount has two concrete failure modes for currency-denominated offers: (a) when the payer's CurrencyConversion doesn't support the currency at all (which build_with_checks tolerates at invoice_request.rs:301), the amount resolution unconditionally fails, creating a payment that can start but never complete; (b) when payer and payee use different rates, the strict != rejects all invoices. Both cases leave the payment stuck in AwaitingInvoice with no user event.

Previously flagged issues (from prior review — not repeated inline):

  • channelmanager.rs:8548DefaultCurrencyConversion at claim time (confirmed safe due to has_amount_msats() guard)
  • invoice.rs:435 — TOCTOU risk (confirmed NOT a real issue on second analysis — has_amount_msats() guard ensures only one conversion per code path)
  • currency.rs:38tolerance_percent unused
  • invoice_request.rs:1530 — fragile UnsupportedCurrency catch at parse time

Cross-cutting concerns:

  • Compilation failures: fuzz/src/chanmon_consistency.rs and fuzz/src/lsps_message.rs were not updated with the new currency_conversion parameter for ChannelManager::new and ChannelManagerReadArgs. These will fail to compile.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 7th Reminder

Hey @jkczyz! 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
Copy Markdown

🔔 8th Reminder

Hey @jkczyz! 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
Copy Markdown

🔔 9th Reminder

Hey @jkczyz! 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
Copy Markdown

🔔 10th Reminder

Hey @jkczyz! 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
Copy Markdown

🔔 11th Reminder

Hey @jkczyz! 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.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Apr 3, 2026

Updated .13 → .14

Changes:

  • Moved CurrencyConversion ownership out of OffersMessageFlow and into ChannelManager, so conversion state is carried at the manager level and passed into request, invoice, and payment handling directly.
  • Stopped eagerly converting fiat-denominated offer amounts into InvoiceRequest.amount_msats; omitted request amounts now stay omitted and are resolved later via InvoiceRequest::amount_msats(converter).
  • Added payer-side invoice amount verification against the pending invoice request, so the received BOLT12 invoice is checked using the local converter instead of relying on earlier request-time normalization.
  • Aligned async and static invoice paths with the new model by retaining the original InvoiceRequest in pending outbound state and binding static-invoice payment secrets to the resolved amount.

Comment on lines +5836 to +5845
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);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. 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).
  2. The payee resolves the currency amount at their exchange rate and sets the invoice amount.
  3. When check_bolt12_invoice_amount runs 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.
  4. 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.

Suggested change
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);
}
}
}

shaavan and others added 8 commits April 3, 2026 16:45
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>
@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Apr 3, 2026

Rebased .14 → .15

Comment on lines +289 to +311
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
)?;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: Double currency conversion with potential TOCTOU. When the payer omits amount_msats and the offer is currency-denominated:

  1. Line 291: resolve_offer_amount(currency_conversion) is called → returns unit_msats (conversion try! instead of ? #1)
  2. Line 308: check_amount_msats_for_quantity(currency_conversion, ...) is called → internally calls resolve_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:

Suggested change
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);
}
}

Comment on lines +5796 to +5806
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);
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

Suggested change
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.
}

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 12th Reminder

Hey @jkczyz! 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.

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.

8 participants