diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index 92eba990..6597d31a 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -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 diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 2d3bbfa1..01394a9e 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -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 ")] -#[command(version = CARGO_PKG_VERSION.unwrap_or("Unknown"))] -#[command(author, version, about, long_about = None)] +#[command(name = "cdk-cli", author = "thesimplekid ", 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 + 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 { diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 24e75cef..c21dc59d 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -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" diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index c1e89c7e..d26076eb 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -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; diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index 1a954a72..3533417c 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -47,6 +47,31 @@ impl HttpClient 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, + ) -> 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) -> 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, Error> { let url = self.mint_url.join_paths(&["v1", "keys"])?; + let transport = self.transport.clone(); - Ok(self - .transport - .http_get::(url, None) - .await? - .keysets) + Ok(transport.http_get::(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::(url, None).await?; + let transport = self.transport.clone(); + let keys_response = transport.http_get::(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 { 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 { 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 = ( diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs index 3b9a73ea..e199bdde 100644 --- a/crates/cdk/src/wallet/mint_connector/mod.rs +++ b/crates/cdk/src/wallet/mint_connector/mod.rs @@ -21,8 +21,11 @@ pub mod transport; /// Auth HTTP Client with async transport #[cfg(feature = "auth")] pub type AuthHttpClient = http_client::AuthHttpClient; -/// Http Client with async transport +/// Default Http Client with async transport (non-Tor) pub type HttpClient = http_client::HttpClient; +/// 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; /// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] diff --git a/crates/cdk/src/wallet/mint_connector/transport.rs b/crates/cdk/src/wallet/mint_connector/transport.rs index 112ebc24..8238c9ce 100644 --- a/crates/cdk/src/wallet/mint_connector/transport.rs +++ b/crates/cdk/src/wallet/mint_connector/transport.rs @@ -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(&self, url: Url, auth: Option) -> Result + async fn http_get( + &self, + url: url::Url, + auth: Option, + ) -> Result where - R: DeserializeOwned; + R: serde::de::DeserializeOwned; /// HTTP Post request async fn http_post( &self, - url: Url, - auth_token: Option, + url: url::Url, + auth_token: Option, payload: &P, - ) -> Result + ) -> Result 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; diff --git a/crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs b/crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs new file mode 100644 index 00000000..4fb93710 --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs @@ -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>>>, +} + +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>, 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::>, 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( + &self, + method: http::Method, + url: Url, + auth: Option, + mut body: Option>, + ) -> Result + 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::() + .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::(&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) => >::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( + &self, + url: url::Url, + auth: Option, + ) -> Result + where + R: serde::de::DeserializeOwned, + { + self.request::(Method::GET, url, auth, None).await + } + + async fn http_post( + &self, + url: url::Url, + auth_token: Option, + payload: &P, + ) -> Result + 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::(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, 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>, + #[allow(dead_code)] + #[serde(default)] + Status: Option, + } + + 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::(Method::GET, url, None, None::>) + .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::>(); + + Ok(txts) + } +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index c2adec46..5f2319f2 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -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; diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 125a4eb2..9534f1a1 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -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>>, /// Proxy configuration for HTTP clients (optional) proxy_config: Option, + /// Shared Tor transport to be cloned into each TorHttpClient (if enabled) + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + shared_tor_transport: Option, } 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 + Send + Sync>, + seed: [u8; 64], + unit: CurrencyUnit, + ) -> Result { + 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;