use std::collections::HashSet;
use std::fmt;
use std::sync::{Arc, RwLock as StdRwLock};
use async_trait::async_trait;
use cdk_common::auth::oidc::{OidcHttpResponse, OidcHttpTransport};
use cdk_common::{
nut19, MeltQuoteCreateResponse, MeltQuoteRequest, MeltQuoteResponse, Method,
MintQuoteBolt11Response, MintQuoteBolt12Response, MintQuoteCustomResponse,
MintQuoteOnchainResponse, MintQuoteRequest, MintQuoteResponse, ProtectedEndpoint, RoutePath,
};
use cdk_http_client::HttpError;
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::sync::RwLock;
use tokio::time::{sleep, timeout};
use tracing::instrument;
use url::Url;
use web_time::{Duration, Instant};
use super::transport::Transport;
use super::{Error, MintConnector};
use crate::error::ErrorResponse;
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, MeltOnchainRequest, MeltRequest, MintInfo,
MintRequest, MintResponse, RestoreRequest, RestoreResponse, SwapRequest, SwapResponse,
};
use crate::wallet::auth::{AuthMintConnector, AuthWallet};
use crate::OidcClient;
type Cache = (u64, HashSet<(nut19::Method, nut19::Path)>);
const HTTP_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(50);
const HTTP_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(1);
fn payment_method_path_segment(method: &PaymentMethod) -> Result<&str, Error> {
match method {
PaymentMethod::Known(known) => Ok(known.as_str()),
PaymentMethod::Custom(method) if PaymentMethod::is_valid_custom_method_name(method) => {
Ok(method)
}
PaymentMethod::Custom(_) => Err(Error::InvalidPaymentMethod),
}
}
fn fill_response_method(value: &mut serde_json::Value, method: &PaymentMethod) {
if let serde_json::Value::Object(object) = value {
object
.entry("method".to_string())
.or_insert_with(|| serde_json::Value::String(method.to_string()));
}
}
fn fill_response_methods(value: &mut serde_json::Value, method: &PaymentMethod) {
match value {
serde_json::Value::Array(items) => {
for item in items {
fill_response_method(item, method);
}
}
_ => fill_response_method(value, method),
}
}
fn deserialize_with_route_method<R>(
mut value: serde_json::Value,
method: &PaymentMethod,
) -> Result<R, Error>
where
R: DeserializeOwned,
{
fill_response_methods(&mut value, method);
serde_json::from_value(value).map_err(|e| Error::Custom(e.to_string()))
}
#[derive(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> fmt::Debug for HttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpClient")
.field("transport", &core::any::type_name::<T>())
.field("mint_url", &self.mint_url)
.field("cache_support", &"[INTERNAL]")
.field("auth_wallet", &"[REDACTED]")
.finish()
}
}
#[derive(Debug, Clone)]
struct OidcTransportClient<T>
where
T: Transport + Send + Sync + 'static,
{
transport: Arc<T>,
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<T> OidcHttpTransport for OidcTransportClient<T>
where
T: Transport + Send + Sync + 'static,
{
async fn get(&self, url: &str) -> Result<OidcHttpResponse, HttpError> {
let url = Url::parse(url).map_err(|e| HttpError::Other(e.to_string()))?;
let response = self.transport.http_get_raw(url, None).await?;
let status = response.status();
let body = response.bytes().await?;
Ok(OidcHttpResponse::new(status, body))
}
async fn post_form(
&self,
url: &str,
params: Vec<(String, String)>,
) -> Result<OidcHttpResponse, HttpError> {
let url = Url::parse(url).map_err(|e| HttpError::Other(e.to_string()))?;
let response = self
.transport
.http_post_form_raw(url, None, ¶ms)
.await?;
let status = response.status();
let body = response.bytes().await?;
Ok(OidcHttpResponse::new(status, body))
}
}
impl<T> HttpClient<T>
where
T: Transport + Send + Sync + 'static,
{
fn map_http_error(err: HttpError) -> Error {
match err {
HttpError::Status { status, message } => {
match serde_json::from_str::<ErrorResponse>(&message) {
Ok(err_response) => err_response.into(),
Err(_) => Error::HttpError(Some(status), message),
}
}
HttpError::Timeout => Error::Timeout,
HttpError::Connection(message)
| HttpError::Serialization(message)
| HttpError::Proxy(message)
| HttpError::Build(message)
| HttpError::Other(message) => Error::HttpError(None, message),
}
}
async fn transport_http_get<R>(&self, url: Url, auth: Option<AuthToken>) -> Result<R, Error>
where
R: DeserializeOwned,
{
self.transport
.http_get(url, auth)
.await
.map_err(Self::map_http_error)
}
async fn transport_http_post<P, R>(
&self,
url: Url,
auth: Option<AuthToken>,
payload: &P,
) -> Result<R, Error>
where
P: Serialize + Send + Sync,
R: DeserializeOwned,
{
self.transport
.http_post(url, auth, payload)
.await
.map_err(Self::map_http_error)
}
pub fn with_transport(
mint_url: MintUrl,
transport: T,
auth_wallet: Option<AuthWallet>,
) -> Self {
Self::with_shared_transport(mint_url, Arc::new(transport), auth_wallet)
}
pub fn with_shared_transport(
mint_url: MintUrl,
transport: Arc<T>,
auth_wallet: Option<AuthWallet>,
) -> Self {
Self {
transport,
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
where
T: Default,
{
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>
where
T: Default,
{
let mut transport = T::default();
transport
.with_proxy(proxy, host_matcher, accept_invalid_certs)
.map_err(Self::map_http_error)?;
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 + 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();
let mut retry_delay = HTTP_RETRY_INITIAL_BACKOFF;
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 request = async {
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
}
}
};
let result = if retriable_window.is_zero() {
request.await
} else {
let remaining = retriable_window.saturating_sub(started.elapsed());
if remaining.is_zero() {
return Err(Error::Timeout);
}
match timeout(remaining, request).await {
Ok(result) => result,
Err(_) => return Err(Error::Timeout),
}
};
match result {
Ok(response) => return Ok(response),
Err(http_error) => {
let replay_safe = http_error.is_replay_safe();
let error = Self::map_http_error(http_error);
let elapsed = started.elapsed();
if !replay_safe || elapsed >= retriable_window {
return Err(error);
}
tracing::warn!(error = %error, "Replay-safe HTTP request failed");
let remaining = retriable_window.saturating_sub(elapsed);
sleep(retry_delay.min(remaining)).await;
if started.elapsed() >= retriable_window {
return Err(error);
}
retry_delay = retry_delay.saturating_mul(2).min(HTTP_RETRY_MAX_BACKOFF);
}
}
}
}
}
fn parse_lnurl_callback_url(url: &str) -> Result<Url, Error> {
let parsed_url = Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
if parsed_url.scheme() != "https" {
return Err(Error::Custom(
"LNURL callback URL must use HTTPS".to_string(),
));
}
if !parsed_url.username().is_empty() || parsed_url.password().is_some() {
return Err(Error::Custom(
"LNURL callback URL must not include credentials".to_string(),
));
}
if parsed_url.fragment().is_some() {
return Err(Error::Custom(
"LNURL callback URL must not include a fragment".to_string(),
));
}
match parsed_url.host() {
Some(url::Host::Domain(host)) if host != "localhost" => Ok(parsed_url),
Some(_) => Err(Error::Custom(
"LNURL callback URL must use a public DNS host".to_string(),
)),
None => Err(Error::Custom(
"LNURL callback URL must include a host".to_string(),
)),
}
}
#[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,
{
fn auth_connector(
&self,
mint_url: MintUrl,
cat: Option<AuthToken>,
) -> Arc<dyn AuthMintConnector + Send + Sync> {
Arc::new(AuthHttpClient::with_transport(
mint_url,
self.transport.as_ref().clone(),
cat,
))
}
fn oidc_client(&self, openid_discovery: String, client_id: Option<String>) -> OidcClient {
OidcClient::with_transport(
openid_discovery,
client_id,
Arc::new(OidcTransportClient {
transport: self.transport.clone(),
}),
)
}
async fn connect_websocket(
&self,
url: &str,
headers: &[(&str, &str)],
) -> Result<
(
cdk_common::ws_client::WsSender,
cdk_common::ws_client::WsReceiver,
),
cdk_common::ws_client::WsError,
> {
self.transport.ws_connect(url, headers).await
}
#[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
.as_ref()
.resolve_dns_txt(domain)
.await
.map_err(Self::map_http_error)
}
#[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 = parse_lnurl_callback_url(url)?;
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"])?;
Ok(self
.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 keys_response = self.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"])?;
self.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();
let method_name = payment_method_path_segment(&method)?;
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", method_name])?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MintQuote(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::Onchain(req) => {
let response: cdk_common::nut30::MintQuoteOnchainResponse<String> =
self.transport_http_post(url, auth_token, req).await?;
Ok(MintQuoteResponse::Onchain(response))
}
MintQuoteRequest::Custom { request: req, .. } => {
let value: serde_json::Value =
self.transport_http_post(url, auth_token, req).await?;
let response: cdk_common::nut04::MintQuoteCustomResponse<String> =
deserialize_with_route_method(value, &method)?;
Ok(MintQuoteResponse::Custom { 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::Known(KnownMethod::Onchain) => {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "onchain", quote_id])?;
let auth_token = self
.get_auth_token(
Method::Get,
RoutePath::MintQuote(
PaymentMethod::Known(KnownMethod::Onchain).to_string(),
),
)
.await?;
let response: MintQuoteOnchainResponse<String> =
self.transport_http_get(url, auth_token).await?;
Ok(MintQuoteResponse::Onchain(response))
}
PaymentMethod::Custom(_) => {
let method_name = payment_method_path_segment(&method)?;
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.to_string()))
.await?;
let value: serde_json::Value = self.transport_http_get(url, auth_token).await?;
let response: MintQuoteCustomResponse<String> =
deserialize_with_route_method(value, &method)?;
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 method_name = payment_method_path_segment(method)?;
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(_) => nut19::Path::custom_mint(method_name),
PaymentMethod::Known(KnownMethod::Onchain) => {
nut19::Path::Custom("/v1/mint/onchain".to_string())
}
};
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<MintQuoteResponse<String>>, Error> {
let method_name = payment_method_path_segment(method)?;
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", method_name, "check"])?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MintQuote(method_name.to_string()))
.await?;
match method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
let responses: Vec<MintQuoteBolt11Response<String>> =
self.transport_http_post(url, auth_token, &request).await?;
Ok(responses
.into_iter()
.map(MintQuoteResponse::Bolt11)
.collect())
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
let responses: Vec<MintQuoteBolt12Response<String>> =
self.transport_http_post(url, auth_token, &request).await?;
Ok(responses
.into_iter()
.map(MintQuoteResponse::Bolt12)
.collect())
}
PaymentMethod::Known(KnownMethod::Onchain) => {
let responses: Vec<MintQuoteOnchainResponse<String>> =
self.transport_http_post(url, auth_token, &request).await?;
Ok(responses
.into_iter()
.map(MintQuoteResponse::Onchain)
.collect())
}
PaymentMethod::Custom(method_name) => {
let value: serde_json::Value =
self.transport_http_post(url, auth_token, &request).await?;
let responses: Vec<MintQuoteCustomResponse<String>> =
deserialize_with_route_method(value, method)?;
Ok(responses
.into_iter()
.map(|response| MintQuoteResponse::Custom {
method: PaymentMethod::Custom(method_name.clone()),
response,
})
.collect())
}
}
}
#[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 method_name = payment_method_path_segment(method)?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Mint(method.to_string()))
.await?;
let path = nut19::Path::Custom(format!("/v1/mint/{method_name}/batch"));
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<MeltQuoteCreateResponse<String>, Error> {
let method = request.method();
let method_name = payment_method_path_segment(&method)?;
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", method_name])?;
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MeltQuote(method.to_string()))
.await?;
match &request {
MeltQuoteRequest::Bolt11(req) => {
let response: cdk_common::nut23::MeltQuoteBolt11Response<String> =
self.transport_http_post(url, auth_token, req).await?;
Ok(MeltQuoteCreateResponse::Bolt11(response))
}
MeltQuoteRequest::Bolt12(req) => {
let response: cdk_common::nut25::MeltQuoteBolt12Response<String> =
self.transport_http_post(url, auth_token, req).await?;
Ok(MeltQuoteCreateResponse::Bolt12(response))
}
MeltQuoteRequest::Onchain(req) => {
let response: cdk_common::nut30::MeltQuoteOnchainResponse<String> =
self.transport_http_post(url, auth_token, req).await?;
Ok(MeltQuoteCreateResponse::Onchain(response))
}
MeltQuoteRequest::Custom(req) => {
let value: serde_json::Value =
self.transport_http_post(url, auth_token, req).await?;
let response: cdk_common::nut05::MeltQuoteCustomResponse<String> =
deserialize_with_route_method(value, &method)?;
Ok(MeltQuoteCreateResponse::Custom((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::Known(KnownMethod::Onchain) => {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "onchain", quote_id])?;
let auth_token = self
.get_auth_token(
Method::Get,
RoutePath::MeltQuote(
PaymentMethod::Known(KnownMethod::Onchain).to_string(),
),
)
.await?;
let response: cdk_common::nut30::MeltQuoteOnchainResponse<String> =
self.transport_http_get(url, auth_token).await?;
Ok(MeltQuoteResponse::Onchain(response))
}
PaymentMethod::Custom(_) => {
let method_name = payment_method_path_segment(&method)?;
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.to_string()))
.await?;
let value: serde_json::Value = self.transport_http_get(url, auth_token).await?;
let response: cdk_common::nut05::MeltQuoteCustomResponse<String> =
deserialize_with_route_method(value, &method)?;
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<MeltQuoteResponse<String>, Error> {
let method_name = payment_method_path_segment(method)?;
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(_) => nut19::Path::custom_melt(method_name),
PaymentMethod::Known(KnownMethod::Onchain) => {
nut19::Path::Custom("/v1/melt/onchain".to_string())
}
};
match method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
let res: cdk_common::nuts::MeltQuoteBolt11Response<String> = self
.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await?;
Ok(MeltQuoteResponse::Bolt11(res))
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
let res: cdk_common::nuts::MeltQuoteBolt12Response<String> = self
.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await?;
Ok(MeltQuoteResponse::Bolt12(res))
}
PaymentMethod::Known(KnownMethod::Onchain) => {
let request = MeltOnchainRequest {
quote: request.quote_id().clone(),
fee_index: request
.selected_fee_index()
.ok_or(Error::InvalidPaymentRequest)?,
inputs: request.inputs().clone(),
outputs: request.outputs().clone(),
};
let res: cdk_common::nuts::MeltQuoteOnchainResponse<String> = self
.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await?;
Ok(MeltQuoteResponse::Onchain(res))
}
PaymentMethod::Custom(_) => {
let value: serde_json::Value = self
.retriable_http_request(nut19::Method::Post, path, auth_token, &request)
.await?;
let res: cdk_common::nuts::MeltQuoteCustomResponse<String> =
deserialize_with_route_method(value, method)?;
Ok(MeltQuoteResponse::Custom((method.clone(), res)))
}
}
}
#[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 info: MintInfo = self.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,
{
async fn transport_http_get<R>(&self, url: Url, auth: Option<AuthToken>) -> Result<R, Error>
where
R: DeserializeOwned,
{
self.transport
.http_get(url, auth)
.await
.map_err(HttpClient::<T>::map_http_error)
}
async fn transport_http_post<P, R>(
&self,
url: Url,
auth: Option<AuthToken>,
payload: &P,
) -> Result<R, Error>
where
P: Serialize + Send + Sync,
R: DeserializeOwned,
{
self.transport
.http_post(url, auth, payload)
.await
.map_err(HttpClient::<T>::map_http_error)
}
pub fn new(mint_url: MintUrl, cat: Option<AuthToken>) -> Self
where
T: Default,
{
Self {
transport: T::default().into(),
mint_url,
cat: Arc::new(RwLock::new(
cat.unwrap_or(AuthToken::ClearAuth("".to_string())),
)),
}
}
pub fn with_transport(mint_url: MintUrl, transport: T, cat: Option<AuthToken>) -> Self {
Self::with_shared_transport(mint_url, Arc::new(transport), cat)
}
pub fn with_shared_transport(
mint_url: MintUrl,
transport: Arc<T>,
cat: Option<AuthToken>,
) -> Self {
Self {
transport,
mint_url,
cat: Arc::new(RwLock::new(
cat.unwrap_or(AuthToken::ClearAuth("".to_string())),
)),
}
}
pub fn with_proxy(
mint_url: MintUrl,
proxy: Url,
host_matcher: Option<&str>,
accept_invalid_certs: bool,
cat: Option<AuthToken>,
) -> Result<Self, Error>
where
T: Default,
{
let mut transport = T::default();
transport
.with_proxy(proxy, host_matcher, accept_invalid_certs)
.map_err(HttpClient::<T>::map_http_error)?;
Ok(Self::with_transport(mint_url, transport, cat))
}
}
#[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
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::fmt;
use std::str::FromStr;
use std::sync::Mutex;
use async_trait::async_trait;
use cdk_common::MintQuoteState;
use cdk_http_client::{HttpError, RawResponse};
use serde::de::DeserializeOwned;
use super::*;
use crate::nuts::nut04::MintQuoteCustomRequest;
use crate::nuts::nut05::MeltQuoteCustomRequest;
#[derive(Clone, Default)]
struct MockTransport {
captured_payload: Arc<Mutex<Option<serde_json::Value>>>,
post_response: Arc<Mutex<Option<String>>>,
get_response: Arc<Mutex<Option<String>>>,
get_urls: Arc<Mutex<Vec<String>>>,
post_urls: Arc<Mutex<Vec<String>>>,
post_errors: Arc<Mutex<VecDeque<HttpError>>>,
post_delay: Option<Duration>,
}
impl fmt::Debug for MockTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MockTransport").finish()
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl Transport for MockTransport {
fn with_proxy(
&mut self,
_proxy: Url,
_host_matcher: Option<&str>,
_accept_invalid_certs: bool,
) -> Result<(), HttpError> {
Ok(())
}
#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
async fn resolve_dns_txt(&self, _domain: &str) -> Result<Vec<String>, HttpError> {
Ok(vec![])
}
async fn http_get<R>(&self, _url: Url, _auth: Option<AuthToken>) -> Result<R, HttpError>
where
R: DeserializeOwned,
{
self.get_urls.lock().expect("lock").push(_url.to_string());
let json = self
.get_response
.lock()
.expect("lock")
.clone()
.expect("no mock response set");
serde_json::from_str(&json).map_err(|e| HttpError::Serialization(e.to_string()))
}
async fn http_get_raw(
&self,
url: Url,
_auth: Option<AuthToken>,
) -> Result<RawResponse, HttpError> {
self.get_urls.lock().expect("lock").push(url.to_string());
let json = self
.get_response
.lock()
.expect("lock")
.clone()
.expect("no mock response set");
Ok(RawResponse::new(200, json.into_bytes()))
}
async fn http_post<P, R>(
&self,
_url: Url,
_auth_token: Option<AuthToken>,
payload: &P,
) -> Result<R, HttpError>
where
P: serde::Serialize + Send + Sync,
R: DeserializeOwned,
{
self.post_urls.lock().expect("lock").push(_url.to_string());
let value = serde_json::to_value(payload)
.map_err(|e| HttpError::Serialization(e.to_string()))?;
*self.captured_payload.lock().expect("lock") = Some(value);
if let Some(delay) = self.post_delay {
tokio::time::sleep(delay).await;
}
if let Some(error) = self.post_errors.lock().expect("lock").pop_front() {
return Err(error);
}
let json = self
.post_response
.lock()
.expect("lock")
.clone()
.expect("no mock response set");
serde_json::from_str(&json).map_err(|e| HttpError::Serialization(e.to_string()))
}
async fn http_post_form_raw<P>(
&self,
url: Url,
_auth_token: Option<AuthToken>,
payload: &P,
) -> Result<RawResponse, HttpError>
where
P: serde::Serialize + Send + Sync,
{
self.post_urls.lock().expect("lock").push(url.to_string());
let value = serde_json::to_value(payload)
.map_err(|e| HttpError::Serialization(e.to_string()))?;
*self.captured_payload.lock().expect("lock") = Some(value);
let json = self
.post_response
.lock()
.expect("lock")
.clone()
.expect("no mock response set");
Ok(RawResponse::new(200, json.into_bytes()))
}
}
#[test]
fn http_client_debug_does_not_traverse_auth_wallet() {
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, MockTransport::default(), None);
let debug = format!("{client:?}");
assert!(debug.contains("https://mint.example.com"));
assert!(debug.contains("auth_wallet: \"[REDACTED]\""));
}
#[tokio::test]
async fn test_post_mint_quote_custom_sends_request_object() {
let canned_json = serde_json::json!({
"quote": "test-quote-id",
"request": "paypal://pay?id=123",
"amount": 1000,
"amount_paid": 0,
"amount_issued": 0,
"updated_at": 0,
"unit": "sat",
"expiry": 9999999
})
.to_string();
let transport = MockTransport {
captured_payload: Arc::new(Mutex::new(None)),
post_response: Arc::new(Mutex::new(Some(canned_json))),
get_response: Arc::new(Mutex::new(None)),
get_urls: Arc::new(Mutex::new(Vec::new())),
post_urls: Arc::new(Mutex::new(Vec::new())),
post_errors: Arc::new(Mutex::new(VecDeque::new())),
post_delay: None,
};
let captured = transport.captured_payload.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let request = MintQuoteRequest::Custom {
method: PaymentMethod::Custom("paypal".to_string()),
request: MintQuoteCustomRequest {
amount: Some(cdk_common::Amount::from(1000)),
unit: cdk_common::CurrencyUnit::Sat,
description: None,
pubkey: None,
extra: serde_json::Value::Null,
},
};
let response = client
.post_mint_quote(request)
.await
.expect("post_mint_quote should succeed");
match response {
MintQuoteResponse::Custom { method, response } => {
assert_eq!(method, PaymentMethod::Custom("paypal".to_string()));
assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string()));
}
_ => panic!("expected custom response"),
}
let payload = captured
.lock()
.expect("lock")
.clone()
.expect("payload was captured");
assert!(
payload.is_object(),
"Custom mint quote body sent to transport must be a JSON object, got: {payload}"
);
let parsed: Result<MintQuoteCustomRequest, _> = serde_json::from_value(payload.clone());
assert!(
parsed.is_ok(),
"Transport payload must deserialize as MintQuoteCustomRequest: {:?}",
parsed.err()
);
let parsed = parsed.expect("already checked");
assert_eq!(parsed.amount, Some(cdk_common::Amount::from(1000)));
assert_eq!(parsed.unit, cdk_common::CurrencyUnit::Sat);
}
#[tokio::test]
async fn retriable_request_retries_transient_transport_error() {
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(
serde_json::json!({ "ok": true }).to_string(),
))),
post_errors: Arc::new(Mutex::new(VecDeque::from([HttpError::Connection(
"connection reset".to_string(),
)]))),
..Default::default()
};
let post_urls = transport.post_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
*client.cache_support.write().expect("cache lock") =
(1, HashSet::from([(nut19::Method::Post, nut19::Path::Swap)]));
let started = Instant::now();
let response: serde_json::Value = client
.retriable_http_request(
nut19::Method::Post,
nut19::Path::Swap,
None,
&serde_json::json!({}),
)
.await
.expect("transient failure should be retried");
assert_eq!(response, serde_json::json!({ "ok": true }));
assert_eq!(post_urls.lock().expect("lock").len(), 2);
assert!(started.elapsed() >= HTTP_RETRY_INITIAL_BACKOFF);
}
#[tokio::test]
async fn retriable_request_does_not_replay_http_status_response() {
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(
serde_json::json!({ "ok": true }).to_string(),
))),
post_errors: Arc::new(Mutex::new(VecDeque::from([HttpError::Status {
status: 503,
message: "unavailable".to_string(),
}]))),
..Default::default()
};
let post_urls = transport.post_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
*client.cache_support.write().expect("cache lock") =
(1, HashSet::from([(nut19::Method::Post, nut19::Path::Swap)]));
let result: Result<serde_json::Value, Error> = client
.retriable_http_request(
nut19::Method::Post,
nut19::Path::Swap,
None,
&serde_json::json!({}),
)
.await;
assert!(matches!(result, Err(Error::HttpError(Some(503), _))));
assert_eq!(post_urls.lock().expect("lock").len(), 1);
}
#[tokio::test]
async fn retriable_request_is_bounded_by_nut19_window() {
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(
serde_json::json!({ "ok": true }).to_string(),
))),
post_delay: Some(Duration::from_secs(5)),
..Default::default()
};
let post_urls = transport.post_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
*client.cache_support.write().expect("cache lock") =
(1, HashSet::from([(nut19::Method::Post, nut19::Path::Swap)]));
let started = Instant::now();
let result: Result<serde_json::Value, Error> = client
.retriable_http_request(
nut19::Method::Post,
nut19::Path::Swap,
None,
&serde_json::json!({}),
)
.await;
assert!(matches!(result, Err(Error::Timeout)));
assert!(started.elapsed() < Duration::from_secs(5));
assert_eq!(post_urls.lock().expect("lock").len(), 1);
}
#[tokio::test]
async fn retriable_request_does_not_retry_terminal_transport_error() {
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(
serde_json::json!({ "ok": true }).to_string(),
))),
post_errors: Arc::new(Mutex::new(VecDeque::from([HttpError::Other(
"attestation failed".to_string(),
)]))),
..Default::default()
};
let post_urls = transport.post_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
*client.cache_support.write().expect("cache lock") =
(1, HashSet::from([(nut19::Method::Post, nut19::Path::Swap)]));
let result: Result<serde_json::Value, Error> = client
.retriable_http_request(
nut19::Method::Post,
nut19::Path::Swap,
None,
&serde_json::json!({}),
)
.await;
assert!(matches!(result, Err(Error::HttpError(None, _))));
assert_eq!(post_urls.lock().expect("lock").len(), 1);
}
#[tokio::test]
async fn retriable_request_does_not_replay_without_nut19_cache_support() {
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(
serde_json::json!({ "ok": true }).to_string(),
))),
post_errors: Arc::new(Mutex::new(VecDeque::from([HttpError::Connection(
"connection reset".to_string(),
)]))),
..Default::default()
};
let post_urls = transport.post_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let result: Result<serde_json::Value, Error> = client
.retriable_http_request(
nut19::Method::Post,
nut19::Path::Swap,
None,
&serde_json::json!({}),
)
.await;
assert!(matches!(result, Err(Error::HttpError(None, _))));
assert_eq!(post_urls.lock().expect("lock").len(), 1);
}
#[tokio::test]
async fn test_invalid_custom_method_is_rejected_before_transport() {
let transport = MockTransport::default();
let get_urls = transport.get_urls.clone();
let post_urls = transport.post_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let invalid_method = PaymentMethod::Custom("../../v1/swap".to_string());
let result = client
.post_mint_quote(MintQuoteRequest::Custom {
method: invalid_method.clone(),
request: MintQuoteCustomRequest {
amount: Some(cdk_common::Amount::from(1000)),
unit: cdk_common::CurrencyUnit::Sat,
description: None,
pubkey: None,
extra: serde_json::Value::Null,
},
})
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.get_mint_quote_status(invalid_method.clone(), "test-quote-id")
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.post_mint(
&invalid_method,
MintRequest {
quote: "test-quote-id".to_string(),
outputs: Vec::new(),
signature: None,
},
)
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.post_batch_check_mint_quote_status(
&invalid_method,
BatchCheckMintQuoteRequest {
quotes: vec!["test-quote-id".to_string()],
},
)
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.post_batch_mint(
&invalid_method,
BatchMintRequest {
quotes: vec!["test-quote-id".to_string()],
quote_amounts: None,
outputs: Vec::new(),
signatures: None,
},
)
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.post_melt_quote(MeltQuoteRequest::Custom(MeltQuoteCustomRequest {
method: "../../v1/swap".to_string(),
request: "custom-payment-request".to_string(),
unit: cdk_common::CurrencyUnit::Sat,
amount: None,
extra: serde_json::Value::Null,
}))
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.get_melt_quote_status(invalid_method.clone(), "test-quote-id")
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
let result = client
.post_melt(
&invalid_method,
MeltRequest::new("test-quote-id".to_string(), Vec::new(), None),
)
.await;
assert!(matches!(result, Err(Error::InvalidPaymentMethod)));
assert!(
get_urls.lock().expect("lock").is_empty(),
"invalid custom method must be rejected before GET transport"
);
assert!(
post_urls.lock().expect("lock").is_empty(),
"invalid custom method must be rejected before POST transport"
);
}
#[tokio::test]
async fn test_get_mint_quote_custom_derives_state_from_amounts() {
let canned_json = serde_json::json!({
"quote": "test-quote-id",
"request": "paypal://pay?id=123",
"amount": 1000,
"amount_paid": 1000,
"amount_issued": 0,
"unit": "sat"
})
.to_string();
let transport = MockTransport {
get_response: Arc::new(Mutex::new(Some(canned_json))),
..Default::default()
};
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let response = client
.get_mint_quote_status(PaymentMethod::Custom("paypal".to_string()), "test-quote-id")
.await
.expect("custom quote status");
assert_eq!(response.state(), Some(MintQuoteState::Paid));
match response {
MintQuoteResponse::Custom { method, response } => {
assert_eq!(method, PaymentMethod::Custom("paypal".to_string()));
assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string()));
assert_eq!(response.amount_paid, cdk_common::Amount::from(1000));
assert_eq!(response.amount_issued, cdk_common::Amount::ZERO);
}
_ => panic!("expected custom response"),
}
}
#[tokio::test]
async fn test_batch_check_mint_quote_custom_parses_custom_responses() {
let canned_json = serde_json::json!([
{
"quote": "test-quote-id",
"request": "paypal://pay?id=123",
"amount": 1000,
"amount_paid": 1000,
"amount_issued": 1000,
"unit": "sat"
}
])
.to_string();
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(canned_json))),
..Default::default()
};
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let responses = client
.post_batch_check_mint_quote_status(
&PaymentMethod::Custom("paypal".to_string()),
BatchCheckMintQuoteRequest {
quotes: vec!["test-quote-id".to_string()],
},
)
.await
.expect("custom batch quote status");
assert_eq!(responses.len(), 1);
assert_eq!(responses[0].state(), Some(MintQuoteState::Issued));
match &responses[0] {
MintQuoteResponse::Custom { method, response } => {
assert_eq!(method, &PaymentMethod::Custom("paypal".to_string()));
assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string()));
}
_ => panic!("expected custom response"),
}
}
#[tokio::test]
async fn test_post_melt_quote_custom_derives_missing_method_from_route() {
let canned_json = serde_json::json!({
"quote": "test-melt-quote-id",
"amount": 1000,
"fee_reserve": 10,
"state": "UNPAID",
"expiry": 9999999,
"request": "paypal://pay?id=123",
"unit": "sat"
})
.to_string();
let transport = MockTransport {
post_response: Arc::new(Mutex::new(Some(canned_json))),
..Default::default()
};
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let response = client
.post_melt_quote(MeltQuoteRequest::Custom(MeltQuoteCustomRequest {
method: "paypal".to_string(),
request: "paypal://pay?id=123".to_string(),
unit: cdk_common::CurrencyUnit::Sat,
amount: None,
extra: serde_json::Value::Null,
}))
.await
.expect("custom melt quote");
match response {
MeltQuoteCreateResponse::Custom((method, response)) => {
assert_eq!(method, PaymentMethod::Custom("paypal".to_string()));
assert_eq!(response.method, PaymentMethod::Custom("paypal".to_string()));
}
_ => panic!("expected custom response"),
}
}
#[tokio::test]
async fn test_fetch_lnurl_invoice_rejects_loopback_url_before_transport() {
let transport = MockTransport::default();
let get_urls = transport.get_urls.clone();
let mint_url = MintUrl::from_str("https://mint.example.com").expect("parse url");
let client = HttpClient::with_transport(mint_url, transport, None);
let result = client
.fetch_lnurl_invoice("http://127.0.0.1:8332/?amount=1000")
.await;
assert!(
result.is_err(),
"fetch_lnurl_invoice must reject loopback URLs to prevent SSRF"
);
assert!(
get_urls.lock().expect("lock").is_empty(),
"invalid LNURL callback must be rejected before transport"
);
}
}