mirror of
https://github.com/aljazceru/cdk.git
synced 2026-02-10 07:36:13 +01:00
feat: tor isolated circuits (#1064)
fixes + tor feature in cdk-cli fix: call `clone_with_prefs` to get a new isolation token format remove `new_isolated` from Transport trait fix: remove tor dependencies under wasm32, disallow compilation with tor feature and wasm32 tor_transport in its own file fixes fmt format tor: implement Transport::resolve_dns_txt for TorAsync using DoH over Tor; fix tor transport trait changes after rebase; remove unused as_str() call for TorToggle in cdk-cli. Ensure compilation with features: tor,bip353 format remove double reference format feat: circuits pool format tor_transport: deterministically select Tor client per request using index_for_request(endpoint path + query + payload)\n\n- Add index_for_request(&Url, Option<&[u8]>) using FNV-1a 64-bit (dependency-free)\n- Replace round-robin next_index() usage in request() with deterministic index\n- Adjust request() to accept Option<Vec<u8>> body to hash payload bytes\n- Update http_get/http_post/resolve_dns_txt to call new request signature\n- Keep next_index() as dead_code for potential fallback tor_transport: implement Default by bootstrapping with default pool size (blocking)\n\n- Default now attempts to use existing Tokio runtime handle, or creates a temporary runtime\n- Preserves previous behavior for async constructors (new/with_pool_size) tor_transport: fix Default to avoid nested runtime panic by initializing on a new thread when no Handle available\n\n- If a runtime is present, block_on via current handle\n- Otherwise, spawn a new OS thread and create a runtime inside it, then join tor_transport: rework Default to use block_in_place + background thread runtime to avoid nested block_on inside tokio\n\n- Always create runtime on a separate OS thread; if inside tokio, enter block_in_place first\n- Avoids 'Cannot start a runtime from within a runtime' panic fix more fixes tor_transport: lazy-initialize Tor client pool on first use via ensure_pool; make Default non-blocking and remove runtime gymnastics\n\n- Introduce Inner with OnceCell<Arc<Vec<TorClient>>> and configured size\n- Default/new/with_pool_size now cheap; actual arti bootstrap happens on first request\n- request() calls ensure_pool() and uses deterministic index with pool.len()\n- Keeps deterministic endpoint/method/body affinity and DoH TXT resolution\n\nThis avoids nested-runtime/block_in_place complexity and makes Default trivial. tor_transport: make DEFAULT_TOR_POOL_SIZE public and support custom pool sizes via TorAsync::with_pool_size() (lazy)} remove unneeded async add salt to keyed circuit selection
This commit is contained in:
@@ -15,6 +15,7 @@ default = []
|
||||
sqlcipher = ["cdk-sqlite/sqlcipher"]
|
||||
# MSRV is not tracked with redb enabled
|
||||
redb = ["dep:cdk-redb"]
|
||||
tor = ["cdk/tor"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -13,6 +13,8 @@ use cdk::wallet::MultiMintWallet;
|
||||
#[cfg(feature = "redb")]
|
||||
use cdk_redb::WalletRedbDatabase;
|
||||
use cdk_sqlite::WalletSqliteDatabase;
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
use clap::ValueEnum;
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -27,11 +29,15 @@ const DEFAULT_WORK_DIR: &str = ".cdk-cli";
|
||||
const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Simple CLI application to interact with cashu
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum TorToggle {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cdk-cli")]
|
||||
#[command(author = "thesimplekid <tsk@thesimplekid.com>")]
|
||||
#[command(version = CARGO_PKG_VERSION.unwrap_or("Unknown"))]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(name = "cdk-cli", author = "thesimplekid <tsk@thesimplekid.com>", version = CARGO_PKG_VERSION.unwrap_or("Unknown"), about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Database engine to use (sqlite/redb)
|
||||
#[arg(short, long, default_value = "sqlite")]
|
||||
@@ -52,6 +58,11 @@ struct Cli {
|
||||
/// Currency unit to use for the wallet
|
||||
#[arg(short, long, default_value = "sat")]
|
||||
unit: String,
|
||||
/// Use Tor transport (only when built with --features tor). Defaults to 'on' when feature is enabled.
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
#[arg(long = "tor", value_enum, default_value_t = TorToggle::On)]
|
||||
transport: TorToggle,
|
||||
/// Subcommand to run
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
@@ -120,8 +131,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
fs::create_dir_all(&work_dir)?;
|
||||
|
||||
let localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync> =
|
||||
match args.engine.as_str() {
|
||||
"sqlite" => {
|
||||
@@ -181,7 +190,6 @@ async fn main() -> Result<()> {
|
||||
// The constructor will automatically load wallets for this currency unit
|
||||
let multi_mint_wallet = match &args.proxy {
|
||||
Some(proxy_url) => {
|
||||
// Create MultiMintWallet with proxy configuration
|
||||
MultiMintWallet::new_with_proxy(
|
||||
localstore.clone(),
|
||||
seed,
|
||||
@@ -190,7 +198,29 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()).await?,
|
||||
None => {
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
{
|
||||
match args.transport {
|
||||
TorToggle::On => {
|
||||
MultiMintWallet::new_with_tor(
|
||||
localstore.clone(),
|
||||
seed,
|
||||
currency_unit.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
TorToggle::Off => {
|
||||
MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone())
|
||||
.await?
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()).await?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match &args.command {
|
||||
|
||||
@@ -21,6 +21,17 @@ bip353 = ["dep:hickory-resolver"]
|
||||
swagger = ["mint", "dep:utoipa", "cdk-common/swagger"]
|
||||
bench = []
|
||||
http_subscription = []
|
||||
tor = [
|
||||
"wallet",
|
||||
"dep:arti-client",
|
||||
"dep:arti-hyper",
|
||||
"dep:hyper",
|
||||
"dep:http",
|
||||
"dep:rustls",
|
||||
"dep:tor-rtcompat",
|
||||
"dep:tls-api",
|
||||
"dep:tls-api-native-tls",
|
||||
]
|
||||
prometheus = ["dep:cdk-prometheus"]
|
||||
|
||||
[dependencies]
|
||||
@@ -40,6 +51,7 @@ serde_json.workspace = true
|
||||
serde_with.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
futures = { workspace = true, optional = true, features = ["alloc"] }
|
||||
url.workspace = true
|
||||
utoipa = { workspace = true, optional = true }
|
||||
@@ -70,13 +82,23 @@ tokio-tungstenite = { workspace = true, features = [
|
||||
"rustls-tls-native-roots",
|
||||
"connect"
|
||||
] }
|
||||
# Tor dependencies (optional; enabled by feature "tor")
|
||||
hyper = { version = "0.14", optional = true, features = ["client", "http1", "http2"] }
|
||||
http = { version = "0.2", optional = true }
|
||||
arti-client = { version = "0.19.0", optional = true, default-features = false, features = ["tokio", "rustls"] }
|
||||
arti-hyper = { version = "0.19.0", optional = true }
|
||||
rustls = { workspace = true, optional = true }
|
||||
tor-rtcompat = { version = "0.19.0", optional = true, features = ["tokio", "rustls"] }
|
||||
tls-api = { version = "0.9", optional = true }
|
||||
tls-api-native-tls = { version = "0.9", optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] }
|
||||
cdk-signatory = { workspace = true, default-features = false }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
ring = { version = "0.17.14", features = ["wasm32_unknown_unknown_js"] }
|
||||
rustls = { workspace = true, optional = true }
|
||||
|
||||
uuid = { workspace = true, features = ["js"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#![warn(missing_docs)]
|
||||
#![warn(rustdoc::bare_urls)]
|
||||
|
||||
// Disallow enabling `tor` feature on wasm32 with a clear error.
|
||||
#[cfg(all(target_arch = "wasm32", feature = "tor"))]
|
||||
compile_error!("The 'tor' feature is not supported on wasm32 targets (browser). Disable the 'tor' feature or use a non-wasm32 target.");
|
||||
|
||||
pub mod cdk_database {
|
||||
//! CDK Database
|
||||
pub use cdk_common::database::Error;
|
||||
|
||||
@@ -47,6 +47,31 @@ impl<T> HttpClient<T>
|
||||
where
|
||||
T: Transport + Send + Sync + 'static,
|
||||
{
|
||||
/// Create new [`HttpClient`] with a provided transport implementation.
|
||||
#[cfg(feature = "auth")]
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new [`HttpClient`] with a provided transport implementation.
|
||||
#[cfg(not(feature = "auth"))]
|
||||
pub fn with_transport(mint_url: MintUrl, transport: T) -> Self {
|
||||
Self {
|
||||
transport: transport.into(),
|
||||
mint_url,
|
||||
cache_support: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new [`HttpClient`]
|
||||
#[cfg(feature = "auth")]
|
||||
pub fn new(mint_url: MintUrl, auth_wallet: Option<AuthWallet>) -> Self {
|
||||
@@ -137,22 +162,20 @@ where
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or_default();
|
||||
|
||||
let transport = self.transport.clone();
|
||||
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.transport.http_get(url, auth_token.clone()).await,
|
||||
nut19::Method::Post => {
|
||||
self.transport
|
||||
.http_post(url, auth_token.clone(), payload)
|
||||
.await
|
||||
}
|
||||
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() {
|
||||
@@ -197,12 +220,9 @@ where
|
||||
#[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(self
|
||||
.transport
|
||||
.http_get::<KeysResponse>(url, None)
|
||||
.await?
|
||||
.keysets)
|
||||
Ok(transport.http_get::<KeysResponse>(url, None).await?.keysets)
|
||||
}
|
||||
|
||||
/// Get Keyset Keys [NUT-01]
|
||||
@@ -212,7 +232,8 @@ where
|
||||
.mint_url
|
||||
.join_paths(&["v1", "keys", &keyset_id.to_string()])?;
|
||||
|
||||
let keys_response = self.transport.http_get::<KeysResponse>(url, None).await?;
|
||||
let transport = self.transport.clone();
|
||||
let keys_response = transport.http_get::<KeysResponse>(url, None).await?;
|
||||
|
||||
Ok(keys_response.keysets.first().unwrap().clone())
|
||||
}
|
||||
@@ -221,7 +242,8 @@ where
|
||||
#[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
|
||||
let transport = self.transport.clone();
|
||||
transport.http_get(url, None).await
|
||||
}
|
||||
|
||||
/// Mint Quote [NUT-04]
|
||||
@@ -368,7 +390,8 @@ where
|
||||
/// Helper to get mint info
|
||||
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?;
|
||||
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 = (
|
||||
|
||||
@@ -21,8 +21,11 @@ pub mod transport;
|
||||
/// Auth HTTP Client with async transport
|
||||
#[cfg(feature = "auth")]
|
||||
pub type AuthHttpClient = http_client::AuthHttpClient<transport::Async>;
|
||||
/// Http Client with async transport
|
||||
/// Default Http Client with async transport (non-Tor)
|
||||
pub type HttpClient = http_client::HttpClient<transport::Async>;
|
||||
/// Tor Http Client with async transport (only when `tor` feature is enabled and not on wasm32)
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
pub type TorHttpClient = http_client::HttpClient<transport::tor_transport::TorAsync>;
|
||||
|
||||
/// Interface that connects a wallet to a mint. Typically represents an [HttpClient].
|
||||
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
|
||||
|
||||
@@ -27,26 +27,30 @@ pub trait Transport: Default + Send + Sync + Debug + Clone {
|
||||
/// Make the transport to use a given proxy
|
||||
fn with_proxy(
|
||||
&mut self,
|
||||
proxy: Url,
|
||||
proxy: url::Url,
|
||||
host_matcher: Option<&str>,
|
||||
accept_invalid_certs: bool,
|
||||
) -> Result<(), Error>;
|
||||
) -> Result<(), super::Error>;
|
||||
|
||||
/// HTTP Get request
|
||||
async fn http_get<R>(&self, url: Url, auth: Option<AuthToken>) -> Result<R, Error>
|
||||
async fn http_get<R>(
|
||||
&self,
|
||||
url: url::Url,
|
||||
auth: Option<cdk_common::AuthToken>,
|
||||
) -> Result<R, super::Error>
|
||||
where
|
||||
R: DeserializeOwned;
|
||||
R: serde::de::DeserializeOwned;
|
||||
|
||||
/// HTTP Post request
|
||||
async fn http_post<P, R>(
|
||||
&self,
|
||||
url: Url,
|
||||
auth_token: Option<AuthToken>,
|
||||
url: url::Url,
|
||||
auth_token: Option<cdk_common::AuthToken>,
|
||||
payload: &P,
|
||||
) -> Result<R, Error>
|
||||
) -> Result<R, super::Error>
|
||||
where
|
||||
P: Serialize + ?Sized + Send + Sync,
|
||||
R: DeserializeOwned;
|
||||
P: serde::Serialize + ?Sized + Send + Sync,
|
||||
R: serde::de::DeserializeOwned;
|
||||
}
|
||||
|
||||
/// Async transport for Http
|
||||
@@ -212,3 +216,6 @@ impl Transport for Async {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
pub mod tor_transport;
|
||||
|
||||
330
crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs
Normal file
330
crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
///! Tor transport implementation (non-wasm32 only)
|
||||
use std::sync::Arc;
|
||||
|
||||
use arti_client::{TorClient, TorClientConfig};
|
||||
use arti_hyper::ArtiHttpConnector;
|
||||
use async_trait::async_trait;
|
||||
use cdk_common::AuthToken;
|
||||
use http::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::http::{Method, Request, Uri};
|
||||
use hyper::{Body, Client};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tls_api::{TlsConnector as _, TlsConnectorBuilder as _};
|
||||
use tokio::sync::OnceCell;
|
||||
use url::Url;
|
||||
|
||||
use super::super::Error;
|
||||
use crate::wallet::getrandom;
|
||||
use crate::wallet::mint_connector::transport::{ErrorResponse, Transport};
|
||||
|
||||
/// Fixed-size pool size
|
||||
pub const DEFAULT_TOR_POOL_SIZE: usize = 5;
|
||||
|
||||
/// Tor transport that maintains a pool of isolated TorClient handles
|
||||
#[derive(Clone)]
|
||||
pub struct TorAsync {
|
||||
salt: [u8; 4],
|
||||
size: usize,
|
||||
pool: Arc<OnceCell<Vec<TorClient<tor_rtcompat::PreferredRuntime>>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TorAsync {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let pool_len = self.pool.get().map(|p| p.len());
|
||||
f.debug_struct("TorAsync")
|
||||
.field("configured_pool_size", &self.size)
|
||||
.field("initialized_pool_size", &pool_len)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// salt generator (sync, tiny, uses OS RNG)
|
||||
#[inline]
|
||||
fn gen_salt() -> [u8; 4] {
|
||||
let mut s = [0u8; 4];
|
||||
getrandom(&mut s).expect("failed to obtain random bytes for TorAsync salt");
|
||||
s
|
||||
}
|
||||
|
||||
impl Default for TorAsync {
|
||||
fn default() -> Self {
|
||||
// Do NOT bootstrap here; keep Default cheap and non-blocking.
|
||||
Self {
|
||||
size: DEFAULT_TOR_POOL_SIZE,
|
||||
pool: Arc::new(OnceCell::new()),
|
||||
salt: gen_salt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TorAsync {
|
||||
/// Create a TorAsync with default pool size (lazy bootstrapping)
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a TorAsync with the given pool size (lazy bootstrapping)
|
||||
pub fn with_pool_size(size: usize) -> Self {
|
||||
let size = size.max(1);
|
||||
Self {
|
||||
size,
|
||||
pool: Arc::new(OnceCell::new()),
|
||||
salt: gen_salt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the Tor client pool is initialized; build on first use.
|
||||
async fn ensure_pool(&self) -> Result<Vec<TorClient<tor_rtcompat::PreferredRuntime>>, Error> {
|
||||
let size = self.size;
|
||||
let pool_ref = self
|
||||
.pool
|
||||
.get_or_try_init(|| async move {
|
||||
let base = TorClient::create_bootstrapped(TorClientConfig::default())
|
||||
.await
|
||||
.map_err(|e| Error::Custom(e.to_string()))?;
|
||||
let mut clients = Vec::with_capacity(size);
|
||||
for _ in 0..size {
|
||||
clients.push(base.isolated_client());
|
||||
}
|
||||
Ok::<Vec<TorClient<tor_rtcompat::PreferredRuntime>>, Error>(clients)
|
||||
})
|
||||
.await?;
|
||||
Ok(pool_ref.clone())
|
||||
}
|
||||
|
||||
/// Choose client index deterministically based on authority (scheme, host, port),
|
||||
/// HTTP method, path+query, and optionally a body fingerprint.
|
||||
#[inline]
|
||||
fn index_for_request(
|
||||
&self,
|
||||
method: &http::Method,
|
||||
url: &Url,
|
||||
body: Option<&[u8]>,
|
||||
pool_len: usize,
|
||||
) -> usize {
|
||||
// Tiny, dependency-free, stable hash (FNV-1a 64-bit)
|
||||
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
|
||||
const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
|
||||
fn fnv1a(mut h: u64, bytes: &[u8]) -> u64 {
|
||||
for &b in bytes {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
let mut h = FNV_OFFSET;
|
||||
|
||||
// Mix in salt first so it affects the entire hash space
|
||||
h = fnv1a(h, &self.salt);
|
||||
// Include scheme and authority
|
||||
h = fnv1a(h, url.scheme().as_bytes());
|
||||
h = fnv1a(h, b"://");
|
||||
if let Some(host) = url.host_str() {
|
||||
h = fnv1a(h, host.as_bytes());
|
||||
}
|
||||
if let Some(port) = url.port() {
|
||||
h = fnv1a(h, b":");
|
||||
let p = port.to_string();
|
||||
h = fnv1a(h, p.as_bytes());
|
||||
}
|
||||
// Include HTTP method
|
||||
h = fnv1a(h, method.as_str().as_bytes());
|
||||
h = fnv1a(h, b" ");
|
||||
// Include path and query
|
||||
h = fnv1a(h, url.path().as_bytes());
|
||||
if let Some(q) = url.query() {
|
||||
h = fnv1a(h, b"?");
|
||||
h = fnv1a(h, q.as_bytes());
|
||||
}
|
||||
// Optionally include body (full). Could be trimmed in the future if needed.
|
||||
if let Some(b) = body {
|
||||
h = fnv1a(h, b);
|
||||
}
|
||||
(h as usize) % pool_len.max(1)
|
||||
}
|
||||
|
||||
async fn request<R>(
|
||||
&self,
|
||||
method: http::Method,
|
||||
url: Url,
|
||||
auth: Option<AuthToken>,
|
||||
mut body: Option<Vec<u8>>,
|
||||
) -> Result<R, Error>
|
||||
where
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let tls = tls_api_native_tls::TlsConnector::builder()
|
||||
.map_err(|e| Error::Custom(format!("{e:?}")))?
|
||||
.build()
|
||||
.map_err(|e| Error::Custom(format!("{e:?}")))?;
|
||||
|
||||
// Lazily initialize the pool and deterministically select a client
|
||||
let pool = self.ensure_pool().await?;
|
||||
let idx = self.index_for_request(&method, &url, body.as_deref(), pool.len());
|
||||
let client_for_request = pool[idx].clone();
|
||||
|
||||
let connector = ArtiHttpConnector::new(client_for_request, tls);
|
||||
let client: Client<_> = Client::builder().build(connector);
|
||||
|
||||
let uri: Uri = url
|
||||
.as_str()
|
||||
.parse::<Uri>()
|
||||
.map_err(|e| Error::Custom(e.to_string()))?;
|
||||
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
builder = builder.header(header::ACCEPT, "application/json");
|
||||
|
||||
let mut req = if let Some(b) = body.take() {
|
||||
builder
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(b))
|
||||
.map_err(|e| Error::Custom(e.to_string()))?
|
||||
} else {
|
||||
builder
|
||||
.body(Body::empty())
|
||||
.map_err(|e| Error::Custom(e.to_string()))?
|
||||
};
|
||||
|
||||
if let Some(auth) = auth {
|
||||
let key = auth.header_key();
|
||||
let val = auth.to_string();
|
||||
req.headers_mut().insert(
|
||||
HeaderName::from_bytes(key.as_bytes()).map_err(|e| Error::Custom(e.to_string()))?,
|
||||
HeaderValue::from_str(&val).map_err(|e| Error::Custom(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.request(req)
|
||||
.await
|
||||
.map_err(|e| Error::HttpError(None, e.to_string()))?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let bytes = hyper::body::to_bytes(resp.into_body())
|
||||
.await
|
||||
.map_err(|e| Error::HttpError(None, e.to_string()))?;
|
||||
|
||||
if !(200..300).contains(&status) {
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
return Err(Error::HttpError(Some(status), text));
|
||||
}
|
||||
|
||||
serde_json::from_slice::<R>(&bytes).map_err(|err| {
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
tracing::warn!("Http Response error: {}", err);
|
||||
match ErrorResponse::from_json(&text) {
|
||||
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
|
||||
Err(err) => err.into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for TorAsync {
|
||||
fn with_proxy(
|
||||
&mut self,
|
||||
_proxy: Url,
|
||||
_host_matcher: Option<&str>,
|
||||
_accept_invalid_certs: bool,
|
||||
) -> Result<(), Error> {
|
||||
panic!("not supported with TorAsync transport");
|
||||
}
|
||||
|
||||
async fn http_get<R>(
|
||||
&self,
|
||||
url: url::Url,
|
||||
auth: Option<cdk_common::AuthToken>,
|
||||
) -> Result<R, super::super::Error>
|
||||
where
|
||||
R: serde::de::DeserializeOwned,
|
||||
{
|
||||
self.request::<R>(Method::GET, url, auth, None).await
|
||||
}
|
||||
|
||||
async fn http_post<P, R>(
|
||||
&self,
|
||||
url: url::Url,
|
||||
auth_token: Option<cdk_common::AuthToken>,
|
||||
payload: &P,
|
||||
) -> Result<R, super::super::Error>
|
||||
where
|
||||
P: serde::Serialize + ?Sized + Send + Sync,
|
||||
R: serde::de::DeserializeOwned,
|
||||
{
|
||||
let body = serde_json::to_vec(payload).map_err(|e| Error::Custom(e.to_string()))?;
|
||||
self.request::<R>(Method::POST, url, auth_token, Some(body))
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
|
||||
async fn resolve_dns_txt(&self, domain: &str) -> Result<Vec<String>, Error> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Answer {
|
||||
#[serde(default)]
|
||||
data: String,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
r#type: u32,
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DnsResp {
|
||||
#[serde(default)]
|
||||
Answer: Option<Vec<Answer>>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
Status: Option<u32>,
|
||||
}
|
||||
|
||||
fn dequote_txt(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_quote = false;
|
||||
let mut buf = String::new();
|
||||
for ch in s.chars() {
|
||||
if ch == '"' {
|
||||
if in_quote {
|
||||
result.push_str(&buf);
|
||||
buf.clear();
|
||||
in_quote = false;
|
||||
} else {
|
||||
in_quote = true;
|
||||
}
|
||||
} else if in_quote {
|
||||
buf.push(ch);
|
||||
}
|
||||
}
|
||||
if !result.is_empty() {
|
||||
result
|
||||
} else {
|
||||
s.trim_matches('"').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
let mut url =
|
||||
Url::parse("https://dns.google/resolve").map_err(|e| Error::Custom(e.to_string()))?;
|
||||
{
|
||||
let mut qp = url.query_pairs_mut();
|
||||
qp.append_pair("name", domain);
|
||||
qp.append_pair("type", "TXT");
|
||||
}
|
||||
|
||||
let resp: DnsResp = self
|
||||
.request::<DnsResp>(Method::GET, url, None, None::<Vec<u8>>)
|
||||
.await?;
|
||||
|
||||
let answers = resp.Answer.unwrap_or_default();
|
||||
let txts = answers
|
||||
.into_iter()
|
||||
.filter(|a| !a.data.is_empty())
|
||||
.map(|a| dequote_txt(&a.data))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(txts)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ use crate::OidcClient;
|
||||
|
||||
#[cfg(feature = "auth")]
|
||||
mod auth;
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
pub use mint_connector::TorHttpClient;
|
||||
mod balance;
|
||||
mod builder;
|
||||
mod issue;
|
||||
|
||||
@@ -25,6 +25,8 @@ use crate::nuts::nut00::ProofsMethods;
|
||||
use crate::nuts::nut23::QuoteState;
|
||||
use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SpendingConditions, Token};
|
||||
use crate::types::Melted;
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
use crate::wallet::mint_connector::transport::tor_transport::TorAsync;
|
||||
use crate::wallet::types::MintQuote;
|
||||
use crate::{Amount, Wallet};
|
||||
|
||||
@@ -114,6 +116,9 @@ pub struct MultiMintWallet {
|
||||
wallets: Arc<RwLock<BTreeMap<MintUrl, Wallet>>>,
|
||||
/// Proxy configuration for HTTP clients (optional)
|
||||
proxy_config: Option<url::Url>,
|
||||
/// Shared Tor transport to be cloned into each TorHttpClient (if enabled)
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
shared_tor_transport: Option<TorAsync>,
|
||||
}
|
||||
|
||||
impl MultiMintWallet {
|
||||
@@ -129,6 +134,8 @@ impl MultiMintWallet {
|
||||
unit,
|
||||
wallets: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
proxy_config: None,
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
shared_tor_transport: None,
|
||||
};
|
||||
|
||||
// Automatically load wallets from database for this currency unit
|
||||
@@ -153,6 +160,35 @@ impl MultiMintWallet {
|
||||
unit,
|
||||
wallets: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
proxy_config: Some(proxy_url),
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
shared_tor_transport: None,
|
||||
};
|
||||
|
||||
// Automatically load wallets from database for this currency unit
|
||||
wallet.load_wallets().await?;
|
||||
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
/// Create a new [MultiMintWallet] with Tor transport for all wallets
|
||||
///
|
||||
/// When the `tor` feature is enabled (and not on wasm32), this constructor
|
||||
/// creates a single Tor transport (TorAsync) that is cloned into each
|
||||
/// TorHttpClient used by per-mint Wallets. This ensures only one Tor instance
|
||||
/// is bootstrapped and shared across wallets.
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
pub async fn new_with_tor(
|
||||
localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
|
||||
seed: [u8; 64],
|
||||
unit: CurrencyUnit,
|
||||
) -> Result<Self, Error> {
|
||||
let wallet = Self {
|
||||
localstore,
|
||||
seed,
|
||||
unit,
|
||||
wallets: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
proxy_config: None,
|
||||
shared_tor_transport: Some(TorAsync::new()),
|
||||
};
|
||||
|
||||
// Automatically load wallets from database for this currency unit
|
||||
@@ -195,14 +231,55 @@ impl MultiMintWallet {
|
||||
.client(client)
|
||||
.build()?
|
||||
} else {
|
||||
// Create wallet with default client
|
||||
Wallet::new(
|
||||
&mint_url.to_string(),
|
||||
self.unit.clone(),
|
||||
self.localstore.clone(),
|
||||
self.seed,
|
||||
target_proof_count,
|
||||
)?
|
||||
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
|
||||
if let Some(tor) = &self.shared_tor_transport {
|
||||
// Create wallet with Tor transport client, cloning the shared transport
|
||||
let client = {
|
||||
let transport = tor.clone();
|
||||
#[cfg(feature = "auth")]
|
||||
{
|
||||
crate::wallet::TorHttpClient::with_transport(
|
||||
mint_url.clone(),
|
||||
transport,
|
||||
None,
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "auth"))]
|
||||
{
|
||||
crate::wallet::TorHttpClient::with_transport(mint_url.clone(), transport)
|
||||
}
|
||||
};
|
||||
|
||||
WalletBuilder::new()
|
||||
.mint_url(mint_url.clone())
|
||||
.unit(self.unit.clone())
|
||||
.localstore(self.localstore.clone())
|
||||
.seed(self.seed)
|
||||
.target_proof_count(target_proof_count.unwrap_or(3))
|
||||
.client(client)
|
||||
.build()?
|
||||
} else {
|
||||
// Create wallet with default client
|
||||
Wallet::new(
|
||||
&mint_url.to_string(),
|
||||
self.unit.clone(),
|
||||
self.localstore.clone(),
|
||||
self.seed,
|
||||
target_proof_count,
|
||||
)?
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
// Create wallet with default client
|
||||
Wallet::new(
|
||||
&mint_url.to_string(),
|
||||
self.unit.clone(),
|
||||
self.localstore.clone(),
|
||||
self.seed,
|
||||
target_proof_count,
|
||||
)?
|
||||
}
|
||||
};
|
||||
|
||||
let mut wallets = self.wallets.write().await;
|
||||
|
||||
Reference in New Issue
Block a user