diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index 58630d14..80e221ea 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -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>>, +} + +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 { + // 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 { + let url = "https://mempool.space/api/v1/prices"; + let response = reqwest::get(url) + .await + .map_err(|_| Error::UnknownInvoiceAmount)? + .json::() + .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 { + match currency { + CurrencyUnit::Usd => Ok(rates.usd), + CurrencyUnit::Eur => Ok(rates.eur), + _ => Err(Error::UnknownInvoiceAmount), + } + } + + fn fallback_rate(currency: &CurrencyUnit) -> Result { + 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 { + 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>>>, 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(); diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index a226a032..f237a700 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -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),