mirror of
https://github.com/aljazceru/cdk.git
synced 2026-02-12 00:26:29 +01:00
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:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user