瀏覽代碼

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
lollerfirst 1 月之前
父節點
當前提交
e581dcbf62

+ 1 - 0
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

+ 38 - 8
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 <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 {

+ 22 - 0
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"

+ 4 - 0
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;

+ 37 - 14
crates/cdk/src/wallet/mint_connector/http_client.rs

@@ -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 = (

+ 4 - 1
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<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))]

+ 16 - 9
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<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 - 0
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<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)
+    }
+}

+ 2 - 0
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;

+ 85 - 8
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<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;