diff --git a/mutiny-core/src/federation.rs b/mutiny-core/src/federation.rs index 9fff881d4..5871c8c00 100644 --- a/mutiny-core/src/federation.rs +++ b/mutiny-core/src/federation.rs @@ -1169,7 +1169,6 @@ fn gateway_preference() { use fedimint_core::util::SafeUrl; use fedimint_ln_common::bitcoin::secp256k1::PublicKey; use fedimint_ln_common::LightningGatewayAnnouncement; - use std::time::Duration; use super::*; diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index f044ce707..626f47307 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -53,6 +53,7 @@ use crate::storage::{ ONCHAIN_PREFIX, PAYMENT_INBOUND_PREFIX_KEY, PAYMENT_OUTBOUND_PREFIX_KEY, SUBSCRIPTION_TIMESTAMP, }; +use crate::utils::spawn; use crate::{auth::MutinyAuthClient, hermes::HermesClient, logging::MutinyLogger}; use crate::{blindauth::BlindAuthClient, cashu::CashuHttpClient}; use crate::{error::MutinyError, nostr::ReservedProfile}; @@ -97,6 +98,7 @@ use bitcoin::{hashes::sha256, Network, Txid}; use fedimint_core::{api::InviteCode, config::FederationId}; use futures::{pin_mut, select, FutureExt}; use futures_util::join; +use futures_util::lock::Mutex; use hex_conservative::{DisplayHex, FromHex}; use itertools::Itertools; use lightning::chain::BestBlock; @@ -116,6 +118,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; use std::sync::Arc; +use std::time::Duration; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use std::{collections::HashMap, sync::atomic::AtomicBool}; @@ -129,6 +132,7 @@ use crate::nostr::{NostrKeySource, RELAYS}; #[cfg(test)] use mockall::{automock, predicate::*}; +const BITCOIN_PRICE_CACHE_SEC: u64 = 300; const DEFAULT_PAYMENT_TIMEOUT: u64 = 30; const MAX_FEDERATION_INVOICE_AMT: u64 = 200_000; const SWAP_LABEL: &str = "SWAP"; @@ -1038,6 +1042,13 @@ impl MutinyWalletBuilder { read.extend(activity_index); } + let price_cache = self + .storage + .get_bitcoin_price_cache()? + .into_iter() + .map(|(k, v)| (k, (v, Duration::from_secs(0)))) + .collect(); + let mw = MutinyWallet { xprivkey: self.xprivkey, config, @@ -1057,6 +1068,7 @@ impl MutinyWalletBuilder { skip_hodl_invoices: self.skip_hodl_invoices, safe_mode: self.safe_mode, cashu_client: CashuHttpClient::new(), + bitcoin_price_cache: Arc::new(Mutex::new(price_cache)), }; // if we are in safe mode, don't create any nodes or @@ -1127,6 +1139,7 @@ pub struct MutinyWallet { skip_hodl_invoices: bool, safe_mode: bool, cashu_client: CashuHttpClient, + bitcoin_price_cache: Arc>>, } impl MutinyWallet { @@ -2912,6 +2925,118 @@ impl MutinyWallet { }); } } + + /// Gets the current bitcoin price in USD. + pub async fn get_bitcoin_price(&self, fiat: Option) -> Result { + let now = crate::utils::now(); + let fiat = fiat.unwrap_or("usd".to_string()); + + let cache_result = { + let cache = self.bitcoin_price_cache.lock().await; + cache.get(&fiat).cloned() + }; + + match cache_result { + Some((price, timestamp)) if timestamp == Duration::from_secs(0) => { + // Cache is from previous run, return it but fetch a new price in the background + let cache = self.bitcoin_price_cache.clone(); + let storage = self.storage.clone(); + let logger = self.logger.clone(); + spawn(async move { + if let Err(e) = + Self::fetch_and_cache_price(fiat, now, cache, storage, logger.clone()).await + { + log_warn!(logger, "failed to fetch bitcoin price: {e:?}"); + } + }); + Ok(price) + } + Some((price, timestamp)) + if timestamp + Duration::from_secs(BITCOIN_PRICE_CACHE_SEC) > now => + { + // Cache is not expired + Ok(price) + } + _ => { + // Cache is either expired, empty, or doesn't have the desired fiat value + Self::fetch_and_cache_price( + fiat, + now, + self.bitcoin_price_cache.clone(), + self.storage.clone(), + self.logger.clone(), + ) + .await + } + } + } + + async fn fetch_and_cache_price( + fiat: String, + now: Duration, + bitcoin_price_cache: Arc>>, + storage: S, + logger: Arc, + ) -> Result { + match Self::fetch_bitcoin_price(&fiat).await { + Ok(new_price) => { + let mut cache = bitcoin_price_cache.lock().await; + let cache_entry = (new_price, now); + cache.insert(fiat.clone(), cache_entry); + + // save to storage in the background + let cache_clone = cache.clone(); + spawn(async move { + let cache = cache_clone + .into_iter() + .map(|(k, (price, _))| (k, price)) + .collect(); + + if let Err(e) = storage.insert_bitcoin_price_cache(cache) { + log_error!(logger, "failed to save bitcoin price cache: {e:?}"); + } + }); + + Ok(new_price) + } + Err(e) => { + // If fetching price fails, return the cached price (if any) + let cache = bitcoin_price_cache.lock().await; + if let Some((price, _)) = cache.get(&fiat) { + log_warn!(logger, "price api failed, returning cached price"); + Ok(*price) + } else { + // If there is no cached price, return the error + log_error!(logger, "no cached price and price api failed for {fiat}"); + Err(e) + } + } + } + } + + async fn fetch_bitcoin_price(fiat: &str) -> Result { + let api_url = format!("https://price.mutinywallet.com/price/{fiat}"); + + let client = reqwest::Client::builder() + .build() + .map_err(|_| MutinyError::BitcoinPriceError)?; + + let request = client + .get(api_url) + .build() + .map_err(|_| MutinyError::BitcoinPriceError)?; + + let resp: reqwest::Response = utils::fetch_with_timeout(&client, request).await?; + + let response: BitcoinPriceResponse = resp + .error_for_status() + .map_err(|_| MutinyError::BitcoinPriceError)? + .json() + .await + .map_err(|_| MutinyError::BitcoinPriceError)?; + + Ok(response.price) + } } impl InvoiceHandler for MutinyWallet { @@ -3070,6 +3195,11 @@ pub(crate) async fn create_new_federation( Ok(new_federation_identity) } +#[derive(Deserialize, Clone, Copy, Debug)] +struct BitcoinPriceResponse { + pub price: f32, +} + #[derive(Deserialize)] struct NostrBuildResult { status: String, diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index daec471f5..b6977d903 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -35,9 +35,8 @@ use bitcoin::hashes::sha256; use bitcoin::psbt::PartiallySignedTransaction; use bitcoin::secp256k1::PublicKey; use bitcoin::{Address, Network, OutPoint, Transaction, Txid}; -use core::time::Duration; use esplora_client::{AsyncClient, Builder}; -use futures::{future::join_all, lock::Mutex}; +use futures::future::join_all; use hex_conservative::DisplayHex; use lightning::chain::Confirm; use lightning::events::ClosureReason; @@ -64,7 +63,6 @@ use std::{collections::HashMap, ops::Deref, sync::Arc}; #[cfg(target_arch = "wasm32")] use web_time::Instant; -const BITCOIN_PRICE_CACHE_SEC: u64 = 300; pub const DEVICE_LOCK_INTERVAL_SECS: u64 = 30; // This is the NodeStorage object saved to the DB @@ -503,13 +501,6 @@ impl NodeManagerBuilder { Arc::new(RwLock::new(nodes_map)) }; - let price_cache = self - .storage - .get_bitcoin_price_cache()? - .into_iter() - .map(|(k, v)| (k, (v, Duration::from_secs(0)))) - .collect(); - let nm = NodeManager { stop, xprivkey: self.xprivkey, @@ -530,7 +521,6 @@ impl NodeManagerBuilder { esplora, lsp_config, logger, - bitcoin_price_cache: Arc::new(Mutex::new(price_cache)), do_not_connect_peers: c.do_not_connect_peers, safe_mode: c.safe_mode, has_done_initial_ldk_sync: Arc::new(AtomicBool::new(false)), @@ -567,7 +557,6 @@ pub struct NodeManager { pub(crate) nodes: Arc>>>>, pub(crate) lsp_config: Option, pub(crate) logger: Arc, - bitcoin_price_cache: Arc>>, do_not_connect_peers: bool, pub safe_mode: bool, /// If we've completed an initial sync this instance @@ -1771,118 +1760,6 @@ impl NodeManager { Ok(storage_peers) } - /// Gets the current bitcoin price in USD. - pub async fn get_bitcoin_price(&self, fiat: Option) -> Result { - let now = crate::utils::now(); - let fiat = fiat.unwrap_or("usd".to_string()); - - let cache_result = { - let cache = self.bitcoin_price_cache.lock().await; - cache.get(&fiat).cloned() - }; - - match cache_result { - Some((price, timestamp)) if timestamp == Duration::from_secs(0) => { - // Cache is from previous run, return it but fetch a new price in the background - let cache = self.bitcoin_price_cache.clone(); - let storage = self.storage.clone(); - let logger = self.logger.clone(); - spawn(async move { - if let Err(e) = - Self::fetch_and_cache_price(fiat, now, cache, storage, logger.clone()).await - { - log_warn!(logger, "failed to fetch bitcoin price: {e:?}"); - } - }); - Ok(price) - } - Some((price, timestamp)) - if timestamp + Duration::from_secs(BITCOIN_PRICE_CACHE_SEC) > now => - { - // Cache is not expired - Ok(price) - } - _ => { - // Cache is either expired, empty, or doesn't have the desired fiat value - Self::fetch_and_cache_price( - fiat, - now, - self.bitcoin_price_cache.clone(), - self.storage.clone(), - self.logger.clone(), - ) - .await - } - } - } - - async fn fetch_and_cache_price( - fiat: String, - now: Duration, - bitcoin_price_cache: Arc>>, - storage: S, - logger: Arc, - ) -> Result { - match Self::fetch_bitcoin_price(&fiat).await { - Ok(new_price) => { - let mut cache = bitcoin_price_cache.lock().await; - let cache_entry = (new_price, now); - cache.insert(fiat.clone(), cache_entry); - - // save to storage in the background - let cache_clone = cache.clone(); - spawn(async move { - let cache = cache_clone - .into_iter() - .map(|(k, (price, _))| (k, price)) - .collect(); - - if let Err(e) = storage.insert_bitcoin_price_cache(cache) { - log_error!(logger, "failed to save bitcoin price cache: {e:?}"); - } - }); - - Ok(new_price) - } - Err(e) => { - // If fetching price fails, return the cached price (if any) - let cache = bitcoin_price_cache.lock().await; - if let Some((price, _)) = cache.get(&fiat) { - log_warn!(logger, "price api failed, returning cached price"); - Ok(*price) - } else { - // If there is no cached price, return the error - log_error!(logger, "no cached price and price api failed for {fiat}"); - Err(e) - } - } - } - } - - async fn fetch_bitcoin_price(fiat: &str) -> Result { - let api_url = format!("https://price.mutinywallet.com/price/{fiat}"); - - let client = Client::builder() - .build() - .map_err(|_| MutinyError::BitcoinPriceError)?; - - let request = client - .get(api_url) - .build() - .map_err(|_| MutinyError::BitcoinPriceError)?; - - let resp: reqwest::Response = utils::fetch_with_timeout(&client, request).await?; - - let response: BitcoinPriceResponse = resp - .error_for_status() - .map_err(|_| MutinyError::BitcoinPriceError)? - .json() - .await - .map_err(|_| MutinyError::BitcoinPriceError)?; - - Ok(response.price) - } - /// Retrieves the logs from storage. pub fn get_logs( storage: S, @@ -1962,11 +1839,6 @@ impl NodeManager { } } -#[derive(Deserialize, Clone, Copy, Debug)] -struct BitcoinPriceResponse { - pub price: f32, -} - // This will create a new node with a node manager and return the PublicKey of the node created. pub(crate) async fn create_new_node_from_node_manager( node_manager: &NodeManager, diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 927430398..635726442 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -1525,7 +1525,7 @@ impl MutinyWallet { /// Gets the current bitcoin price in chosen Fiat. #[wasm_bindgen] pub async fn get_bitcoin_price(&self, fiat: Option) -> Result { - Ok(self.inner.node_manager.get_bitcoin_price(fiat).await?) + Ok(self.inner.get_bitcoin_price(fiat).await?) } /// Exports the current state of the node manager to a json object.