fix: handle fiat melt amount conversions (#1109)

* fix: handle fiat melt amount conversions

* feat: add check that processor returns quote unit

---------

Co-authored-by: thesimplekid <tsk@thesimplekid.com>
This commit is contained in:
gudnuf
2025-09-25 10:57:54 -07:00
committed by GitHub
parent caba6978e7
commit 500d162f67
2 changed files with 205 additions and 36 deletions

View File

@@ -18,6 +18,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use bitcoin::hashes::{sha256, Hash};
@@ -50,6 +51,139 @@ pub mod error;
/// Default maximum size for the secondary repayment queue
const DEFAULT_REPAY_QUEUE_MAX_SIZE: usize = 100;
/// Cache duration for exchange rate (5 minutes)
const RATE_CACHE_DURATION: Duration = Duration::from_secs(300);
/// Mempool.space prices API response structure
#[derive(Debug, Deserialize)]
struct MempoolPricesResponse {
#[serde(rename = "USD")]
usd: f64,
#[serde(rename = "EUR")]
eur: f64,
}
/// Exchange rate cache with built-in fallback rates
#[derive(Debug, Clone)]
struct ExchangeRateCache {
rates: Arc<Mutex<Option<(MempoolPricesResponse, Instant)>>>,
}
impl ExchangeRateCache {
fn new() -> Self {
Self {
rates: Arc::new(Mutex::new(None)),
}
}
/// Get current BTC rate for the specified currency with caching and fallback
async fn get_btc_rate(&self, currency: &CurrencyUnit) -> Result<f64, Error> {
// Return cached rate if still valid
{
let cached_rates = self.rates.lock().await;
if let Some((rates, timestamp)) = &*cached_rates {
if timestamp.elapsed() < RATE_CACHE_DURATION {
return Self::rate_for_currency(rates, currency);
}
}
}
// Try to fetch fresh rates, fallback on error
match self.fetch_fresh_rate(currency).await {
Ok(rate) => Ok(rate),
Err(e) => {
tracing::warn!(
"Failed to fetch exchange rates, using fallback for {:?}: {}",
currency,
e
);
Self::fallback_rate(currency)
}
}
}
/// Fetch fresh rate and update cache
async fn fetch_fresh_rate(&self, currency: &CurrencyUnit) -> Result<f64, Error> {
let url = "https://mempool.space/api/v1/prices";
let response = reqwest::get(url)
.await
.map_err(|_| Error::UnknownInvoiceAmount)?
.json::<MempoolPricesResponse>()
.await
.map_err(|_| Error::UnknownInvoiceAmount)?;
let rate = Self::rate_for_currency(&response, currency)?;
*self.rates.lock().await = Some((response, Instant::now()));
Ok(rate)
}
fn rate_for_currency(
rates: &MempoolPricesResponse,
currency: &CurrencyUnit,
) -> Result<f64, Error> {
match currency {
CurrencyUnit::Usd => Ok(rates.usd),
CurrencyUnit::Eur => Ok(rates.eur),
_ => Err(Error::UnknownInvoiceAmount),
}
}
fn fallback_rate(currency: &CurrencyUnit) -> Result<f64, Error> {
match currency {
CurrencyUnit::Usd => Ok(110_000.0), // $110k per BTC
CurrencyUnit::Eur => Ok(95_000.0), // €95k per BTC
_ => Err(Error::UnknownInvoiceAmount),
}
}
}
async fn convert_currency_amount(
amount: u64,
from_unit: &CurrencyUnit,
target_unit: &CurrencyUnit,
rate_cache: &ExchangeRateCache,
) -> Result<Amount, Error> {
use CurrencyUnit::*;
// Try basic unit conversion first (handles SAT/MSAT and same-unit conversions)
if let Ok(converted) = to_unit(amount, from_unit, target_unit) {
return Ok(converted);
}
// Handle fiat <-> bitcoin conversions that require exchange rates
match (from_unit, target_unit) {
// Fiat to Bitcoin conversions
(Usd | Eur, Sat) => {
let rate = rate_cache.get_btc_rate(from_unit).await?;
let fiat_amount = amount as f64 / 100.0; // cents to dollars/euros
Ok(Amount::from(
(fiat_amount / rate * 100_000_000.0).round() as u64
)) // to sats
}
(Usd | Eur, Msat) => {
let rate = rate_cache.get_btc_rate(from_unit).await?;
let fiat_amount = amount as f64 / 100.0; // cents to dollars/euros
Ok(Amount::from(
(fiat_amount / rate * 100_000_000_000.0).round() as u64,
)) // to msats
}
// Bitcoin to fiat conversions
(Sat, Usd | Eur) => {
let rate = rate_cache.get_btc_rate(target_unit).await?;
let btc_amount = amount as f64 / 100_000_000.0; // sats to BTC
Ok(Amount::from((btc_amount * rate * 100.0).round() as u64)) // to cents
}
(Msat, Usd | Eur) => {
let rate = rate_cache.get_btc_rate(target_unit).await?;
let btc_amount = amount as f64 / 100_000_000_000.0; // msats to BTC
Ok(Amount::from((btc_amount * rate * 100.0).round() as u64)) // to cents
}
_ => Err(Error::UnknownInvoiceAmount), // Unsupported conversion
}
}
/// Secondary repayment queue manager for any-amount invoices
#[derive(Debug, Clone)]
struct SecondaryRepaymentQueue {
@@ -201,6 +335,7 @@ pub struct FakeWallet {
incoming_payments: Arc<RwLock<HashMap<PaymentIdentifier, Vec<WaitPaymentResponse>>>>,
unit: CurrencyUnit,
secondary_repayment_queue: SecondaryRepaymentQueue,
exchange_rate_cache: ExchangeRateCache,
}
impl FakeWallet {
@@ -249,6 +384,7 @@ impl FakeWallet {
incoming_payments,
unit,
secondary_repayment_queue,
exchange_rate_cache: ExchangeRateCache::new(),
}
}
}
@@ -376,7 +512,13 @@ impl MintPayment for FakeWallet {
}
};
let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?;
let amount = convert_currency_amount(
amount_msat,
&CurrencyUnit::Msat,
unit,
&self.exchange_rate_cache,
)
.await?;
let relative_fee_reserve =
(self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64;
@@ -441,7 +583,13 @@ impl MintPayment for FakeWallet {
.ok_or(Error::UnknownInvoiceAmount)?
};
let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?;
let total_spent = convert_currency_amount(
amount_msat,
&CurrencyUnit::Msat,
unit,
&self.exchange_rate_cache,
)
.await?;
Ok(MakePaymentResponse {
payment_proof: Some("".to_string()),
@@ -466,7 +614,13 @@ impl MintPayment for FakeWallet {
}
};
let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?;
let total_spent = convert_currency_amount(
amount_msat,
&CurrencyUnit::Msat,
unit,
&self.exchange_rate_cache,
)
.await?;
Ok(MakePaymentResponse {
payment_proof: Some("".to_string()),
@@ -499,7 +653,13 @@ impl MintPayment for FakeWallet {
let offer_builder = match amount {
Some(amount) => {
let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?;
let amount_msat = convert_currency_amount(
u64::from(amount),
unit,
&CurrencyUnit::Msat,
&self.exchange_rate_cache,
)
.await?;
offer_builder.amount_msats(amount_msat.into())
}
None => offer_builder,
@@ -519,13 +679,14 @@ impl MintPayment for FakeWallet {
let amount = bolt11_options.amount;
let expiry = bolt11_options.unix_expiry;
// For fake invoices, always use msats regardless of unit
let amount_msat = if unit == &CurrencyUnit::Sat {
u64::from(amount) * 1000
} else {
// If unit is Msat, use as-is
u64::from(amount)
};
let amount_msat = convert_currency_amount(
u64::from(amount),
unit,
&CurrencyUnit::Msat,
&self.exchange_rate_cache,
)
.await?
.into();
let invoice = create_fake_invoice(amount_msat, description.clone());
let payment_hash = invoice.payment_hash();

View File

@@ -142,19 +142,6 @@ impl Mint {
..
} = melt_request;
let amount_msats = melt_request.amount_msat()?;
let amount_quote_unit = to_unit(amount_msats, &CurrencyUnit::Msat, unit)?;
self.check_melt_request_acceptable(
amount_quote_unit,
unit.clone(),
PaymentMethod::Bolt11,
request.to_string(),
*options,
)
.await?;
let ln = self
.payment_processors
.get(&PaymentProcessorKey::new(
@@ -196,6 +183,20 @@ impl Mint {
Error::UnsupportedUnit
})?;
if &payment_quote.unit != unit {
return Err(Error::UnitMismatch);
}
// Validate using processor quote amount for currency conversion
self.check_melt_request_acceptable(
payment_quote.amount,
unit.clone(),
PaymentMethod::Bolt11,
request.to_string(),
*options,
)
.await?;
let melt_ttl = self.quote_ttl().await?.melt_ttl;
let quote = MeltQuote::new(
@@ -215,7 +216,7 @@ impl Mint {
"New {} melt quote {} for {} {} with request id {:?}",
quote.payment_method,
quote.id,
amount_quote_unit,
payment_quote.amount,
unit,
payment_quote.request_lookup_id
);
@@ -251,15 +252,6 @@ impl Mint {
None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?,
};
self.check_melt_request_acceptable(
amount,
unit.clone(),
PaymentMethod::Bolt12,
request.clone(),
*options,
)
.await?;
let ln = self
.payment_processors
.get(&PaymentProcessorKey::new(
@@ -297,6 +289,20 @@ impl Mint {
Error::UnsupportedUnit
})?;
if &payment_quote.unit != unit {
return Err(Error::UnitMismatch);
}
// Validate using processor quote amount for currency conversion
self.check_melt_request_acceptable(
payment_quote.amount,
unit.clone(),
PaymentMethod::Bolt12,
request.clone(),
*options,
)
.await?;
let payment_request = MeltPaymentRequest::Bolt12 {
offer: Box::new(offer),
};
@@ -506,8 +512,6 @@ impl Mint {
unit: input_unit,
} = input_verification;
ensure_cdk!(input_unit.is_some(), Error::UnsupportedUnit);
let mut proof_writer =
ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
@@ -524,6 +528,10 @@ impl Mint {
.update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None)
.await?;
if input_unit != Some(quote.unit.clone()) {
return Err(Error::UnitMismatch);
}
match state {
MeltQuoteState::Unpaid | MeltQuoteState::Failed => Ok(()),
MeltQuoteState::Pending => Err(Error::PendingQuote),