Skip to content

Commit

Permalink
Merge pull request #1140 from MutinyWallet/mv-bitcoin-price
Browse files Browse the repository at this point in the history
Move bitcoin price fetching to MutinyWallet
  • Loading branch information
TonyGiorgio authored Apr 15, 2024
2 parents 014bac1 + 3c792a2 commit c98f9f1
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 131 deletions.
1 change: 0 additions & 1 deletion mutiny-core/src/federation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down
130 changes: 130 additions & 0 deletions mutiny-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
Expand All @@ -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};
Expand All @@ -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";
Expand Down Expand Up @@ -1038,6 +1042,13 @@ impl<S: MutinyStorage> MutinyWalletBuilder<S> {
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,
Expand All @@ -1057,6 +1068,7 @@ impl<S: MutinyStorage> MutinyWalletBuilder<S> {
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
Expand Down Expand Up @@ -1127,6 +1139,7 @@ pub struct MutinyWallet<S: MutinyStorage> {
skip_hodl_invoices: bool,
safe_mode: bool,
cashu_client: CashuHttpClient,
bitcoin_price_cache: Arc<Mutex<HashMap<String, (f32, Duration)>>>,
}

impl<S: MutinyStorage> MutinyWallet<S> {
Expand Down Expand Up @@ -2912,6 +2925,118 @@ impl<S: MutinyStorage> MutinyWallet<S> {
});
}
}

/// Gets the current bitcoin price in USD.
pub async fn get_bitcoin_price(&self, fiat: Option<String>) -> Result<f32, MutinyError> {
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<Mutex<HashMap<String, (f32, Duration)>>>,
storage: S,
logger: Arc<MutinyLogger>,
) -> Result<f32, MutinyError> {
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<f32, MutinyError> {
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<S: MutinyStorage> InvoiceHandler for MutinyWallet<S> {
Expand Down Expand Up @@ -3070,6 +3195,11 @@ pub(crate) async fn create_new_federation<S: MutinyStorage>(
Ok(new_federation_identity)
}

#[derive(Deserialize, Clone, Copy, Debug)]
struct BitcoinPriceResponse {
pub price: f32,
}

#[derive(Deserialize)]
struct NostrBuildResult {
status: String,
Expand Down
130 changes: 1 addition & 129 deletions mutiny-core/src/nodemanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -503,13 +501,6 @@ impl<S: MutinyStorage> NodeManagerBuilder<S> {
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,
Expand All @@ -530,7 +521,6 @@ impl<S: MutinyStorage> NodeManagerBuilder<S> {
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)),
Expand Down Expand Up @@ -567,7 +557,6 @@ pub struct NodeManager<S: MutinyStorage> {
pub(crate) nodes: Arc<RwLock<HashMap<PublicKey, Arc<Node<S>>>>>,
pub(crate) lsp_config: Option<LspConfig>,
pub(crate) logger: Arc<MutinyLogger>,
bitcoin_price_cache: Arc<Mutex<HashMap<String, (f32, Duration)>>>,
do_not_connect_peers: bool,
pub safe_mode: bool,
/// If we've completed an initial sync this instance
Expand Down Expand Up @@ -1771,118 +1760,6 @@ impl<S: MutinyStorage> NodeManager<S> {
Ok(storage_peers)
}

/// Gets the current bitcoin price in USD.
pub async fn get_bitcoin_price(&self, fiat: Option<String>) -> Result<f32, MutinyError> {
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<Mutex<HashMap<String, (f32, Duration)>>>,
storage: S,
logger: Arc<MutinyLogger>,
) -> Result<f32, MutinyError> {
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<f32, MutinyError> {
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,
Expand Down Expand Up @@ -1962,11 +1839,6 @@ impl<S: MutinyStorage> NodeManager<S> {
}
}

#[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<S: MutinyStorage>(
node_manager: &NodeManager<S>,
Expand Down
2 changes: 1 addition & 1 deletion mutiny-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Result<f32, MutinyJsError> {
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.
Expand Down

0 comments on commit c98f9f1

Please sign in to comment.