]> Untitled Git - bdk-cli/commitdiff
fix(client): route electrum & esplora traffic through config proxy
authorVihiga Tyonum <withtvpeter@gmail.com>
Fri, 18 Sep 2026 07:30:33 +0000 (08:30 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Fri, 2 Oct 2026 03:05:32 +0000 (04:05 +0100)
`--proxy` accepted proxy_opts for both electrum and esplora clients
but did not use them during connection. This fix updates both
clients to use the provided proxy options during connection
- added test for connection through proxy
- updated CHANGELOG

CHANGELOG.md
src/client.rs
tests/cli.rs
tests/integration/proxy.rs [new file with mode: 0644]

index 5d127b14f79895a2da387a456c67bc1ef20b0bb9..7f4cd38f9c3adb9796365d7ab22f65b018e0d55c 100644 (file)
@@ -9,6 +9,8 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details.
 - Fixed the data directory and config.toml permission being world-readable (0755/0644) to 0700/0600 on Unix.
 - Fixed `create_tx` and `bump_fee` panicking on malformed `--utxos` and `--add_data` values instead of returning an error
 - Fixed `--fee_rate` silently truncating to a whole sat/vB, falling back to a default, or producing a zero-fee transaction, unusable values are now rejected
+- Fixed routing electrum and esplora traffic through configured socks5 proxy
+- Rejected `--proxy` on the `rpc` and `cbf` backends
 
 
 ## [4.0.0]
index 54c28d558c8d66b9b7b9be2253669640c8eb4782..6d69898b87990492ae4807d245444a6881ad8e37 100644 (file)
@@ -22,6 +22,10 @@ use {
     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};
 
@@ -202,6 +206,71 @@ pub struct KyotoClientHandle {
         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",
@@ -219,7 +288,8 @@ pub(crate) fn new_blockchain_client(
     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),
@@ -228,7 +298,15 @@ pub(crate) fn new_blockchain_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,
@@ -237,6 +315,8 @@ pub(crate) fn new_blockchain_client(
 
         #[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(
@@ -253,6 +333,8 @@ pub(crate) fn new_blockchain_client(
 
         #[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());
 
index 9285327c4b75973a9577cc04d633da4c888fdb3a..c4759043b887e26b775538fc8c79296c3dd749e2 100644 (file)
@@ -17,4 +17,5 @@ mod integration {
     mod init;
     mod offline;
     mod online;
+    mod proxy;
 }
diff --git a/tests/integration/proxy.rs b/tests/integration/proxy.rs
new file mode 100644 (file)
index 0000000..264cc27
--- /dev/null
@@ -0,0 +1,209 @@
+//! 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",
+            ));
+    }
+}