Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- GUI: Add a network proxy mode in the settings UI so the application can be routed through a user-configured system Tor SOCKS5 proxy.
- GUI + SWAP + ASB: Route the relevant HTTP, wallet, updater, and transport paths through shared system Tor SOCKS5 plumbing instead of each subsystem carrying its own proxy logic.

## [4.5.0] - 2026-04-27

- ASB+CONTROLLER: `get-swaps` now includes the `btc_redeem_fee` per swap: the fee Alice paid (or will pay) for the Bitcoin redeem transaction.
Expand Down
34 changes: 34 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ members = [
"swap-proptest",
"swap-serde",
"throttle",
"tor-socks5",
"tracing-ext",
]

Expand Down Expand Up @@ -92,7 +93,9 @@ uuid = { version = "1", features = ["v4"] }
# Tokio
testcontainers = "0.15"
tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync"] }
tokio-socks = "0.5.2"
tokio-util = { version = "0.7", features = ["io", "codec", "rt"] }
tor-socks5 = { path = "./tor-socks5" }

# Tor/Arti crates
arti-client = { git = "https://github.com/eigenwallet/arti", branch = "downgraded_rusqlite_arti_2_2_0", default-features = false }
Expand Down
1 change: 1 addition & 0 deletions bitcoin-wallet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ bdk_wallet = { workspace = true, features = ["rusqlite", "test-utils"] }
bitcoin = { workspace = true }
derive_builder = "0.20.2"
electrum-pool = { path = "../electrum-pool" }
tor-socks5 = { workspace = true, features = ["reqwest"] }
futures = { workspace = true }
moka = { version = "0.12", features = ["sync", "future"] }
proptest = "1"
Expand Down
13 changes: 11 additions & 2 deletions bitcoin-wallet/src/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2614,8 +2614,17 @@ mod mempool_client {
_ => bail!("mempool.space fee estimation unsupported for network"),
};

let client = reqwest::Client::builder()
.timeout(HTTP_TIMEOUT)
let mut builder = reqwest::Client::builder().timeout(HTTP_TIMEOUT);

if let Some(proxy) = tor_socks5::proxy_config(tor_socks5::Subsystem::Bitcoin) {
builder = builder.proxy(
proxy
.reqwest_proxy()
.context("Failed to configure system Tor SOCKS5 for mempool.space")?,
);
}

let client = builder
.build()
.context("Failed to build mempool.space HTTP client")?;

Expand Down
1 change: 1 addition & 0 deletions electrum-pool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ bitcoin = { workspace = true }
futures = { workspace = true }
once_cell = { workspace = true }
tokio = { workspace = true }
tor-socks5 = { workspace = true }
tracing = { workspace = true }
18 changes: 17 additions & 1 deletion electrum-pool/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use backoff::{Error as BackoffError, ExponentialBackoff};
use bdk_electrum::BdkElectrumClient;
use bdk_electrum::electrum_client::{Client, ConfigBuilder, ElectrumApi, Error};
use bdk_electrum::electrum_client::{Client, ConfigBuilder, ElectrumApi, Error, Socks5Config};
use bitcoin::Transaction;
use futures::future::join_all;
use once_cell::sync::OnceCell;
Expand Down Expand Up @@ -569,7 +569,15 @@ impl ElectrumClientFactory<BdkElectrumClient<Client>> for BdkElectrumClientFacto
url: &str,
config: &ElectrumBalancerConfig,
) -> Result<Arc<BdkElectrumClient<Client>>, Error> {
let socks5 = tor_socks5::proxy_config(tor_socks5::Subsystem::Electrum).map(|proxy| {
Socks5Config::with_credentials(
proxy.addr.to_string(),
proxy.username.to_string(),
proxy.password.to_string(),
)
});
let client_config = ConfigBuilder::new()
.socks5(socks5.clone())
.timeout(Some(config.request_timeout))
// TODO: Why is this set to 1?
// The goal of this crate is to extract retry logic out of the electrum client library
Expand All @@ -579,6 +587,14 @@ impl ElectrumClientFactory<BdkElectrumClient<Client>> for BdkElectrumClientFacto
.retry(1)
.build();

if socks5.is_some() {
debug!(
server_url = url,
proxy = %tor_socks5::current_addr(),
"Using system Tor SOCKS5 proxy for Electrum connection"
);
}

let client = Client::from_config(url, client_config).map_err(|e| {
// Wrap connection errors with DNS resolution context
match &e {
Expand Down
1 change: 1 addition & 0 deletions monero-harness/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ impl<'c> Monero {
hostname: "127.0.0.1".to_string(),
port: monerod_port,
ssl: false,
proxy_address: None,
}
};

Expand Down
1 change: 1 addition & 0 deletions monero-rpc-pool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ tracing-subscriber = { workspace = true }
# Async runtime
crossbeam = "0.8.4"
tokio = { workspace = true, features = ["full"] }
tor-socks5 = { workspace = true }

# Serialization
chrono = { version = "0.4", features = ["serde"] }
Expand Down
2 changes: 2 additions & 0 deletions monero-rpc-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use proxy::{proxy_handler, stats_handler};
pub struct AppState {
pub node_pool: Arc<NodePool>,
pub tor_client: Option<TorClientArc>,
pub system_tor_socks5: Option<tor_socks5::ProxyConfig>,
pub connection_pool: crate::connection_pool::ConnectionPool,
}

Expand Down Expand Up @@ -109,6 +110,7 @@ pub async fn create_app_with_receiver(
let app_state = AppState {
node_pool,
tor_client: config.tor_client,
system_tor_socks5: tor_socks5::proxy_config(tor_socks5::Subsystem::MoneroRpc),
connection_pool: crate::connection_pool::ConnectionPool::new(),
};

Expand Down
18 changes: 17 additions & 1 deletion monero-rpc-pool/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,16 @@ async fn proxy_to_single_node(
.await
.map_err(|e| SingleRequestError::ConnectionError(format!("{e:?}")))?;

Box::new(stream)
} else if let Some(proxy) = state
.system_tor_socks5
.filter(|_| !request.clearnet_whitelisted())
{
let stream = proxy
.connect(address)
.await
.map_err(|e| SingleRequestError::ConnectionError(format!("{e:?}")))?;

Box::new(stream)
} else {
let stream = TcpStream::connect(address)
Expand Down Expand Up @@ -530,7 +540,13 @@ async fn proxy_to_single_node(

tracing::trace!(
"Established new connection via {}{}",
if use_tor { "Tor" } else { "clearnet" },
if use_tor {
"Tor"
} else if state.system_tor_socks5.is_some() && !request.clearnet_whitelisted() {
"system Tor SOCKS5"
} else {
"clearnet"
},
if node.0 == "https" { " with TLS" } else { "" }
);
}
Expand Down
1 change: 1 addition & 0 deletions monero-sys/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ monero-address = { workspace = true }
monero-oxide-ext = { path = "../monero-oxide-ext" }
swap-serde = { path = "../swap-serde" }
throttle = { path = "../throttle" }
tor-socks5 = { workspace = true }

[build-dependencies]
cmake = "0.1.54"
Expand Down
69 changes: 68 additions & 1 deletion monero-sys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use bridge::ffi::{self};
use typeshare::typeshare;
use uuid::Uuid;


/// Approval callback for transactions
/// The callback receives (txid, amount, fee) and returns whether to proceed with the transaction
pub type ApprovalCallback = Arc<
Expand Down Expand Up @@ -202,6 +203,7 @@ pub struct Daemon {
pub hostname: String,
pub port: u16,
pub ssl: bool,
pub proxy_address: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -1075,6 +1077,7 @@ impl WalletHandle {
hostname: "localhost".to_string(),
port: 18081,
ssl: false,
proxy_address: None,
},
&wallet_name,
)?;
Expand Down Expand Up @@ -1895,7 +1898,7 @@ impl FfiWallet {
let_cxx_string!(daemon_address = daemon_address);
let_cxx_string!(daemon_username = "");
let_cxx_string!(daemon_password = "");
let_cxx_string!(proxy_address = "");
let_cxx_string!(proxy_address = daemon.proxy_address.as_deref().unwrap_or(""));

let raw_wallet = &mut self.inner;

Expand Down Expand Up @@ -3116,11 +3119,21 @@ impl TryFrom<String> for Daemon {
.ok_or_else(|| anyhow::anyhow!("No port found in URL"))?;

let ssl = url.scheme() == "https";
// wallet2's C++ `init(..., proxy_address)` accepts only a host:port
// string — no SOCKS5 credentials — so we use `proxy_addr()` instead of
// `proxy_config(Subsystem::…)`. Consequence: Monero wallet traffic
// cannot be stream-isolated from other wallet connections on the Tor
// side (all share the same circuit). See `tor-socks5` module docs.
let proxy_address = (!tor_socks5::is_local_host(&hostname))
.then(|| tor_socks5::proxy_addr())
.flatten()
.map(|a| a.to_string());

Ok(Daemon {
hostname,
port,
ssl,
proxy_address,
})
}
}
Expand Down Expand Up @@ -3345,3 +3358,57 @@ fn backoff(
.with_max_interval(max_interval)
.build()
}

#[cfg(test)]
mod tests {
use super::*;

/// `tor_socks5` is a process-global singleton, so the Daemon proxy tests
/// serialize on this lock to stop `cargo test` parallelism from letting
/// one test's enable leak into another's disable.
static PROXY_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[test]
fn test_daemon_local_host_never_gets_proxy() {
let _guard = PROXY_STATE_LOCK.lock().unwrap();

// Even with the SOCKS5 singleton enabled, a loopback daemon URL must
// stay direct — wallet2 talking to 127.0.0.1 through Tor would be
// a configuration smell and waste a circuit.
tor_socks5::enable_with_addr(std::net::SocketAddrV4::new(
std::net::Ipv4Addr::new(127, 0, 0, 1),
9050,
));
let daemon = Daemon::try_from("http://127.0.0.1:18081".to_string()).unwrap();
assert_eq!(daemon.proxy_address, None);
tor_socks5::disable();
}

#[test]
fn test_daemon_remote_host_without_proxy() {
let _guard = PROXY_STATE_LOCK.lock().unwrap();

tor_socks5::disable();
let daemon = Daemon::try_from("http://xmr.example.com:18081".to_string()).unwrap();
assert_eq!(daemon.proxy_address, None);
assert_eq!(daemon.hostname, "xmr.example.com");
assert_eq!(daemon.port, 18081);
assert!(!daemon.ssl);
}

#[test]
fn test_daemon_remote_host_propagates_proxy_address() {
let _guard = PROXY_STATE_LOCK.lock().unwrap();

let proxy = std::net::SocketAddrV4::new(std::net::Ipv4Addr::new(10, 152, 152, 10), 9050);
tor_socks5::enable_with_addr(proxy);

let daemon = Daemon::try_from("https://xmr.example.com:18089".to_string()).unwrap();
// wallet2 needs a bare host:port string — no creds, since its C++
// `init(.., proxy_address)` doesn't accept a SOCKS5 auth pair.
assert_eq!(daemon.proxy_address, Some("10.152.152.10:9050".to_string()));
assert!(daemon.ssl);

tor_socks5::disable();
}
}
Loading