diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d127b14..f2bf0503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ 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 +- 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] diff --git a/src/client.rs b/src/client.rs index 54c28d55..bce66a18 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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,54 @@ pub struct KyotoClientHandle { tokio::sync::Mutex>, } +/// 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 { + 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}"), + }) +} + #[cfg(any( feature = "electrum", feature = "esplora", @@ -219,7 +271,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 +281,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 +298,7 @@ pub(crate) fn new_blockchain_client( #[cfg(feature = "rpc")] ClientType::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( @@ -253,8 +315,13 @@ pub(crate) fn new_blockchain_client( #[cfg(feature = "cbf")] ClientType::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) diff --git a/src/commands.rs b/src/commands.rs index 37f80523..86826ffe 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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, /// 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, } +#[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, 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::().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); + } +} diff --git a/src/config.rs b/src/config.rs index e905c427..5e95dc5e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -51,7 +51,7 @@ pub struct WalletConfigInner { pub parallel_requests: Option, #[cfg(feature = "rpc")] pub cookie: Option, - #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf"))] #[serde(default)] pub proxy: Option, #[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, diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 13131c19..905011a6 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -128,7 +128,7 @@ impl AppCommand> 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 diff --git a/tests/cli.rs b/tests/cli.rs index 9285327c..c4759043 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -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 index 00000000..2d676b6f --- /dev/null +++ b/tests/integration/proxy.rs @@ -0,0 +1,307 @@ +//! 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", feature = "cbf"))] +mod test_proxy { + use crate::common::BdkCli; + use assert_cmd::Command; + #[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(); + let (tx, rx) = channel(); + + thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + drop::(stream); + if tx.send(()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + (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() + } + + /// 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, + 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 + } + + /// 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")] + #[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 at all is rejected, rather than ignored. + #[cfg(feature = "rpc")] + #[test] + fn test_rpc_backend_rejects_a_proxy() { + let (cli, _temp_dir) = setup_wallet_with_args("rpc", &["--proxy", "127.0.0.1:9050"]); + + sync_cmd(&cli) + .assert() + .failure() + .stderr(predicate::str::contains( + "rpc backend does not support a SOCKS5 proxy", + )); + } + + /// `--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() + .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 --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", + )); + } +}