use std::collections::HashSet;
use std::sync::{Arc, RwLock as StdRwLock};
use async_trait::async_trait;
use cdk_common::{
nut19, MeltQuoteBolt11Response, MeltQuoteRequest, MeltQuoteResponse, Method,
MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteRequest,
MintQuoteResponse, ProtectedEndpoint, RoutePath,
};
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::sync::RwLock;
use tracing::instrument;
use url::Url;
use web_time::{Duration, Instant};
use super::transport::Transport;
use super::{Error, MintConnector};
use crate::mint_url::MintUrl;
use crate::nuts::nut00::{KnownMethod, PaymentMethod};
use crate::nuts::nut22::MintAuthRequest;
use crate::nuts::{
AuthToken, BatchCheckMintQuoteRequest, BatchMintRequest, CheckStateRequest, CheckStateResponse,
Id, KeySet, KeysResponse, KeysetResponse, MeltRequest, MintInfo, MintRequest, MintResponse,
RestoreRequest, RestoreResponse, SwapRequest, SwapResponse,
};
use crate::wallet::auth::{AuthMintConnector, AuthWallet};
type Cache = (u64, HashSet<(nut19::Method, nut19::Path)>);
#[derive(Debug, Clone)]
pub struct HttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
transport: Arc<T>,
mint_url: MintUrl,
cache_support: Arc<StdRwLock<Cache>>,
auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
}
impl<T> HttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
pub fn with_transport(
mint_url: MintUrl,
transport: T,
auth_wallet: Option<AuthWallet>,
) -> Self {
Self {
transport: transport.into(),
mint_url,
auth_wallet: Arc::new(RwLock::new(auth_wallet)),
cache_support: Default::default(),
}
}
pub fn new(mint_url: MintUrl, auth_wallet: Option<AuthWallet>) -> Self {
Self {
transport: T::default().into(),
mint_url,
auth_wallet: Arc::new(RwLock::new(auth_wallet)),
cache_support: Default::default(),
}
}
#[instrument(skip(self))]
pub async fn get_auth_token(
&self,
method: Method,
path: RoutePath,
) -> Result<Option<AuthToken>, Error> {
let auth_wallet = self.auth_wallet.read().await;
match auth_wallet.as_ref() {
Some(auth_wallet) => {
let endpoint = ProtectedEndpoint::new(method, path);
auth_wallet.get_auth_for_request(&endpoint).await
}
None => Ok(None),
}
}
pub fn with_proxy(
mint_url: MintUrl,
proxy: Url,
host_matcher: Option<&str>,
accept_invalid_certs: bool,
) -> Result<Self, Error> {
let mut transport = T::default();
transport.with_proxy(proxy, host_matcher, accept_invalid_certs)?;
Ok(Self {
transport: transport.into(),
mint_url,
auth_wallet: Arc::new(RwLock::new(None)),
cache_support: Default::default(),
})
}
#[inline(always)]
async fn retriable_http_request<P, R>(
&self,
method: nut19::Method,
path: nut19::Path,
auth_token: Option<AuthToken>,
payload: &P,
) -> Result<R, Error>
where
P: Serialize + ?Sized + Send + Sync,
R: DeserializeOwned,
{
let started = Instant::now();
let retriable_window = self
.cache_support
.read()
.map(|cache_support| {
cache_support
.1
.get(&(method, path.clone()))
.map(|_| cache_support.0)
})
.unwrap_or_default()
.map(Duration::from_secs)
.unwrap_or_default();
let transport = self.transport.clone();
loop {
let url = match &path {
nut19::Path::Swap => self.mint_url.join_paths(&["v1", "swap"])?,
nut19::Path::Custom(custom_path) => {
let path_str = custom_path.trim_start_matches('/');
let parts: Vec<&str> = path_str.split('/').collect();
self.mint_url.join_paths(&parts)?
}
};
let result = match method {
nut19::Method::Get => transport.http_get(url, auth_token.clone()).await,
nut19::Method::Post => transport.http_post(url, auth_token.clone(), payload).await,
};
if result.is_ok() {
return result;
}
match result.as_ref() {
Err(Error::HttpError(status_code, _)) => {
let status_code = status_code.to_owned().unwrap_or_default();
if (400..=499).contains(&status_code) {
return result;
}
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))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<T> MintConnector for HttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn resolve_dns_txt(&self, domain: &str) -> Result<Vec<String>, Error> {
self.transport.resolve_dns_txt(domain).await
}
#[instrument(skip(self))]
async fn fetch_lnurl_pay_request(
&self,
url: &str,
) -> Result<crate::lightning_address::LnurlPayResponse, Error> {
let parsed_url =
url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
self.transport.http_get(parsed_url, None).await
}
#[instrument(skip(self))]
async fn fetch_lnurl_invoice(
&self,
url: &str,
) -> Result<crate::lightning_address::LnurlPayInvoiceResponse, Error> {
let parsed_url =
url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
self.transport.http_get(parsed_url, None).await
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
let url = self.mint_url.join_paths(&["v1", "keys"])?;
let transport = self.transport.clone();
Ok(transport.http_get::<KeysResponse>(url, None).await?.keysets)
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
let url = self
.mint_url
.join_paths(&["v1", "keys", &keyset_id.to_string()])?;
let transport = self.transport.clone();
let keys_response = transport.http_get::<KeysResponse>(url, None).await?;
Ok(keys_response
.keysets
.first()
.ok_or(Error::UnknownKeySet)?
.clone())
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "keysets"])?;
let transport = self.transport.clone();
transport.http_get(url, None).await
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_mint_quote(
&self,
request: MintQuoteRequest,
) -> Result<MintQuoteResponse<String>, Error> {
let method = request.method().to_string();
let path = format!("v1/mint/quote/{}", method);
let url = self
.mint_url
.join_paths(&path.split('/').collect::<Vec<_>>())?;
let auth_token = self
.get_auth_token(
Method::Post,
RoutePath::MintQuote(request.method().to_string()),
)
.await?;
match &request {
MintQuoteRequest::Bolt11(req) => {
let response: cdk_common::nut23::MintQuoteBolt11Response<String> =
self.transport.http_post(url, auth_token, req).await?;
Ok(MintQuoteResponse::Bolt11(response))
}
MintQuoteRequest::Bolt12(req) => {
let response: cdk_common::nut25::MintQuoteBolt12Response<String> =
self.transport.http_post(url, auth_token, req).await?;
Ok(MintQuoteResponse::Bolt12(response))
}
MintQuoteRequest::Custom(req) => {
let response: cdk_common::nut04::MintQuoteCustomResponse<String> =
self.transport.http_post(url, auth_token, req).await?;
Ok(MintQuoteResponse::Custom((request.method(), response)))
}
}
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_quote_status(
&self,
method: PaymentMethod,
quote_id: &str,
) -> Result<MintQuoteResponse<String>, Error> {
match &method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "bolt11", quote_id])?;
let auth_token = self
.get_auth_token(
Method::Get,
RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string()),
)
.await?;
let response: MintQuoteBolt11Response<String> =
self.transport.http_get(url, auth_token).await?;
Ok(MintQuoteResponse::Bolt11(response))
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "bolt12", quote_id])?;
let auth_token = self
.get_auth_token(
Method::Get,
RoutePath::MintQuote(PaymentMethod::Known(KnownMethod::Bolt12).to_string()),
)
.await?;
let response: MintQuoteBolt12Response<String> =
self.transport.http_get(url, auth_token).await?;
Ok(MintQuoteResponse::Bolt12(response))
}
PaymentMethod::Custom(method_name) => {
let url =
self.mint_url
.join_paths(&["v1", "mint", "quote", method_name, quote_id])?;
let auth_token = self
.get_auth_token(Method::Get, RoutePath::MintQuote(method_name.clone()))
.await?;
let response: MintQuoteCustomResponse<String> =
self.transport.http_get(url, auth_token).await?;
Ok(MintQuoteResponse::Custom((method, response)))
}
}
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_mint(
&self,
method: &PaymentMethod,
request: MintRequest<String>,
) -> Result<MintResponse, Error> {
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Mint(method.to_string()))
.await?;
let path = match method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
nut19::Path::Custom("/v1/mint/bolt11".to_string())
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
nut19::Path::Custom("/v1/mint/bolt12".to_string())
}
PaymentMethod::Custom(m) => nut19::Path::custom_mint(m),
};
self.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_batch_check_mint_quote_status(
&self,
method: &PaymentMethod,
request: BatchCheckMintQuoteRequest<String>,
) -> Result<Vec<MintQuoteBolt11Response<String>>, Error> {
let url =
self.mint_url
.join_paths(&["v1", "mint", "quote", &method.to_string(), "check"])?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MintQuote(method.to_string()))
.await?;
self.transport.http_post(url, auth_token, &request).await
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_batch_mint(
&self,
method: &PaymentMethod,
request: BatchMintRequest<String>,
) -> Result<MintResponse, Error> {
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Mint(method.to_string()))
.await?;
let path = nut19::Path::Custom(format!("/v1/mint/{}/batch", method));
self.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_melt_quote(
&self,
request: MeltQuoteRequest,
) -> Result<MeltQuoteResponse<String>, Error> {
let method = request.method().to_string();
let path = format!("v1/melt/quote/{}", method);
let url = self
.mint_url
.join_paths(&path.split('/').collect::<Vec<_>>())?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MeltQuote(method))
.await?;
match &request {
MeltQuoteRequest::Bolt11(req) => {
let response: cdk_common::nut23::MeltQuoteBolt11Response<String> =
self.transport.http_post(url, auth_token, req).await?;
Ok(MeltQuoteResponse::Bolt11(response))
}
MeltQuoteRequest::Bolt12(req) => {
let response: cdk_common::nut25::MeltQuoteBolt12Response<String> =
self.transport.http_post(url, auth_token, req).await?;
Ok(MeltQuoteResponse::Bolt12(response))
}
MeltQuoteRequest::Custom(req) => {
let response: cdk_common::nut05::MeltQuoteCustomResponse<String> =
self.transport.http_post(url, auth_token, req).await?;
Ok(MeltQuoteResponse::Custom((request.method(), response)))
}
}
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_melt_quote_status(
&self,
method: PaymentMethod,
quote_id: &str,
) -> Result<MeltQuoteResponse<String>, Error> {
match &method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "bolt11", quote_id])?;
let auth_token = self
.get_auth_token(
Method::Get,
RoutePath::MeltQuote(PaymentMethod::Known(KnownMethod::Bolt11).to_string()),
)
.await?;
let response: cdk_common::nut23::MeltQuoteBolt11Response<String> =
self.transport.http_get(url, auth_token).await?;
Ok(MeltQuoteResponse::Bolt11(response))
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "bolt12", quote_id])?;
let auth_token = self
.get_auth_token(
Method::Get,
RoutePath::MeltQuote(PaymentMethod::Known(KnownMethod::Bolt12).to_string()),
)
.await?;
let response: cdk_common::nut25::MeltQuoteBolt12Response<String> =
self.transport.http_get(url, auth_token).await?;
Ok(MeltQuoteResponse::Bolt12(response))
}
PaymentMethod::Custom(method_name) => {
let url =
self.mint_url
.join_paths(&["v1", "melt", "quote", method_name, quote_id])?;
let auth_token = self
.get_auth_token(Method::Get, RoutePath::MeltQuote(method_name.clone()))
.await?;
let response: cdk_common::nut05::MeltQuoteCustomResponse<String> =
self.transport.http_get(url, auth_token).await?;
Ok(MeltQuoteResponse::Custom((method.clone(), response)))
}
}
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_melt(
&self,
method: &PaymentMethod,
request: MeltRequest<String>,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Melt(method.to_string()))
.await?;
let path = match method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
nut19::Path::Custom("/v1/melt/bolt11".to_string())
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
nut19::Path::Custom("/v1/melt/bolt12".to_string())
}
PaymentMethod::Custom(m) => nut19::Path::custom_melt(m),
};
self.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await
}
#[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))]
async fn post_swap(&self, swap_request: SwapRequest) -> Result<SwapResponse, Error> {
let auth_token = self.get_auth_token(Method::Post, RoutePath::Swap).await?;
self.retriable_http_request(
nut19::Method::Post,
nut19::Path::Swap,
auth_token,
&swap_request,
)
.await
}
async fn get_mint_info(&self) -> Result<MintInfo, Error> {
let url = self.mint_url.join_paths(&["v1", "info"])?;
let transport = self.transport.clone();
let info: MintInfo = transport.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)
}
async fn get_auth_wallet(&self) -> Option<AuthWallet> {
self.auth_wallet.read().await.clone()
}
async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
*self.auth_wallet.write().await = wallet;
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_check_state(
&self,
request: CheckStateRequest,
) -> Result<CheckStateResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "checkstate"])?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Checkstate)
.await?;
self.transport.http_post(url, auth_token, &request).await
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "restore"])?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Restore)
.await?;
self.transport.http_post(url, auth_token, &request).await
}
}
#[derive(Debug, Clone)]
pub struct AuthHttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
transport: Arc<T>,
mint_url: MintUrl,
cat: Arc<RwLock<AuthToken>>,
}
impl<T> AuthHttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
pub fn new(mint_url: MintUrl, cat: Option<AuthToken>) -> Self {
Self {
transport: T::default().into(),
mint_url,
cat: Arc::new(RwLock::new(
cat.unwrap_or(AuthToken::ClearAuth("".to_string())),
)),
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<T> AuthMintConnector for AuthHttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
async fn get_auth_token(&self) -> Result<AuthToken, Error> {
Ok(self.cat.read().await.clone())
}
async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error> {
*self.cat.write().await = token;
Ok(())
}
async fn get_mint_info(&self) -> Result<MintInfo, Error> {
let url = self.mint_url.join_paths(&["v1", "info"])?;
let mint_info: MintInfo = self.transport.http_get::<MintInfo>(url, None).await?;
Ok(mint_info)
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
let url =
self.mint_url
.join_paths(&["v1", "auth", "blind", "keys", &keyset_id.to_string()])?;
let mut keys_response = self.transport.http_get::<KeysResponse>(url, None).await?;
let keyset = keys_response
.keysets
.drain(0..1)
.next()
.ok_or_else(|| Error::UnknownKeySet)?;
Ok(keyset)
}
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_blind_auth_keysets(&self) -> Result<KeysetResponse, Error> {
let url = self
.mint_url
.join_paths(&["v1", "auth", "blind", "keysets"])?;
self.transport.http_get(url, None).await
}
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_mint_blind_auth(&self, request: MintAuthRequest) -> Result<MintResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?;
self.transport
.http_post(url, Some(self.cat.read().await.clone()), &request)
.await
}
}