bdk_wallet::chain::CanonicalizationParams,
};
+#[cfg(any(feature = "electrum", feature = "esplora"))]
+use crate::commands::ProxyOpts;
+#[cfg(feature = "electrum")]
+use std::time::Duration;
#[cfg(feature = "cbf")]
use {crate::utils::trace_logger, bdk_kyoto::BuilderExt};
tokio::sync::Mutex<bdk_kyoto::UpdateSubscriber<bdk_kyoto::wallets::Single>>,
}
+/// Build the electrum [`Config`] from the wallet's SOCKS5 proxy options.
+///
+/// The hostname is handed to the proxy as a `TargetAddr::Domain`, so the target is
+/// resolved by the proxy rather than locally, and no DNS query leaks.
+#[cfg(feature = "electrum")]
+fn electrum_config(proxy_opts: &ProxyOpts) -> bdk_electrum::electrum_client::Config {
+ use bdk_electrum::electrum_client::{ConfigBuilder, Socks5Config};
+
+ let socks5 = proxy_opts
+ .proxy
+ .as_ref()
+ .map(|addr| match &proxy_opts.proxy_auth {
+ Some((user, password)) => {
+ Socks5Config::with_credentials(addr, user.clone(), password.clone())
+ }
+ None => Socks5Config::new(addr),
+ });
+
+ ConfigBuilder::new()
+ .socks5(socks5)
+ .retry(proxy_opts.retries)
+ .timeout(
+ proxy_opts
+ .timeout
+ .map(|secs| Duration::from_secs(secs as u64)),
+ )
+ .build()
+}
+
+/// Render the SOCKS5 proxy options as a URL for esplora's HTTP client.
+///
+/// `socks5h` rather than `socks5` so the proxy resolves the esplora hostname; with
+/// plain `socks5` the client resolves it locally first, leaking a DNS query that
+/// identifies the server being synced against.
+#[cfg(feature = "esplora")]
+fn esplora_proxy_url(proxy_opts: &ProxyOpts) -> Option<String> {
+ let addr = proxy_opts.proxy.as_ref()?;
+ let addr = addr
+ .strip_prefix("socks5h://")
+ .or_else(|| addr.strip_prefix("socks5://"))
+ .unwrap_or(addr);
+
+ Some(match &proxy_opts.proxy_auth {
+ Some((user, password)) => format!("socks5h://{user}:{password}@{addr}"),
+ None => format!("socks5h://{addr}"),
+ })
+}
+
+/// Reject a proxy the backend client does not understand.
+///
+/// `bitcoind`'s RPC client and the compact block filter backend have no SOCKS5
+/// support here, so a proxy set against them would silently do nothing.
+#[cfg(all(
+ any(feature = "electrum", feature = "esplora"),
+ any(feature = "rpc", feature = "cbf")
+))]
+fn reject_unsupported_proxy(proxy_opts: &ProxyOpts, backend: &str) -> Result<(), Error> {
+ match proxy_opts.proxy {
+ Some(_) => Err(Error::Generic(format!(
+ "The {backend} backend does not support a SOCKS5 proxy. Remove --proxy, or use the electrum or esplora backend."
+ ))),
+ None => Ok(()),
+ }
+}
+
#[cfg(any(
feature = "electrum",
feature = "esplora",
let client = match wallet_opts.client_type {
#[cfg(feature = "electrum")]
ClientType::Electrum => {
- let client = bdk_electrum::electrum_client::Client::new(url)
+ let config = electrum_config(&wallet_opts.proxy_opts);
+ let client = bdk_electrum::electrum_client::Client::from_config(url, config)
.map(bdk_electrum::BdkElectrumClient::new)?;
BlockchainClient::Electrum {
client: Box::new(client),
}
#[cfg(feature = "esplora")]
ClientType::Esplora => {
- let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?;
+ let mut builder = bdk_esplora::esplora_client::Builder::new(url)
+ .max_retries(wallet_opts.proxy_opts.retries as usize);
+ if let Some(proxy) = esplora_proxy_url(&wallet_opts.proxy_opts) {
+ builder = builder.proxy(&proxy);
+ }
+ if let Some(timeout) = wallet_opts.proxy_opts.timeout {
+ builder = builder.timeout(timeout as u64);
+ }
+ let client = builder.build_async()?;
BlockchainClient::Esplora {
client: Box::new(client),
parallel_requests: wallet_opts.parallel_requests,
#[cfg(feature = "rpc")]
ClientType::Rpc => {
+ #[cfg(any(feature = "electrum", feature = "esplora"))]
+ reject_unsupported_proxy(&wallet_opts.proxy_opts, "rpc")?;
let auth = match &wallet_opts.cookie {
Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()),
None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass(
#[cfg(feature = "cbf")]
ClientType::Cbf => {
+ #[cfg(any(feature = "electrum", feature = "esplora"))]
+ reject_unsupported_proxy(&wallet_opts.proxy_opts, "cbf")?;
let scan_type = bdk_kyoto::ScanType::Sync;
let builder = bdk_kyoto::builder::Builder::new(_wallet.network());
--- /dev/null
+//! The SOCKS5 proxy options must actually reach the blockchain client.
+//!
+//! These tests stand two TCP listeners in for the chain server and the proxy, so
+//! they need neither a real node nor a real SOCKS5 service: all that matters is
+//! which port the connection arrives on.
+#[cfg(any(feature = "electrum", feature = "esplora"))]
+mod test_proxy {
+ use crate::common::BdkCli;
+ use assert_cmd::Command;
+ #[cfg(feature = "rpc")]
+ use predicates::prelude::*;
+ use serde_json::Value;
+ use std::net::{TcpListener, TcpStream};
+ use std::sync::mpsc::{Receiver, channel};
+ use std::thread;
+ use std::time::Duration;
+ use tempfile::TempDir;
+
+ static WALLET_NAME: &str = "proxy_test_wallet";
+
+ fn spawn_listener() -> (String, Receiver<()>) {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind listener");
+ let addr = listener.local_addr().unwrap().to_string();
+ let (tx, rx) = channel();
+
+ thread::spawn(move || {
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => {
+ drop::<TcpStream>(stream);
+ if tx.send(()).is_err() {
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ (addr, rx)
+ }
+
+ fn connected(rx: &Receiver<()>) -> bool {
+ rx.recv_timeout(Duration::from_secs(5)).is_ok()
+ }
+
+ /// As above, but for an arbitrary client type and url.
+ fn setup_wallet_for(
+ client_type: &str,
+ url: &str,
+ proxy_addr: Option<&str>,
+ ) -> (BdkCli, TempDir) {
+ let temp_dir = TempDir::new().unwrap();
+ let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf()));
+
+ let desc = cli
+ .cmd("descriptor", &["--type", "wpkh"])
+ .output()
+ .expect("failed to generate descriptors");
+ let desc_values: Value =
+ serde_json::from_slice(&desc.stdout).expect("invalid JSON from descriptor");
+ let public = &desc_values["public_descriptors"];
+ let ext_desc = public["external"].as_str().unwrap();
+ let int_desc = public["internal"].as_str().unwrap();
+
+ let mut cmd = cli.build_base_cmd();
+ cmd.arg("wallet")
+ .arg("--wallet")
+ .arg(WALLET_NAME)
+ .arg("config")
+ .arg("--ext-descriptor")
+ .arg(ext_desc)
+ .arg("--int-descriptor")
+ .arg(int_desc)
+ .arg("--client-type")
+ .arg(client_type)
+ .arg("--database-type")
+ .arg("sqlite")
+ .arg("--url")
+ .arg(url);
+ if let Some(proxy) = proxy_addr {
+ cmd.arg("--proxy").arg(proxy);
+ }
+ cmd.assert().success();
+
+ (cli, temp_dir)
+ }
+
+ /// Runs `sync` and returns the command, so callers can assert on how it failed.
+ fn sync_cmd(cli: &BdkCli) -> Command {
+ let mut cmd = cli.wallet_cmd(&["--wallet", WALLET_NAME, "sync"]);
+ cmd.timeout(Duration::from_secs(30));
+ cmd
+ }
+
+ /// With `--proxy` set, the connection must go to the proxy and never to the
+ /// chain server directly.
+ #[cfg(feature = "electrum")]
+ #[test]
+ fn test_electrum_sync_goes_through_the_proxy() {
+ let (server_addr, server_rx) = spawn_listener();
+ let (proxy_addr, proxy_rx) = spawn_listener();
+
+ let (cli, _temp_dir) = setup_wallet_for(
+ "electrum",
+ &format!("tcp://{server_addr}"),
+ Some(&proxy_addr),
+ );
+
+ // The stub proxy does not speak SOCKS5, so the sync must fail rather than
+ // quietly falling back to a direct connection.
+ sync_cmd(&cli).assert().failure();
+
+ assert!(
+ connected(&proxy_rx),
+ "the proxy was never contacted: traffic bypassed --proxy"
+ );
+ assert!(
+ !connected(&server_rx),
+ "a direct connection reached the chain server despite --proxy"
+ );
+ }
+
+ /// Without `--proxy`, the connection goes straight to the chain server. This is
+ /// the control: it shows the test above is detecting the proxy, not a failure
+ /// to connect at all.
+ #[cfg(feature = "electrum")]
+ #[test]
+ fn test_electrum_sync_without_proxy_goes_direct() {
+ let (server_addr, server_rx) = spawn_listener();
+
+ let (cli, _temp_dir) = setup_wallet_for("electrum", &format!("tcp://{server_addr}"), None);
+
+ sync_cmd(&cli).assert().failure();
+
+ assert!(
+ connected(&server_rx),
+ "no connection reached the chain server"
+ );
+ }
+
+ /// The esplora backend must honour `--proxy` just as electrum does.
+ #[cfg(feature = "esplora")]
+ #[test]
+ fn test_esplora_sync_goes_through_the_proxy() {
+ let (server_addr, server_rx) = spawn_listener();
+ let (proxy_addr, proxy_rx) = spawn_listener();
+
+ let (cli, _temp_dir) = setup_wallet_for(
+ "esplora",
+ &format!("http://{server_addr}"),
+ Some(&proxy_addr),
+ );
+
+ sync_cmd(&cli).assert().failure();
+
+ assert!(
+ connected(&proxy_rx),
+ "the proxy was never contacted: traffic bypassed --proxy"
+ );
+ assert!(
+ !connected(&server_rx),
+ "a direct connection reached the chain server despite --proxy"
+ );
+ }
+
+ /// A proxy the backend cannot honour is rejected, rather than ignored.
+ #[cfg(feature = "rpc")]
+ #[test]
+ fn test_rpc_backend_rejects_a_proxy() {
+ let temp_dir = TempDir::new().unwrap();
+ let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf()));
+
+ let desc = cli
+ .cmd("descriptor", &["--type", "wpkh"])
+ .output()
+ .expect("failed to generate descriptors");
+ let desc_values: Value =
+ serde_json::from_slice(&desc.stdout).expect("invalid JSON from descriptor");
+ let public = &desc_values["public_descriptors"];
+
+ cli.build_base_cmd()
+ .arg("wallet")
+ .arg("--wallet")
+ .arg(WALLET_NAME)
+ .arg("config")
+ .arg("--ext-descriptor")
+ .arg(public["external"].as_str().unwrap())
+ .arg("--int-descriptor")
+ .arg(public["internal"].as_str().unwrap())
+ .arg("--client-type")
+ .arg("rpc")
+ .arg("--database-type")
+ .arg("sqlite")
+ .arg("--url")
+ .arg("127.0.0.1:18443")
+ .arg("--proxy")
+ .arg("127.0.0.1:9050")
+ .assert()
+ .success();
+
+ sync_cmd(&cli)
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains(
+ "rpc backend does not support a SOCKS5 proxy",
+ ));
+ }
+}