- 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]
})
}
-/// 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",
#[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(
#[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)
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;
#[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.
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',
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)]
/// 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);
+ }
+}
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"))]
#[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,
},
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,
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,
#[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
//! 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();
(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,
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")]
);
}
- /// 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",
));
}
}