Skip to content

Add currency conversion support for BOLT 12 offers#3833

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

Add currency conversion support for BOLT 12 offers#3833
shaavan wants to merge 6 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.

@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Mar 10, 2026

Updated .11 → .12

Changes:

  • Made Offer::build infallible.
  • Fix CI.
  • Improve documentation.
  • Improve test coverage.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd 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

🔔 3rd 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

🔔 4th 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

🔔 5th 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

🔔 6th 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 added 6 commits March 21, 2026 21:29
Adds a `CurrencyConversion` trait allowing users to provide logic for
converting currency-denominated amounts into millisatoshis.

LDK itself cannot perform such conversions as exchange rates are
external, time-dependent, and application-specific. Instead, the
conversion logic must be supplied by the user.

This trait forms the foundation for supporting Offers denominated in
fiat currencies while keeping exchange-rate handling outside the core
protocol logic.
Makes OffersMessageFlow generic over a CurrencyConversion implementation,
propagating the parameter through to ChannelManager.

Upcoming changes will introduce currency conversion support in BOLT 12
message handling, which requires access to conversion logic from both
ChannelManager and OffersMessageFlow.

By threading the conversion abstraction through OffersMessageFlow now,
subsequent commits can use it directly without introducing temporary
plumbing or refactoring the type hierarchy later.
Extends `OfferBuilder` to allow creating Offers whose amount is
denominated in a fiat currency instead of millisatoshis.

To ensure such Offers can later be processed correctly, currency
amounts may only be set when the caller provides a `CurrencyConversion`
implementation capable of resolving the amount into millisatoshis.

Since amount correctness checks are now performed directly in the
amount setters, they can be removed from the `build()` method.

This introduces the first layer of currency support in Offers,
allowing them to be created with currency-denominated amounts.
To support currency-denominated Offers, the InvoiceRequest builder
needs to resolve the Offer amount at multiple points during construction.
This occurs when explicitly setting `amount_msats` and again when the
InvoiceRequest is finalized via `build()`.

To avoid repeatedly passing a `CurrencyConversion` implementation into
these checks, the builder now stores a reference to it at creation time.
This allows the builder to resolve currency-denominated Offer amounts
whenever validation requires it.

As part of this change, `InvoiceRequest::amount_msats()` is updated to
use the provided `CurrencyConversion` to resolve the underlying Offer
amount when necessary.
Adds currency conversion support when responding to an `InvoiceRequest`
and constructing the `InvoiceBuilder`.

When the underlying Offer specifies its amount in a currency denomination,
the `CurrencyConversion` implementation is used to resolve the payable
amount into millisatoshis and ensure the invoice amount satisfies the
Offer's requirements.

This reintroduces the currency validation intentionally skipped during
`InvoiceRequest` parsing, keeping parsing focused on structural
validation while enforcing amount correctness at the time the Invoice
is constructed.
Adds tests covering Offers whose amounts are denominated in fiat
currencies. These tests verify that:

* currency-denominated Offer amounts can be created
* InvoiceRequests correctly resolve amounts using CurrencyConversion
* Invoice construction validates and enforces the payable amount

This ensures the full Offer → InvoiceRequest → Invoice flow works
correctly when the original Offer amount is specified in currency.
@shaavan
Copy link
Copy Markdown
Member Author

shaavan commented Mar 21, 2026

Rebased: .12 → .13

Comment on lines +348 to +351
pub fn amount_msats($($self_mut)* $self: $self_type, amount_msats: u64) -> Result<$return_type, Bolt12SemanticError> {
if amount_msats > MAX_VALUE_MSAT {
return Err(Bolt12SemanticError::InvalidAmount);
}
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: amount_msats(0) is accepted here because the check is only > MAX_VALUE_MSAT, but the test builds_offer_with_amount at line 1763 expects amount_msats(0) to return Err(Bolt12SemanticError::InvalidAmount). The parser at line 1367 correctly rejects 0-amount offers, creating an inconsistency between builder and parser. A zero-amount offer is invalid per BOLT 12.

Suggested change
pub fn amount_msats($($self_mut)* $self: $self_type, amount_msats: u64) -> Result<$return_type, Bolt12SemanticError> {
if amount_msats > MAX_VALUE_MSAT {
return Err(Bolt12SemanticError::InvalidAmount);
}
pub fn amount_msats($($self_mut)* $self: $self_type, amount_msats: u64) -> Result<$return_type, Bolt12SemanticError> {
if amount_msats == 0 || amount_msats > MAX_VALUE_MSAT {
return Err(Bolt12SemanticError::InvalidAmount);
}

Comment on lines +360 to +363
pub fn amount<CC: CurrencyConversion>($($self_mut)* $self: $self_type, amount: Amount, currency_conversion: &CC) -> Result<$return_type, Bolt12SemanticError>
{
if amount.into_msats(currency_conversion)? > MAX_VALUE_MSAT {
return Err(Bolt12SemanticError::InvalidAmount);
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.

Same issue: amount() with Amount::Bitcoin { amount_msats: 0 } passes this check since 0 > MAX_VALUE_MSAT is false. Also, Amount::Currency { ..., amount: 0 } would pass if msats_per_minor_unit returns 0 (since 0 * anything = 0). The parser rejects both of these at line 1367/1372. Should add a zero-amount check here for consistency.

Suggested change
pub fn amount<CC: CurrencyConversion>($($self_mut)* $self: $self_type, amount: Amount, currency_conversion: &CC) -> Result<$return_type, Bolt12SemanticError>
{
if amount.into_msats(currency_conversion)? > MAX_VALUE_MSAT {
return Err(Bolt12SemanticError::InvalidAmount);
pub fn amount<CC: CurrencyConversion>($($self_mut)* $self: $self_type, amount: Amount, currency_conversion: &CC) -> Result<$return_type, Bolt12SemanticError>
{
let msats = amount.into_msats(currency_conversion)?;
if msats == 0 || msats > MAX_VALUE_MSAT {
return Err(Bolt12SemanticError::InvalidAmount);
}

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

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