diff --git a/crates/cashu/src/nuts/nut19.rs b/crates/cashu/src/nuts/nut19.rs index bef6ded1..7f34c41c 100644 --- a/crates/cashu/src/nuts/nut19.rs +++ b/crates/cashu/src/nuts/nut19.rs @@ -32,7 +32,7 @@ impl CachedEndpoint { } /// HTTP method -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum Method { @@ -43,7 +43,7 @@ pub enum Method { } /// Route path -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum Path { /// Bolt11 Mint diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index 96720f5a..25b0c891 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -1,8 +1,9 @@ -#[cfg(feature = "auth")] -use std::sync::Arc; +use std::collections::HashSet; +use std::sync::{Arc, RwLock as StdRwLock}; +use std::time::{Duration, Instant}; use async_trait::async_trait; -use cdk_common::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; +use cdk_common::{nut19, MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; #[cfg(feature = "auth")] use cdk_common::{Method, ProtectedEndpoint, RoutePath}; use reqwest::{Client, IntoUrl}; @@ -109,11 +110,14 @@ impl HttpClientCore { } } +type Cache = (u64, HashSet<(nut19::Method, nut19::Path)>); + /// Http Client #[derive(Debug, Clone)] pub struct HttpClient { core: HttpClientCore, mint_url: MintUrl, + cache_support: Arc>, #[cfg(feature = "auth")] auth_wallet: Arc>>, } @@ -126,6 +130,7 @@ impl HttpClient { core: HttpClientCore::new(), mint_url, auth_wallet: Arc::new(RwLock::new(auth_wallet)), + cache_support: Default::default(), } } @@ -134,6 +139,7 @@ impl HttpClient { pub fn new(mint_url: MintUrl) -> Self { Self { core: HttpClientCore::new(), + cache_support: Default::default(), mint_url, } } @@ -190,8 +196,72 @@ impl HttpClient { mint_url, #[cfg(feature = "auth")] auth_wallet: Arc::new(RwLock::new(None)), + cache_support: Default::default(), }) } + + /// Generic implementation of a retriable http request + /// + /// The retry only happens if the mint supports replay through the Caching of NUT-19. + #[inline(always)] + async fn retriable_http_request( + &self, + method: nut19::Method, + path: nut19::Path, + auth_token: Option, + payload: &P, + ) -> Result + where + P: Serialize + ?Sized, + R: DeserializeOwned, + { + let started = Instant::now(); + + let retriable_window = self + .cache_support + .read() + .map(|cache_support| { + cache_support + .1 + .get(&(method, path)) + .map(|_| cache_support.0) + }) + .unwrap_or_default() + .map(Duration::from_secs) + .unwrap_or_default(); + + loop { + let url = self.mint_url.join_paths(&match path { + nut19::Path::MintBolt11 => vec!["v1", "mint", "bolt11"], + nut19::Path::MeltBolt11 => vec!["v1", "melt", "bolt11"], + nut19::Path::MintBolt12 => vec!["v1", "mint", "bolt12"], + nut19::Path::MeltBolt12 => vec!["v1", "melt", "bolt12"], + nut19::Path::Swap => vec!["v1", "swap"], + })?; + + let result = match method { + nut19::Method::Get => self.core.http_get(url, auth_token.clone()).await, + nut19::Method::Post => self.core.http_post(url, auth_token.clone(), payload).await, + }; + + if result.is_ok() { + return result; + } + + match result.as_ref() { + Err(Error::Database(_) | Error::HttpError(_) | Error::Custom(_)) => { + // retry request, if possible + tracing::error!("Failed http_request {:?}", result.as_ref().err()); + + if retriable_window < started.elapsed() { + return result; + } + } + Err(_) => return result, + _ => unreachable!(), + }; + } + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -272,7 +342,6 @@ impl MintConnector for HttpClient { /// Mint Tokens [NUT-04] #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] async fn post_mint(&self, request: MintRequest) -> Result { - let url = self.mint_url.join_paths(&["v1", "mint", "bolt11"])?; #[cfg(feature = "auth")] let auth_token = self .get_auth_token(Method::Post, RoutePath::MintBolt11) @@ -280,7 +349,13 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::MintBolt11, + auth_token, + &request, + ) + .await } /// Melt Quote [NUT-05] @@ -329,7 +404,6 @@ impl MintConnector for HttpClient { &self, request: MeltRequest, ) -> Result, Error> { - let url = self.mint_url.join_paths(&["v1", "melt", "bolt11"])?; #[cfg(feature = "auth")] let auth_token = self .get_auth_token(Method::Post, RoutePath::MeltBolt11) @@ -337,25 +411,53 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::MeltBolt11, + auth_token, + &request, + ) + .await } /// Swap Token [NUT-03] #[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))] async fn post_swap(&self, swap_request: SwapRequest) -> Result { - let url = self.mint_url.join_paths(&["v1", "swap"])?; #[cfg(feature = "auth")] let auth_token = self.get_auth_token(Method::Post, RoutePath::Swap).await?; #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &swap_request).await + + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::Swap, + auth_token, + &swap_request, + ) + .await } /// Helper to get mint info async fn get_mint_info(&self) -> Result { let url = self.mint_url.join_paths(&["v1", "info"])?; - self.core.http_get(url, None).await + let info: MintInfo = self.core.http_get(url, None).await?; + + if let Ok(mut cache_support) = self.cache_support.write() { + *cache_support = ( + info.nuts.nut19.ttl.unwrap_or(300), + info.nuts + .nut19 + .cached_endpoints + .clone() + .into_iter() + .map(|cached_endpoint| (cached_endpoint.method, cached_endpoint.path)) + .collect(), + ); + } + + Ok(info) } #[cfg(feature = "auth")] @@ -485,7 +587,6 @@ impl MintConnector for HttpClient { &self, request: MeltRequest, ) -> Result, Error> { - let url = self.mint_url.join_paths(&["v1", "melt", "bolt12"])?; #[cfg(feature = "auth")] let auth_token = self .get_auth_token(Method::Post, RoutePath::MeltBolt12) @@ -493,7 +594,13 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::MeltBolt12, + auth_token, + &request, + ) + .await } }