cdk/wallet/mint_connector/
http_client.rs

1//! HTTP Mint client with pluggable transport
2use std::collections::HashSet;
3use std::sync::{Arc, RwLock as StdRwLock};
4
5use async_trait::async_trait;
6use cdk_common::{nut19, MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};
7#[cfg(feature = "auth")]
8use cdk_common::{Method, ProtectedEndpoint, RoutePath};
9use serde::de::DeserializeOwned;
10use serde::Serialize;
11#[cfg(feature = "auth")]
12use tokio::sync::RwLock;
13use tracing::instrument;
14use url::Url;
15use web_time::{Duration, Instant};
16
17use super::transport::Transport;
18use super::{Error, MintConnector};
19use crate::mint_url::MintUrl;
20#[cfg(feature = "auth")]
21use crate::nuts::nut22::MintAuthRequest;
22use crate::nuts::{
23    AuthToken, CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse,
24    MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request,
25    MintQuoteBolt11Response, MintRequest, MintResponse, RestoreRequest, RestoreResponse,
26    SwapRequest, SwapResponse,
27};
28#[cfg(feature = "auth")]
29use crate::wallet::auth::{AuthMintConnector, AuthWallet};
30
31type Cache = (u64, HashSet<(nut19::Method, nut19::Path)>);
32
33/// Http Client
34#[derive(Debug, Clone)]
35pub struct HttpClient<T>
36where
37    T: Transport + Send + Sync + 'static,
38{
39    transport: Arc<T>,
40    mint_url: MintUrl,
41    cache_support: Arc<StdRwLock<Cache>>,
42    #[cfg(feature = "auth")]
43    auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
44}
45
46impl<T> HttpClient<T>
47where
48    T: Transport + Send + Sync + 'static,
49{
50    /// Create new [`HttpClient`] with a provided transport implementation.
51    #[cfg(feature = "auth")]
52    pub fn with_transport(
53        mint_url: MintUrl,
54        transport: T,
55        auth_wallet: Option<AuthWallet>,
56    ) -> Self {
57        Self {
58            transport: transport.into(),
59            mint_url,
60            auth_wallet: Arc::new(RwLock::new(auth_wallet)),
61            cache_support: Default::default(),
62        }
63    }
64
65    /// Create new [`HttpClient`] with a provided transport implementation.
66    #[cfg(not(feature = "auth"))]
67    pub fn with_transport(mint_url: MintUrl, transport: T) -> Self {
68        Self {
69            transport: transport.into(),
70            mint_url,
71            cache_support: Default::default(),
72        }
73    }
74
75    /// Create new [`HttpClient`]
76    #[cfg(feature = "auth")]
77    pub fn new(mint_url: MintUrl, auth_wallet: Option<AuthWallet>) -> Self {
78        Self {
79            transport: T::default().into(),
80            mint_url,
81            auth_wallet: Arc::new(RwLock::new(auth_wallet)),
82            cache_support: Default::default(),
83        }
84    }
85
86    #[cfg(not(feature = "auth"))]
87    /// Create new [`HttpClient`]
88    pub fn new(mint_url: MintUrl) -> Self {
89        Self {
90            transport: T::default().into(),
91            cache_support: Default::default(),
92            mint_url,
93        }
94    }
95
96    /// Get auth token for a protected endpoint
97    #[cfg(feature = "auth")]
98    #[instrument(skip(self))]
99    pub async fn get_auth_token(
100        &self,
101        method: Method,
102        path: RoutePath,
103    ) -> Result<Option<AuthToken>, Error> {
104        let auth_wallet = self.auth_wallet.read().await;
105        match auth_wallet.as_ref() {
106            Some(auth_wallet) => {
107                let endpoint = ProtectedEndpoint::new(method, path);
108                auth_wallet.get_auth_for_request(&endpoint).await
109            }
110            None => Ok(None),
111        }
112    }
113
114    /// Create new [`HttpClient`] with a proxy for specific TLDs.
115    /// Specifying `None` for `host_matcher` will use the proxy for all
116    /// requests.
117    pub fn with_proxy(
118        mint_url: MintUrl,
119        proxy: Url,
120        host_matcher: Option<&str>,
121        accept_invalid_certs: bool,
122    ) -> Result<Self, Error> {
123        let mut transport = T::default();
124        transport.with_proxy(proxy, host_matcher, accept_invalid_certs)?;
125
126        Ok(Self {
127            transport: transport.into(),
128            mint_url,
129            #[cfg(feature = "auth")]
130            auth_wallet: Arc::new(RwLock::new(None)),
131            cache_support: Default::default(),
132        })
133    }
134
135    /// Generic implementation of a retriable http request
136    ///
137    /// The retry only happens if the mint supports replay through the Caching of NUT-19.
138    #[inline(always)]
139    async fn retriable_http_request<P, R>(
140        &self,
141        method: nut19::Method,
142        path: nut19::Path,
143        auth_token: Option<AuthToken>,
144        payload: &P,
145    ) -> Result<R, Error>
146    where
147        P: Serialize + ?Sized + Send + Sync,
148        R: DeserializeOwned,
149    {
150        let started = Instant::now();
151
152        let retriable_window = self
153            .cache_support
154            .read()
155            .map(|cache_support| {
156                cache_support
157                    .1
158                    .get(&(method, path))
159                    .map(|_| cache_support.0)
160            })
161            .unwrap_or_default()
162            .map(Duration::from_secs)
163            .unwrap_or_default();
164
165        let transport = self.transport.clone();
166        loop {
167            let url = self.mint_url.join_paths(&match path {
168                nut19::Path::MintBolt11 => vec!["v1", "mint", "bolt11"],
169                nut19::Path::MeltBolt11 => vec!["v1", "melt", "bolt11"],
170                nut19::Path::MintBolt12 => vec!["v1", "mint", "bolt12"],
171
172                nut19::Path::MeltBolt12 => vec!["v1", "melt", "bolt12"],
173                nut19::Path::Swap => vec!["v1", "swap"],
174            })?;
175
176            let result = match method {
177                nut19::Method::Get => transport.http_get(url, auth_token.clone()).await,
178                nut19::Method::Post => transport.http_post(url, auth_token.clone(), payload).await,
179            };
180
181            if result.is_ok() {
182                return result;
183            }
184
185            match result.as_ref() {
186                Err(Error::HttpError(status_code, _)) => {
187                    let status_code = status_code.to_owned().unwrap_or_default();
188                    if (400..=499).contains(&status_code) {
189                        // 4xx errors won't be 'solved' by retrying
190                        return result;
191                    }
192
193                    // retry request, if possible
194                    tracing::error!("Failed http_request {:?}", result.as_ref().err());
195
196                    if retriable_window < started.elapsed() {
197                        return result;
198                    }
199                }
200                Err(_) => return result,
201                _ => unreachable!(),
202            };
203        }
204    }
205}
206
207#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
208#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
209impl<T> MintConnector for HttpClient<T>
210where
211    T: Transport + Send + Sync + 'static,
212{
213    #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
214    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
215    async fn resolve_dns_txt(&self, domain: &str) -> Result<Vec<String>, Error> {
216        self.transport.resolve_dns_txt(domain).await
217    }
218
219    /// Fetch Lightning address pay request data
220    #[instrument(skip(self))]
221    async fn fetch_lnurl_pay_request(
222        &self,
223        url: &str,
224    ) -> Result<crate::lightning_address::LnurlPayResponse, Error> {
225        let parsed_url =
226            url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
227        self.transport.http_get(parsed_url, None).await
228    }
229
230    /// Fetch invoice from Lightning address callback
231    #[instrument(skip(self))]
232    async fn fetch_lnurl_invoice(
233        &self,
234        url: &str,
235    ) -> Result<crate::lightning_address::LnurlPayInvoiceResponse, Error> {
236        let parsed_url =
237            url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
238        self.transport.http_get(parsed_url, None).await
239    }
240
241    /// Get Active Mint Keys [NUT-01]
242    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
243    async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
244        let url = self.mint_url.join_paths(&["v1", "keys"])?;
245        let transport = self.transport.clone();
246
247        Ok(transport.http_get::<KeysResponse>(url, None).await?.keysets)
248    }
249
250    /// Get Keyset Keys [NUT-01]
251    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
252    async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
253        let url = self
254            .mint_url
255            .join_paths(&["v1", "keys", &keyset_id.to_string()])?;
256
257        let transport = self.transport.clone();
258        let keys_response = transport.http_get::<KeysResponse>(url, None).await?;
259
260        Ok(keys_response.keysets.first().unwrap().clone())
261    }
262
263    /// Get Keysets [NUT-02]
264    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
265    async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error> {
266        let url = self.mint_url.join_paths(&["v1", "keysets"])?;
267        let transport = self.transport.clone();
268        transport.http_get(url, None).await
269    }
270
271    /// Mint Quote [NUT-04]
272    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
273    async fn post_mint_quote(
274        &self,
275        request: MintQuoteBolt11Request,
276    ) -> Result<MintQuoteBolt11Response<String>, Error> {
277        let url = self
278            .mint_url
279            .join_paths(&["v1", "mint", "quote", "bolt11"])?;
280
281        #[cfg(feature = "auth")]
282        let auth_token = self
283            .get_auth_token(Method::Post, RoutePath::MintQuoteBolt11)
284            .await?;
285
286        #[cfg(not(feature = "auth"))]
287        let auth_token = None;
288
289        self.transport.http_post(url, auth_token, &request).await
290    }
291
292    /// Mint Quote status
293    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
294    async fn get_mint_quote_status(
295        &self,
296        quote_id: &str,
297    ) -> Result<MintQuoteBolt11Response<String>, Error> {
298        let url = self
299            .mint_url
300            .join_paths(&["v1", "mint", "quote", "bolt11", quote_id])?;
301
302        #[cfg(feature = "auth")]
303        let auth_token = self
304            .get_auth_token(Method::Get, RoutePath::MintQuoteBolt11)
305            .await?;
306
307        #[cfg(not(feature = "auth"))]
308        let auth_token = None;
309        self.transport.http_get(url, auth_token).await
310    }
311
312    /// Mint Tokens [NUT-04]
313    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
314    async fn post_mint(&self, request: MintRequest<String>) -> Result<MintResponse, Error> {
315        #[cfg(feature = "auth")]
316        let auth_token = self
317            .get_auth_token(Method::Post, RoutePath::MintBolt11)
318            .await?;
319
320        #[cfg(not(feature = "auth"))]
321        let auth_token = None;
322        self.retriable_http_request(
323            nut19::Method::Post,
324            nut19::Path::MintBolt11,
325            auth_token,
326            &request,
327        )
328        .await
329    }
330
331    /// Melt Quote [NUT-05]
332    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
333    async fn post_melt_quote(
334        &self,
335        request: MeltQuoteBolt11Request,
336    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
337        let url = self
338            .mint_url
339            .join_paths(&["v1", "melt", "quote", "bolt11"])?;
340        #[cfg(feature = "auth")]
341        let auth_token = self
342            .get_auth_token(Method::Post, RoutePath::MeltQuoteBolt11)
343            .await?;
344
345        #[cfg(not(feature = "auth"))]
346        let auth_token = None;
347        self.transport.http_post(url, auth_token, &request).await
348    }
349
350    /// Melt Quote Status
351    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
352    async fn get_melt_quote_status(
353        &self,
354        quote_id: &str,
355    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
356        let url = self
357            .mint_url
358            .join_paths(&["v1", "melt", "quote", "bolt11", quote_id])?;
359
360        #[cfg(feature = "auth")]
361        let auth_token = self
362            .get_auth_token(Method::Get, RoutePath::MeltQuoteBolt11)
363            .await?;
364
365        #[cfg(not(feature = "auth"))]
366        let auth_token = None;
367        self.transport.http_get(url, auth_token).await
368    }
369
370    /// Melt [NUT-05]
371    /// [Nut-08] Lightning fee return if outputs defined
372    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
373    async fn post_melt(
374        &self,
375        request: MeltRequest<String>,
376    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
377        #[cfg(feature = "auth")]
378        let auth_token = self
379            .get_auth_token(Method::Post, RoutePath::MeltBolt11)
380            .await?;
381
382        #[cfg(not(feature = "auth"))]
383        let auth_token = None;
384
385        self.retriable_http_request(
386            nut19::Method::Post,
387            nut19::Path::MeltBolt11,
388            auth_token,
389            &request,
390        )
391        .await
392    }
393
394    /// Swap Token [NUT-03]
395    #[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))]
396    async fn post_swap(&self, swap_request: SwapRequest) -> Result<SwapResponse, Error> {
397        #[cfg(feature = "auth")]
398        let auth_token = self.get_auth_token(Method::Post, RoutePath::Swap).await?;
399
400        #[cfg(not(feature = "auth"))]
401        let auth_token = None;
402
403        self.retriable_http_request(
404            nut19::Method::Post,
405            nut19::Path::Swap,
406            auth_token,
407            &swap_request,
408        )
409        .await
410    }
411
412    /// Helper to get mint info
413    async fn get_mint_info(&self) -> Result<MintInfo, Error> {
414        let url = self.mint_url.join_paths(&["v1", "info"])?;
415        let transport = self.transport.clone();
416        let info: MintInfo = transport.http_get(url, None).await?;
417
418        if let Ok(mut cache_support) = self.cache_support.write() {
419            *cache_support = (
420                info.nuts.nut19.ttl.unwrap_or(300),
421                info.nuts
422                    .nut19
423                    .cached_endpoints
424                    .clone()
425                    .into_iter()
426                    .map(|cached_endpoint| (cached_endpoint.method, cached_endpoint.path))
427                    .collect(),
428            );
429        }
430
431        Ok(info)
432    }
433
434    #[cfg(feature = "auth")]
435    async fn get_auth_wallet(&self) -> Option<AuthWallet> {
436        self.auth_wallet.read().await.clone()
437    }
438
439    #[cfg(feature = "auth")]
440    async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
441        *self.auth_wallet.write().await = wallet;
442    }
443
444    /// Spendable check [NUT-07]
445    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
446    async fn post_check_state(
447        &self,
448        request: CheckStateRequest,
449    ) -> Result<CheckStateResponse, Error> {
450        let url = self.mint_url.join_paths(&["v1", "checkstate"])?;
451        #[cfg(feature = "auth")]
452        let auth_token = self
453            .get_auth_token(Method::Post, RoutePath::Checkstate)
454            .await?;
455
456        #[cfg(not(feature = "auth"))]
457        let auth_token = None;
458        self.transport.http_post(url, auth_token, &request).await
459    }
460
461    /// Restore request [NUT-13]
462    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
463    async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
464        let url = self.mint_url.join_paths(&["v1", "restore"])?;
465        #[cfg(feature = "auth")]
466        let auth_token = self
467            .get_auth_token(Method::Post, RoutePath::Restore)
468            .await?;
469
470        #[cfg(not(feature = "auth"))]
471        let auth_token = None;
472        self.transport.http_post(url, auth_token, &request).await
473    }
474
475    /// Mint Quote Bolt12 [NUT-23]
476    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
477    async fn post_mint_bolt12_quote(
478        &self,
479        request: MintQuoteBolt12Request,
480    ) -> Result<MintQuoteBolt12Response<String>, Error> {
481        let url = self
482            .mint_url
483            .join_paths(&["v1", "mint", "quote", "bolt12"])?;
484
485        #[cfg(feature = "auth")]
486        let auth_token = self
487            .get_auth_token(Method::Post, RoutePath::MintQuoteBolt12)
488            .await?;
489
490        #[cfg(not(feature = "auth"))]
491        let auth_token = None;
492
493        self.transport.http_post(url, auth_token, &request).await
494    }
495
496    /// Mint Quote Bolt12 status
497    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
498    async fn get_mint_quote_bolt12_status(
499        &self,
500        quote_id: &str,
501    ) -> Result<MintQuoteBolt12Response<String>, Error> {
502        let url = self
503            .mint_url
504            .join_paths(&["v1", "mint", "quote", "bolt12", quote_id])?;
505
506        #[cfg(feature = "auth")]
507        let auth_token = self
508            .get_auth_token(Method::Get, RoutePath::MintQuoteBolt12)
509            .await?;
510
511        #[cfg(not(feature = "auth"))]
512        let auth_token = None;
513        self.transport.http_get(url, auth_token).await
514    }
515
516    /// Melt Quote Bolt12 [NUT-23]
517    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
518    async fn post_melt_bolt12_quote(
519        &self,
520        request: MeltQuoteBolt12Request,
521    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
522        let url = self
523            .mint_url
524            .join_paths(&["v1", "melt", "quote", "bolt12"])?;
525        #[cfg(feature = "auth")]
526        let auth_token = self
527            .get_auth_token(Method::Post, RoutePath::MeltQuoteBolt12)
528            .await?;
529
530        #[cfg(not(feature = "auth"))]
531        let auth_token = None;
532        self.transport.http_post(url, auth_token, &request).await
533    }
534
535    /// Melt Quote Bolt12 Status [NUT-23]
536    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
537    async fn get_melt_bolt12_quote_status(
538        &self,
539        quote_id: &str,
540    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
541        let url = self
542            .mint_url
543            .join_paths(&["v1", "melt", "quote", "bolt12", quote_id])?;
544
545        #[cfg(feature = "auth")]
546        let auth_token = self
547            .get_auth_token(Method::Get, RoutePath::MeltQuoteBolt12)
548            .await?;
549
550        #[cfg(not(feature = "auth"))]
551        let auth_token = None;
552        self.transport.http_get(url, auth_token).await
553    }
554
555    /// Melt Bolt12 [NUT-23]
556    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
557    async fn post_melt_bolt12(
558        &self,
559        request: MeltRequest<String>,
560    ) -> Result<MeltQuoteBolt11Response<String>, Error> {
561        #[cfg(feature = "auth")]
562        let auth_token = self
563            .get_auth_token(Method::Post, RoutePath::MeltBolt12)
564            .await?;
565
566        #[cfg(not(feature = "auth"))]
567        let auth_token = None;
568        self.retriable_http_request(
569            nut19::Method::Post,
570            nut19::Path::MeltBolt12,
571            auth_token,
572            &request,
573        )
574        .await
575    }
576}
577
578/// Http Client
579#[derive(Debug, Clone)]
580#[cfg(feature = "auth")]
581pub struct AuthHttpClient<T>
582where
583    T: Transport + Send + Sync + 'static,
584{
585    transport: Arc<T>,
586    mint_url: MintUrl,
587    cat: Arc<RwLock<AuthToken>>,
588}
589
590#[cfg(feature = "auth")]
591impl<T> AuthHttpClient<T>
592where
593    T: Transport + Send + Sync + 'static,
594{
595    /// Create new [`AuthHttpClient`]
596    pub fn new(mint_url: MintUrl, cat: Option<AuthToken>) -> Self {
597        Self {
598            transport: T::default().into(),
599            mint_url,
600            cat: Arc::new(RwLock::new(
601                cat.unwrap_or(AuthToken::ClearAuth("".to_string())),
602            )),
603        }
604    }
605}
606
607#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
608#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
609#[cfg(feature = "auth")]
610impl<T> AuthMintConnector for AuthHttpClient<T>
611where
612    T: Transport + Send + Sync + 'static,
613{
614    async fn get_auth_token(&self) -> Result<AuthToken, Error> {
615        Ok(self.cat.read().await.clone())
616    }
617
618    async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error> {
619        *self.cat.write().await = token;
620        Ok(())
621    }
622
623    /// Get Mint Info [NUT-06]
624    async fn get_mint_info(&self) -> Result<MintInfo, Error> {
625        let url = self.mint_url.join_paths(&["v1", "info"])?;
626        let mint_info: MintInfo = self.transport.http_get::<MintInfo>(url, None).await?;
627
628        Ok(mint_info)
629    }
630
631    /// Get Auth Keyset Keys [NUT-22]
632    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
633    async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
634        let url =
635            self.mint_url
636                .join_paths(&["v1", "auth", "blind", "keys", &keyset_id.to_string()])?;
637
638        let mut keys_response = self.transport.http_get::<KeysResponse>(url, None).await?;
639
640        let keyset = keys_response
641            .keysets
642            .drain(0..1)
643            .next()
644            .ok_or_else(|| Error::UnknownKeySet)?;
645
646        Ok(keyset)
647    }
648
649    /// Get Auth Keysets [NUT-22]
650    #[instrument(skip(self), fields(mint_url = %self.mint_url))]
651    async fn get_mint_blind_auth_keysets(&self) -> Result<KeysetResponse, Error> {
652        let url = self
653            .mint_url
654            .join_paths(&["v1", "auth", "blind", "keysets"])?;
655
656        self.transport.http_get(url, None).await
657    }
658
659    /// Mint Tokens [NUT-22]
660    #[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
661    async fn post_mint_blind_auth(&self, request: MintAuthRequest) -> Result<MintResponse, Error> {
662        let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?;
663        self.transport
664            .http_post(url, Some(self.cat.read().await.clone()), &request)
665            .await
666    }
667}