@@ -53,6 +53,7 @@ use crate::storage::{
5353 ONCHAIN_PREFIX , PAYMENT_INBOUND_PREFIX_KEY , PAYMENT_OUTBOUND_PREFIX_KEY ,
5454 SUBSCRIPTION_TIMESTAMP ,
5555} ;
56+ use crate :: utils:: spawn;
5657use crate :: { auth:: MutinyAuthClient , hermes:: HermesClient , logging:: MutinyLogger } ;
5758use crate :: { blindauth:: BlindAuthClient , cashu:: CashuHttpClient } ;
5859use crate :: { error:: MutinyError , nostr:: ReservedProfile } ;
@@ -97,6 +98,7 @@ use bitcoin::{hashes::sha256, Network, Txid};
9798use fedimint_core:: { api:: InviteCode , config:: FederationId } ;
9899use futures:: { pin_mut, select, FutureExt } ;
99100use futures_util:: join;
101+ use futures_util:: lock:: Mutex ;
100102use hex_conservative:: { DisplayHex , FromHex } ;
101103use itertools:: Itertools ;
102104use lightning:: chain:: BestBlock ;
@@ -116,6 +118,7 @@ use serde::{Deserialize, Serialize};
116118use serde_json:: Value ;
117119use std:: collections:: HashSet ;
118120use std:: sync:: Arc ;
121+ use std:: time:: Duration ;
119122#[ cfg( not( target_arch = "wasm32" ) ) ]
120123use std:: time:: Instant ;
121124use std:: { collections:: HashMap , sync:: atomic:: AtomicBool } ;
@@ -129,6 +132,7 @@ use crate::nostr::{NostrKeySource, RELAYS};
129132#[ cfg( test) ]
130133use mockall:: { automock, predicate:: * } ;
131134
135+ const BITCOIN_PRICE_CACHE_SEC : u64 = 300 ;
132136const DEFAULT_PAYMENT_TIMEOUT : u64 = 30 ;
133137const MAX_FEDERATION_INVOICE_AMT : u64 = 200_000 ;
134138const SWAP_LABEL : & str = "SWAP" ;
@@ -1038,6 +1042,13 @@ impl<S: MutinyStorage> MutinyWalletBuilder<S> {
10381042 read. extend ( activity_index) ;
10391043 }
10401044
1045+ let price_cache = self
1046+ . storage
1047+ . get_bitcoin_price_cache ( ) ?
1048+ . into_iter ( )
1049+ . map ( |( k, v) | ( k, ( v, Duration :: from_secs ( 0 ) ) ) )
1050+ . collect ( ) ;
1051+
10411052 let mw = MutinyWallet {
10421053 xprivkey : self . xprivkey ,
10431054 config,
@@ -1057,6 +1068,7 @@ impl<S: MutinyStorage> MutinyWalletBuilder<S> {
10571068 skip_hodl_invoices : self . skip_hodl_invoices ,
10581069 safe_mode : self . safe_mode ,
10591070 cashu_client : CashuHttpClient :: new ( ) ,
1071+ bitcoin_price_cache : Arc :: new ( Mutex :: new ( price_cache) ) ,
10601072 } ;
10611073
10621074 // if we are in safe mode, don't create any nodes or
@@ -1127,6 +1139,7 @@ pub struct MutinyWallet<S: MutinyStorage> {
11271139 skip_hodl_invoices : bool ,
11281140 safe_mode : bool ,
11291141 cashu_client : CashuHttpClient ,
1142+ bitcoin_price_cache : Arc < Mutex < HashMap < String , ( f32 , Duration ) > > > ,
11301143}
11311144
11321145impl < S : MutinyStorage > MutinyWallet < S > {
@@ -2912,6 +2925,118 @@ impl<S: MutinyStorage> MutinyWallet<S> {
29122925 } ) ;
29132926 }
29142927 }
2928+
2929+ /// Gets the current bitcoin price in USD.
2930+ pub async fn get_bitcoin_price ( & self , fiat : Option < String > ) -> Result < f32 , MutinyError > {
2931+ let now = crate :: utils:: now ( ) ;
2932+ let fiat = fiat. unwrap_or ( "usd" . to_string ( ) ) ;
2933+
2934+ let cache_result = {
2935+ let cache = self . bitcoin_price_cache . lock ( ) . await ;
2936+ cache. get ( & fiat) . cloned ( )
2937+ } ;
2938+
2939+ match cache_result {
2940+ Some ( ( price, timestamp) ) if timestamp == Duration :: from_secs ( 0 ) => {
2941+ // Cache is from previous run, return it but fetch a new price in the background
2942+ let cache = self . bitcoin_price_cache . clone ( ) ;
2943+ let storage = self . storage . clone ( ) ;
2944+ let logger = self . logger . clone ( ) ;
2945+ spawn ( async move {
2946+ if let Err ( e) =
2947+ Self :: fetch_and_cache_price ( fiat, now, cache, storage, logger. clone ( ) ) . await
2948+ {
2949+ log_warn ! ( logger, "failed to fetch bitcoin price: {e:?}" ) ;
2950+ }
2951+ } ) ;
2952+ Ok ( price)
2953+ }
2954+ Some ( ( price, timestamp) )
2955+ if timestamp + Duration :: from_secs ( BITCOIN_PRICE_CACHE_SEC ) > now =>
2956+ {
2957+ // Cache is not expired
2958+ Ok ( price)
2959+ }
2960+ _ => {
2961+ // Cache is either expired, empty, or doesn't have the desired fiat value
2962+ Self :: fetch_and_cache_price (
2963+ fiat,
2964+ now,
2965+ self . bitcoin_price_cache . clone ( ) ,
2966+ self . storage . clone ( ) ,
2967+ self . logger . clone ( ) ,
2968+ )
2969+ . await
2970+ }
2971+ }
2972+ }
2973+
2974+ async fn fetch_and_cache_price (
2975+ fiat : String ,
2976+ now : Duration ,
2977+ bitcoin_price_cache : Arc < Mutex < HashMap < String , ( f32 , Duration ) > > > ,
2978+ storage : S ,
2979+ logger : Arc < MutinyLogger > ,
2980+ ) -> Result < f32 , MutinyError > {
2981+ match Self :: fetch_bitcoin_price ( & fiat) . await {
2982+ Ok ( new_price) => {
2983+ let mut cache = bitcoin_price_cache. lock ( ) . await ;
2984+ let cache_entry = ( new_price, now) ;
2985+ cache. insert ( fiat. clone ( ) , cache_entry) ;
2986+
2987+ // save to storage in the background
2988+ let cache_clone = cache. clone ( ) ;
2989+ spawn ( async move {
2990+ let cache = cache_clone
2991+ . into_iter ( )
2992+ . map ( |( k, ( price, _) ) | ( k, price) )
2993+ . collect ( ) ;
2994+
2995+ if let Err ( e) = storage. insert_bitcoin_price_cache ( cache) {
2996+ log_error ! ( logger, "failed to save bitcoin price cache: {e:?}" ) ;
2997+ }
2998+ } ) ;
2999+
3000+ Ok ( new_price)
3001+ }
3002+ Err ( e) => {
3003+ // If fetching price fails, return the cached price (if any)
3004+ let cache = bitcoin_price_cache. lock ( ) . await ;
3005+ if let Some ( ( price, _) ) = cache. get ( & fiat) {
3006+ log_warn ! ( logger, "price api failed, returning cached price" ) ;
3007+ Ok ( * price)
3008+ } else {
3009+ // If there is no cached price, return the error
3010+ log_error ! ( logger, "no cached price and price api failed for {fiat}" ) ;
3011+ Err ( e)
3012+ }
3013+ }
3014+ }
3015+ }
3016+
3017+ async fn fetch_bitcoin_price ( fiat : & str ) -> Result < f32 , MutinyError > {
3018+ let api_url = format ! ( "https://price.mutinywallet.com/price/{fiat}" ) ;
3019+
3020+ let client = reqwest:: Client :: builder ( )
3021+ . build ( )
3022+ . map_err ( |_| MutinyError :: BitcoinPriceError ) ?;
3023+
3024+ let request = client
3025+ . get ( api_url)
3026+ . build ( )
3027+ . map_err ( |_| MutinyError :: BitcoinPriceError ) ?;
3028+
3029+ let resp: reqwest:: Response = utils:: fetch_with_timeout ( & client, request) . await ?;
3030+
3031+ let response: BitcoinPriceResponse = resp
3032+ . error_for_status ( )
3033+ . map_err ( |_| MutinyError :: BitcoinPriceError ) ?
3034+ . json ( )
3035+ . await
3036+ . map_err ( |_| MutinyError :: BitcoinPriceError ) ?;
3037+
3038+ Ok ( response. price )
3039+ }
29153040}
29163041
29173042impl < S : MutinyStorage > InvoiceHandler for MutinyWallet < S > {
@@ -3070,6 +3195,11 @@ pub(crate) async fn create_new_federation<S: MutinyStorage>(
30703195 Ok ( new_federation_identity)
30713196}
30723197
3198+ #[ derive( Deserialize , Clone , Copy , Debug ) ]
3199+ struct BitcoinPriceResponse {
3200+ pub price : f32 ,
3201+ }
3202+
30733203#[ derive( Deserialize ) ]
30743204struct NostrBuildResult {
30753205 status : String ,
0 commit comments