]> Untitled Git - bdk-cli/commitdiff
fix(client): Route cbf traffic via configured SOCKS5 proxy
authorVihiga Tyonum <withtvpeter@gmail.com>
Fri, 18 Sep 2026 19:58:01 +0000 (20:58 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Fri, 2 Oct 2026 03:05:38 +0000 (04:05 +0100)
- The cbf arm of new_blockchain_client was ignored proxy_opts but
bdk_kyoto 0.17.0 support a proxy. This change adds the
connection through socks5 proxy.

- add tests coverage for cbf connection through socks5 proxy

CHANGELOG.md
src/client.rs
src/commands.rs
src/config.rs
src/handlers/config.rs
tests/integration/proxy.rs

index 7f4cd38f9c3adb9796365d7ab22f65b018e0d55c..f2bf05039034f94029502befbd5372fdbdf4dd72 100644 (file)
@@ -10,8 +10,8 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details.
 - 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
-
+- Routed compact filter (cbf) traffic through the configured SOCKS5 proxy
+- Rejected `--proxy` on the `rpc` backend, and unsupported proxy options (`--proxy_auth`, `--timeout`) on the `rpc` and `cbf` backends, instead of silently ignoring them
 
 ## [4.0.0]
 
index 6d69898b87990492ae4807d245444a6881ad8e37..bce66a1892b2313affd363dbc8187bfa3587b083 100644 (file)
@@ -254,23 +254,6 @@ fn esplora_proxy_url(proxy_opts: &ProxyOpts) -> Option<String> {
     })
 }
 
-/// 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",
@@ -315,8 +298,7 @@ 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")?;
+            wallet_opts.reject_proxy("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(
@@ -333,10 +315,13 @@ 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")?;
+            wallet_opts.reject_proxy_auth("cbf")?;
+
             let scan_type = bdk_kyoto::ScanType::Sync;
-            let builder = bdk_kyoto::builder::Builder::new(_wallet.network());
+            let mut builder = bdk_kyoto::builder::Builder::new(_wallet.network());
+            if let Some(proxy) = wallet_opts.proxy_opts.socket_addr()? {
+                builder = builder.socks5_proxy(proxy);
+            }
 
             let light_client = builder
                 .required_peers(wallet_opts.compactfilter_opts.conn_count)
index 37f80523fd28d8506d8852c60b66d549ea8bd6ea..86826ffe1791dc2a4b34f5cc519527fcf0e70796 100644 (file)
@@ -53,9 +53,10 @@ use bdk_wallet::bitcoin::Network;
 use clap::{Args, Parser, Subcommand, value_parser};
 use clap_complete::Shell;
 
+#[cfg(any(feature = "rpc", feature = "cbf"))]
+use crate::error::BDKCliError as Error;
 #[cfg(feature = "dns_payment")]
 use crate::handlers::dns::{CreateDnsTxCommand, ResolveDnsRecipientCommand};
-
 #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
 use crate::utils::parse_proxy_auth;
 
@@ -284,13 +285,68 @@ pub struct WalletOpts {
     #[cfg(feature = "cbf")]
     #[clap(flatten)]
     pub compactfilter_opts: CompactFilterOpts,
-    #[cfg(any(feature = "electrum", feature = "esplora"))]
+    #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
     #[command(flatten)]
     pub proxy_opts: ProxyOpts,
 }
 
+#[cfg(any(feature = "rpc", feature = "cbf"))]
+impl WalletOpts {
+    /// Reject a proxy the selected backend cannot honour at all, rather than
+    /// silently ignoring it.
+    ///
+    /// `--retries` cannot be checked the same way: it defaults to 5, so a
+    /// user-supplied value cannot be told apart from the default.
+    #[cfg(feature = "rpc")]
+    pub(crate) fn reject_proxy(&self, _backend: &str) -> Result<(), Error> {
+        #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
+        if self.proxy_opts.proxy.is_some() {
+            return Err(Error::Generic(format!(
+                "The {_backend} backend does not support a SOCKS5 proxy. \
+                 Remove --proxy, or use the electrum, esplora or cbf backend."
+            )));
+        }
+        #[cfg(any(feature = "electrum", feature = "esplora"))]
+        if self.proxy_opts.proxy_auth.is_some() {
+            return Err(Error::Generic(format!(
+                "The {_backend} backend does not support --proxy_auth."
+            )));
+        }
+        #[cfg(any(feature = "electrum", feature = "esplora"))]
+        if self.proxy_opts.timeout.is_some() {
+            return Err(Error::Generic(format!(
+                "The {_backend} backend does not support --timeout."
+            )));
+        }
+        Ok(())
+    }
+
+    /// Reject proxy options the cbf backend cannot honour, even though it does
+    /// support `--proxy` itself.
+    ///
+    /// Kyoto takes the proxy as a bare `SocketAddr`, so it has nowhere to put a
+    /// username and password, and no proxy-specific timeout knob. `--retries`
+    /// cannot be checked for the same reason noted on `reject_proxy`.
+    #[cfg(feature = "cbf")]
+    pub(crate) fn reject_proxy_auth(&self, _backend: &str) -> Result<(), Error> {
+        #[cfg(any(feature = "electrum", feature = "esplora"))]
+        if self.proxy_opts.proxy_auth.is_some() {
+            return Err(Error::Generic(format!(
+                "The {_backend} backend does not support --proxy_auth."
+            )));
+        }
+        #[cfg(any(feature = "electrum", feature = "esplora"))]
+        if self.proxy_opts.timeout.is_some() {
+            return Err(Error::Generic(format!(
+                "The {_backend} backend does not support --timeout."
+            )));
+        }
+        Ok(())
+    }
+}
+
 /// Options to configure a SOCKS5 proxy for a blockchain client connection.
-#[cfg(any(feature = "electrum", feature = "esplora"))]
+#[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
 #[derive(Debug, Args, Clone, PartialEq, Eq)]
 pub struct ProxyOpts {
     /// Sets the SOCKS5 proxy for a blockchain client.
@@ -298,10 +354,12 @@ pub struct ProxyOpts {
     pub proxy: Option<String>,
 
     /// Sets the SOCKS5 proxy credential.
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", value_parser = parse_proxy_auth)]
     pub proxy_auth: Option<(String, String)>,
 
     /// Sets the SOCKS5 proxy retries for the blockchain client.
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     #[arg(
         env = "PROXY_RETRIES",
         short = 'r',
@@ -311,10 +369,32 @@ pub struct ProxyOpts {
     pub retries: u8,
 
     /// Sets the SOCKS5 proxy timeout for the blockchain client.
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     #[arg(env = "PROXY_TIMEOUT", short = 't', long = "timeout")]
     pub timeout: Option<u8>,
 }
 
+#[cfg(feature = "cbf")]
+impl ProxyOpts {
+    /// The proxy as a [`SocketAddr`] for kyoto.
+    ///
+    /// Unlike the electrum and esplora backends this cannot take a hostname.
+    pub(crate) fn socket_addr(&self) -> Result<Option<std::net::SocketAddr>, Error> {
+        let Some(addr) = self.proxy.as_ref() else {
+            return Ok(None);
+        };
+        let addr = addr
+            .strip_prefix("socks5h://")
+            .or_else(|| addr.strip_prefix("socks5://"))
+            .unwrap_or(addr);
+
+        addr.parse().map(Some).map_err(|_| {
+            Error::Generic(format!(
+                "The cbf backend needs --proxy as an ip:port address, but got '{addr}'."
+            ))
+        })
+    }
+}
 /// Options to configure a BIP157 Compact Filter backend.
 #[cfg(feature = "cbf")]
 #[derive(Debug, Args, Clone, PartialEq, Eq)]
@@ -437,3 +517,67 @@ pub enum ReplSubCommand {
     /// Exit REPL loop.
     Exit,
 }
+
+#[cfg(all(test, feature = "cbf"))]
+mod cbf_proxy_tests {
+    use super::*;
+    use std::net::SocketAddr;
+
+    /// `ProxyOpts` carrying only a proxy; the other fields exist for the electrum
+    /// and esplora backends, which kyoto does not share.
+    fn proxy_opts(proxy: &str) -> ProxyOpts {
+        ProxyOpts {
+            proxy: Some(proxy.to_string()),
+            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            proxy_auth: None,
+            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            retries: 5,
+            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            timeout: None,
+        }
+    }
+
+    #[test]
+    fn parses_the_spellings_the_other_backends_accept() {
+        let expected = Some(SocketAddr::from(([127, 0, 0, 1], 9050)));
+
+        assert_eq!(
+            proxy_opts("127.0.0.1:9050").socket_addr().unwrap(),
+            expected
+        );
+        assert_eq!(
+            proxy_opts("socks5://127.0.0.1:9050").socket_addr().unwrap(),
+            expected
+        );
+        assert_eq!(
+            proxy_opts("socks5h://127.0.0.1:9050")
+                .socket_addr()
+                .unwrap(),
+            expected
+        );
+    }
+
+    #[test]
+    fn parses_an_ipv6_proxy() {
+        assert_eq!(
+            proxy_opts("[::1]:9050").socket_addr().unwrap(),
+            Some("[::1]:9050".parse::<SocketAddr>().unwrap())
+        );
+    }
+
+    #[test]
+    fn rejects_a_hostname_kyoto_cannot_use() {
+        let err = proxy_opts("tor.local:9050").socket_addr().unwrap_err();
+        assert!(
+            err.to_string().contains("ip:port"),
+            "unhelpful error: {err}"
+        );
+    }
+
+    #[test]
+    fn no_proxy_is_not_an_error() {
+        let mut opts = proxy_opts("127.0.0.1:9050");
+        opts.proxy = None;
+        assert_eq!(opts.socket_addr().unwrap(), None);
+    }
+}
index e905c427794df0dc0f72e5abb8f927000b4774f2..5e95dc5ea311be09b5e10a355fab9a91a00ebb24 100644 (file)
@@ -51,7 +51,7 @@ pub struct WalletConfigInner {
     pub parallel_requests: Option<usize>,
     #[cfg(feature = "rpc")]
     pub cookie: Option<String>,
-    #[cfg(any(feature = "electrum", feature = "esplora"))]
+    #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
     #[serde(default)]
     pub proxy: Option<String>,
     #[cfg(any(feature = "electrum", feature = "esplora"))]
@@ -175,14 +175,17 @@ impl TryFrom<&WalletConfigInner> for WalletOpts {
             #[cfg(feature = "rpc")]
             cookie: config.cookie.clone(),
 
-            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
             proxy_opts: crate::commands::ProxyOpts {
                 proxy: config.proxy.clone(),
+                #[cfg(any(feature = "electrum", feature = "esplora"))]
                 proxy_auth: match &config.proxy_auth {
                     Some(s) => Some(crate::utils::parse_proxy_auth(s)?),
                     None => None,
                 },
+                #[cfg(any(feature = "electrum", feature = "esplora"))]
                 retries: config.proxy_retries.unwrap_or(5),
+                #[cfg(any(feature = "electrum", feature = "esplora"))]
                 timeout: config.proxy_timeout,
             },
 
@@ -251,7 +254,7 @@ mod tests {
             rpc_password: None,
             #[cfg(feature = "rpc")]
             cookie: None,
-            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
             proxy: None,
             #[cfg(any(feature = "electrum", feature = "esplora"))]
             proxy_auth: None,
@@ -336,7 +339,7 @@ mod tests {
             rpc_password: None,
             #[cfg(feature = "rpc")]
             cookie: None,
-            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
             proxy: None,
             #[cfg(any(feature = "electrum", feature = "esplora"))]
             proxy_auth: None,
index 13131c19433df54295afbe528306f82868cfedbe..905011a616b5c1ce2f1facc58eed2d08b7cac2b6 100644 (file)
@@ -128,7 +128,7 @@ impl AppCommand<AppContext<Init>> for SaveConfigCommand {
             #[cfg(feature = "rpc")]
             cookie: self.wallet_opts.cookie.clone(),
 
-            #[cfg(any(feature = "electrum", feature = "esplora"))]
+            #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
             proxy: self.wallet_opts.proxy_opts.proxy.clone(),
             #[cfg(any(feature = "electrum", feature = "esplora"))]
             proxy_auth: self
index 264cc27292eeab2908fe1fd1f4c22d3ac770bc25..2d676b6fb4d8d041b1b7bb65341748e0abbfe14f 100644 (file)
@@ -3,21 +3,27 @@
 //! 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"))]
+#[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))]
 mod test_proxy {
     use crate::common::BdkCli;
     use assert_cmd::Command;
-    #[cfg(feature = "rpc")]
+    #[cfg(any(feature = "rpc", feature = "cbf"))]
     use predicates::prelude::*;
     use serde_json::Value;
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     use std::net::{TcpListener, TcpStream};
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     use std::sync::mpsc::{Receiver, channel};
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     use std::thread;
     use std::time::Duration;
     use tempfile::TempDir;
 
     static WALLET_NAME: &str = "proxy_test_wallet";
 
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
+    /// Accept connections on an ephemeral port, reporting each one and closing it
+    /// immediately. Returns the bound address and the receiving end of the report.
     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();
@@ -40,11 +46,15 @@ mod test_proxy {
         (addr, rx)
     }
 
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
+    /// Did a connection arrive within the grace period?
     fn connected(rx: &Receiver<()>) -> bool {
         rx.recv_timeout(Duration::from_secs(5)).is_ok()
     }
 
-    /// As above, but for an arbitrary client type and url.
+    /// Configure a wallet against `url`, optionally through `proxy_addr`, for the
+    /// backends that talk to a listener the tests can watch.
+    #[cfg(any(feature = "electrum", feature = "esplora"))]
     fn setup_wallet_for(
         client_type: &str,
         url: &str,
@@ -93,6 +103,45 @@ mod test_proxy {
         cmd
     }
 
+    /// Configure a wallet for `client_type` with `extra_args` appended to the
+    /// `config` command (e.g. `--proxy`, `--proxy_auth`, `--timeout`). No listener
+    /// is needed: these tests only exercise validation, which happens before any
+    /// connection is attempted, at `sync` time.
+    #[cfg(any(feature = "rpc", feature = "cbf"))]
+    fn setup_wallet_with_args(client_type: &str, extra_args: &[&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 mut cmd = cli.build_base_cmd();
+        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(client_type)
+            .arg("--database-type")
+            .arg("sqlite");
+        // `--url` is required whenever electrum, esplora or rpc is built, no matter
+        // which `--client-type` is chosen; it does not exist at all otherwise.
+        #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
+        cmd.arg("--url").arg("127.0.0.1:18443");
+        cmd.args(extra_args).assert().success();
+
+        (cli, temp_dir)
+    }
+
     /// With `--proxy` set, the connection must go to the proxy and never to the
     /// chain server directly.
     #[cfg(feature = "electrum")]
@@ -164,46 +213,95 @@ mod test_proxy {
         );
     }
 
-    /// A proxy the backend cannot honour is rejected, rather than ignored.
+    /// A proxy the backend cannot honour at all 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 (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--proxy", "127.0.0.1:9050"]);
 
-        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"];
+        sync_cmd(&cli)
+            .assert()
+            .failure()
+            .stderr(predicate::str::contains(
+                "rpc backend does not support a SOCKS5 proxy",
+            ));
+    }
 
-        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")
+    /// `--proxy_auth` alone (no `--proxy`) is also rejected for rpc, not just
+    /// silently dropped.
+    #[cfg(all(feature = "rpc", any(feature = "electrum", feature = "esplora")))]
+    #[test]
+    fn test_rpc_backend_rejects_proxy_auth() {
+        let (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--proxy_auth", "user:password"]);
+
+        sync_cmd(&cli)
             .assert()
-            .success();
+            .failure()
+            .stderr(predicate::str::contains(
+                "rpc backend does not support --proxy_auth",
+            ));
+    }
+
+    /// `--timeout` alone is likewise rejected for rpc.
+    #[cfg(all(feature = "rpc", any(feature = "electrum", feature = "esplora")))]
+    #[test]
+    fn test_rpc_backend_rejects_timeout() {
+        let (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--timeout", "30"]);
 
         sync_cmd(&cli)
             .assert()
             .failure()
             .stderr(predicate::str::contains(
-                "rpc backend does not support a SOCKS5 proxy",
+                "rpc backend does not support --timeout",
+            ));
+    }
+
+    /// Kyoto takes the proxy as a `SocketAddr`, so a hostname is reported rather
+    /// than accepted and then failing obscurely.
+    #[cfg(feature = "cbf")]
+    #[test]
+    fn test_cbf_backend_rejects_a_proxy_hostname() {
+        let (cli, _temp_dir) = setup_wallet_with_args("cbf", &["--proxy", "tor.local:9050"]);
+
+        sync_cmd(&cli)
+            .assert()
+            .failure()
+            .stderr(predicate::str::contains(
+                "cbf backend needs --proxy as an ip:port address",
+            ));
+    }
+
+    /// Kyoto's proxy carries no credentials, so `--proxy_auth` is reported rather
+    /// than silently dropped.
+    #[cfg(all(feature = "cbf", any(feature = "electrum", feature = "esplora")))]
+    #[test]
+    fn test_cbf_backend_rejects_proxy_auth() {
+        let (cli, _temp_dir) = setup_wallet_with_args(
+            "cbf",
+            &["--proxy", "127.0.0.1:9050", "--proxy_auth", "user:password"],
+        );
+
+        sync_cmd(&cli)
+            .assert()
+            .failure()
+            .stderr(predicate::str::contains(
+                "cbf backend does not support --proxy_auth",
+            ));
+    }
+
+    /// Kyoto has no proxy-specific timeout knob, so `--timeout` is reported rather
+    /// than silently dropped.
+    #[cfg(all(feature = "cbf", any(feature = "electrum", feature = "esplora")))]
+    #[test]
+    fn test_cbf_backend_rejects_timeout() {
+        let (cli, _temp_dir) =
+            setup_wallet_with_args("cbf", &["--proxy", "127.0.0.1:9050", "--timeout", "30"]);
+
+        sync_cmd(&cli)
+            .assert()
+            .failure()
+            .stderr(predicate::str::contains(
+                "cbf backend does not support --timeout",
             ));
     }
 }