diff --git a/Cargo.lock b/Cargo.lock index dac015db4..9a6529cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,8 +41,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "bitcoin-internals", - "bitcoin_hashes", + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.0", ] [[package]] @@ -51,6 +51,12 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + [[package]] name = "bech32" version = "0.11.0" @@ -77,6 +83,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bitcoin" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c85783c2fe40083ea54a33aa2f0ba58831d90fcd190f5bdc47e74e84d2a96ae" +dependencies = [ + "bech32 0.10.0-beta", + "bitcoin-internals 0.2.0", + "bitcoin_hashes 0.13.0", + "hex-conservative 0.1.2", + "hex_lit", + "secp256k1 0.28.2", +] + [[package]] name = "bitcoin" version = "0.32.2" @@ -84,17 +104,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea507acc1cd80fc084ace38544bbcf7ced7c2aa65b653b102de0ce718df668f6" dependencies = [ "base58ck", - "bech32", - "bitcoin-internals", + "bech32 0.11.0", + "bitcoin-internals 0.3.0", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes", - "hex-conservative", + "bitcoin_hashes 0.14.0", + "hex-conservative 0.2.0", "hex_lit", - "secp256k1", + "secp256k1 0.29.0", "serde", ] +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -122,10 +148,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb54da0b28892f3c52203a7191534033e051b6f4b52bc15480681b57b7e036f5" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.3.0", "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals 0.2.0", + "hex-conservative 0.1.2", +] + [[package]] name = "bitcoin_hashes" version = "0.14.0" @@ -133,7 +169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.0", "serde", ] @@ -143,8 +179,8 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8041a1be831c809ada090db2e3bd1469c65b72321bb2f31d7f56261eefc8321" dependencies = [ - "bitcoin", - "bitcoin_hashes", + "bitcoin 0.32.2", + "bitcoin_hashes 0.14.0", "sha2", ] @@ -167,7 +203,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8909583c5fab98508e80ef73e5592a651c954993dc6b7739963257d19f0e71a" dependencies = [ - "bitcoin", + "bitcoin 0.32.2", "serde", "serde_json", ] @@ -349,6 +385,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + [[package]] name = "digest" version = "0.10.7" @@ -380,6 +422,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "dns-lookup" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5766087c2235fec47fafa4cfecc81e494ee679d0fd4a59887ea0919bfb0e4fc" +dependencies = [ + "cfg-if", + "libc", + "socket2", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.9.0" @@ -391,7 +445,7 @@ name = "electrs" version = "0.10.5" dependencies = [ "anyhow", - "bitcoin", + "bitcoin 0.32.2", "bitcoin-test-data", "bitcoin_slices", "bitcoincore-rpc", @@ -405,6 +459,7 @@ dependencies = [ "log", "parking_lot", "prometheus", + "pushtx", "rayon", "serde", "serde_derive", @@ -464,9 +519,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "fmt2io" @@ -519,6 +574,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + [[package]] name = "hex-conservative" version = "0.2.0" @@ -595,6 +656,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -705,6 +775,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -750,6 +838,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "peerlink" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cec5e068ddc6eaedd4d068f3191f24289ec4228cf3179ec93575cb55e2c532d" +dependencies = [ + "crossbeam-channel", + "log", + "mio", + "nohash-hasher", + "slab", + "socks", +] + [[package]] name = "pkg-config" version = "0.3.28" @@ -807,6 +909,23 @@ version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +[[package]] +name = "pushtx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f5f8d03ac9b2434614a06000ee4186ebfd21c0c65460791c63c1a1d29d5159" +dependencies = [ + "bitcoin 0.31.2", + "crossbeam-channel", + "data-encoding", + "dns-lookup", + "fastrand", + "hex", + "log", + "peerlink", + "sha3", +] + [[package]] name = "quote" version = "1.0.33" @@ -960,18 +1079,37 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes 0.13.0", + "secp256k1-sys 0.9.2", +] + [[package]] name = "secp256k1" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e0cc0f1cf93f4969faf3ea1c7d8a9faed25918d96affa959720823dfe86d4f3" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.0", "rand", - "secp256k1-sys", + "secp256k1-sys 0.10.0", "serde", ] +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + [[package]] name = "secp256k1-sys" version = "0.10.0" @@ -1023,6 +1161,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1048,12 +1196,42 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 1a5db60f5..3391fa882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ serde_derive = "1.0, <=1.0.171" # avoid precompiled binaries (https://github.co serde_json = "1.0" signal-hook = "0.3" tiny_http = { version = "0.12", optional = true } +pushtx = "0.4.0" [dependencies.electrs-rocksdb] version = "0.19.0-e3" diff --git a/doc/config_example.toml b/doc/config_example.toml index 1272ddbb9..f4aa7a29b 100644 --- a/doc/config_example.toml +++ b/doc/config_example.toml @@ -29,3 +29,17 @@ electrum_rpc_addr = "127.0.0.1:50001" # How much information about internal workings should electrs print. Increase before reporting a bug. log_filters = "INFO" + +# How to broadcast transactions. +# - "rpc" : use bitcoind to broadcast the TX directly +# - "pushtx-clear" : send the transaction to random bitcoin peers resolved from DNS seeds directly over clearnet +# (bad for privacy, don't use this unless you have protected the outbound connections via +# other means, such as a VPN) +# - "pushtx-tor" : send the transaction to random bitcoin peers resolved from DNS seeds over a TOR proxy +# - "script" : pass the hex-encoded transaction to a script (specified by tx_broadcast_script) +tx_broadcast_method = "rpc" + +# A script which is invoked with a hex-encoded transaction as the sole positional argument. Should exit +# with a zero status if the broadcast was successful. +# +# tx_broadcast_script = "/usr/local/bin/broadcast.sh" diff --git a/internal/config_specification.toml b/internal/config_specification.toml index 75aad9bca..fae22fd73 100644 --- a/internal/config_specification.toml +++ b/internal/config_specification.toml @@ -106,6 +106,17 @@ doc = "Don't sync mempool - queries will show only confirmed transactions." name = "disable_electrum_rpc" doc = "Disable Electrum RPC server - only sync and index blocks." +[[param]] +name = "tx_broadcast_method" +type = "crate::config::TxBroadcastMethodConfigOption" +doc = "'rpc' to broadcast transactions directly from the bitcoin node, OR 'pushtx-clear' to broadcast to random peers over clearnet (bad for privacy unless you know what you're doing), OR 'pushtx-tor' to broadcast to random peers over a local TOR proxy" +default = "crate::config::TxBroadcastMethodConfigOption::BitcoinRPC" + +[[param]] +name = "tx_broadcast_script" +type = "String" +doc = "An executable script-file or command which will be executed with a raw transaction hex as the first and only argument. Only use this if tx_broadcast_script is set to 'script'. The script should exit with a zero status if the broadcast was successful." + [[switch]] name = "sync_once" doc = "Exit after the initial sync is over (don't start Electrum server)." diff --git a/src/config.rs b/src/config.rs index d37ff35b2..f85902cfe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -121,6 +121,60 @@ impl From for Network { } } +/// Describes how the user wants electrs to handle transaction broadcasting. +#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone)] +pub enum TxBroadcastMethodConfigOption { + /// Use the bitcoin node's send_raw_transaction RPC call. + #[serde(rename = "rpc")] + BitcoinRPC, + /// Use the [`pushtx`] crate to broadcast directly to peers over clearnet. + #[serde(rename = "pushtx-clear")] + PushtxClear, + /// Use the [`pushtx`] crate to broadcast directly to peers over TOR. + #[serde(rename = "pushtx-tor")] + PushtxTor, + /// Pass the transaction to the given script file as the first and only positional argument. + #[serde(rename = "script")] + Script, +} + +impl ::configure_me::parse_arg::ParseArgFromStr for TxBroadcastMethodConfigOption { + fn describe_type(mut writer: W) -> fmt::Result { + write!( + writer, + "either 'rpc', 'pushtx-clear', 'pushtx-tor' or 'script'" + ) + } +} + +impl FromStr for TxBroadcastMethodConfigOption { + type Err = anyhow::Error; + + fn from_str(string: &str) -> std::result::Result { + let method = match string { + "rpc" => TxBroadcastMethodConfigOption::BitcoinRPC, + "pushtx-clear" => TxBroadcastMethodConfigOption::PushtxClear, + "pushtx-tor" => TxBroadcastMethodConfigOption::PushtxTor, + "script" => TxBroadcastMethodConfigOption::Script, + method => bail!("invalid tx broadcast method \"{method}\""), + }; + Ok(method) + } +} + +/// Describes how the user wants electrs to handle transaction broadcasting. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum TxBroadcastMethod { + /// Use the bitcoin node's send_raw_transaction RPC call. + BitcoinRPC, + /// Use the [`pushtx`] crate to broadcast directly to peers over clearnet. + PushtxClear, + /// Use the [`pushtx`] crate to broadcast directly to peers over TOR. + PushtxTor, + /// Pass the transaction to a script as the first and only positional argument. + Script(String), +} + /// Parsed and post-processed configuration #[derive(Debug)] pub struct Config { @@ -144,6 +198,7 @@ pub struct Config { pub sync_once: bool, pub skip_block_download_wait: bool, pub disable_electrum_rpc: bool, + pub tx_broadcast_method: TxBroadcastMethod, pub server_banner: String, pub signet_magic: Magic, } @@ -346,6 +401,27 @@ impl Config { std::process::exit(0); } + let tx_broadcast_method = match (config.tx_broadcast_method, config.tx_broadcast_script) { + (TxBroadcastMethodConfigOption::Script, Some(script_path)) => { + TxBroadcastMethod::Script(script_path) + } + (TxBroadcastMethodConfigOption::BitcoinRPC, None) => TxBroadcastMethod::BitcoinRPC, + (TxBroadcastMethodConfigOption::PushtxClear, None) => TxBroadcastMethod::PushtxClear, + (TxBroadcastMethodConfigOption::PushtxTor, None) => TxBroadcastMethod::PushtxTor, + (TxBroadcastMethodConfigOption::Script, None) => { + eprintln!( + "Error: tx_broadcast_script must be configured if tx_broadcast_method == \"script\"" + ); + std::process::exit(1); + } + (_, Some(_)) => { + eprintln!( + "Error: tx_broadcast_script must not be configured if tx_broadcast_method != \"script\"" + ); + std::process::exit(1); + } + }; + let config = Config { network: config.network, db_path: config.db_dir, @@ -366,6 +442,7 @@ impl Config { sync_once: config.sync_once, skip_block_download_wait: config.skip_block_download_wait, disable_electrum_rpc: config.disable_electrum_rpc, + tx_broadcast_method, server_banner: config.server_banner, signet_magic: magic, }; diff --git a/src/electrum.rs b/src/electrum.rs index 46e0fa968..92580c230 100644 --- a/src/electrum.rs +++ b/src/electrum.rs @@ -16,13 +16,14 @@ use std::str::FromStr; use crate::{ cache::Cache, - config::{Config, ELECTRS_VERSION}, + config::{Config, TxBroadcastMethod, ELECTRS_VERSION}, daemon::{self, extract_bitcoind_error, Daemon}, merkle::Proof, metrics::{self, Histogram, Metrics}, signals::Signal, status::ScriptHashStatus, tracker::Tracker, + tx_broadcaster::TxBroadcaster, types::ScriptHash, }; @@ -124,6 +125,7 @@ pub struct Rpc { cache: Cache, rpc_duration: Histogram, daemon: Daemon, + tx_broadcaster: TxBroadcaster, signal: Signal, banner: String, port: u16, @@ -143,11 +145,20 @@ impl Rpc { let signal = Signal::new(); let daemon = Daemon::connect(config, signal.exit_flag(), tracker.metrics())?; let cache = Cache::new(tracker.metrics()); + + let tx_broadcaster = match &config.tx_broadcast_method { + TxBroadcastMethod::BitcoinRPC => TxBroadcaster::BitcoinRPC, + TxBroadcastMethod::PushtxClear => TxBroadcaster::PushtxClear, + TxBroadcastMethod::PushtxTor => TxBroadcaster::PushtxTor, + TxBroadcastMethod::Script(script_path) => TxBroadcaster::Script(script_path.clone()), + }; + Ok(Self { tracker, cache, rpc_duration, daemon, + tx_broadcaster, signal, banner: config.server_banner.clone(), port: config.electrum_rpc_addr.port(), @@ -360,7 +371,7 @@ impl Rpc { fn transaction_broadcast(&self, (tx_hex,): &(String,)) -> Result { let tx_bytes = Vec::from_hex(tx_hex).context("non-hex transaction")?; let tx = deserialize(&tx_bytes).context("invalid transaction")?; - let txid = self.daemon.broadcast(&tx)?; + let txid = self.tx_broadcaster.broadcast(&self.daemon, &tx)?; Ok(json!(txid)) } diff --git a/src/lib.rs b/src/lib.rs index d1df9c062..d12db2956 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ mod signals; mod status; mod thread; mod tracker; +mod tx_broadcaster; mod types; pub use server::run; diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs new file mode 100644 index 000000000..706d549e2 --- /dev/null +++ b/src/tx_broadcaster.rs @@ -0,0 +1,88 @@ +use anyhow::{bail, Result}; +use bitcoin::{Transaction, Txid}; + +use crate::daemon::Daemon; + +/// Represents one of many possible ways of broadcasting transactions. +pub enum TxBroadcaster { + BitcoinRPC, + PushtxClear, + PushtxTor, + Script(String), +} + +fn broadcast_with_script(script: &str, tx: &Transaction) -> Result { + let tx_hex = bitcoin::consensus::encode::serialize_hex(tx); + let output = std::process::Command::new(script).arg(tx_hex).output()?; + if !output.status.success() { + bail!( + "broadcasting TX with script '{}' failed with status code {}; stderr: {}", + script, + output + .status + .code() + .map(|i| i.to_string()) + .unwrap_or_else(|| "".to_string()), + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(tx.compute_txid()) +} + +fn broadcast_with_pushtx(tx: &Transaction, opts: pushtx::Opts) -> Result { + let txs = vec![pushtx::Transaction::from_bytes( + bitcoin::consensus::encode::serialize(tx), + )?]; + let recv = pushtx::broadcast(txs, opts); + + loop { + match recv.recv().unwrap() { + pushtx::Info::Done(Ok(report)) => { + if let Some(reason) = report.rejects.values().next() { + bail!("transaction rejected by peers: {reason}"); + } + let txid = tx.compute_txid(); + info!("successful pushtx broadcast of {txid}"); + return Ok(txid); + } + pushtx::Info::Done(Err(e)) => bail!("failed to broadcast with pushtx: {e}"), + pushtx::Info::ResolvingPeers => debug!("resolving pushtx peers from DNS seeds"), + pushtx::Info::ResolvedPeers(n) => debug!("resolved {n} pushtx peers"), + pushtx::Info::ConnectingToNetwork { tor_status } => match tor_status { + None => { + debug!("connecting over clearnet to bitcoin P2P nodes for pushtx broadcast") + } + Some(addr) => debug!( + "connecting to bitcoin P2P nodes via Tor proxy {addr} for pushtx broadcast" + ), + }, + pushtx::Info::Broadcast { peer } => { + debug!("successful broadcast to bitcoin node {peer}") + } + }; + } +} + +impl TxBroadcaster { + pub fn broadcast(&self, daemon: &Daemon, tx: &Transaction) -> Result { + match self { + TxBroadcaster::BitcoinRPC => daemon.broadcast(tx), + TxBroadcaster::PushtxClear => broadcast_with_pushtx( + tx, + pushtx::Opts { + use_tor: pushtx::TorMode::No, + ..pushtx::Opts::default() + }, + ), + TxBroadcaster::PushtxTor => broadcast_with_pushtx( + tx, + pushtx::Opts { + use_tor: pushtx::TorMode::Must, + ..pushtx::Opts::default() + }, + ), + TxBroadcaster::Script(script) => broadcast_with_script(script, tx), + } + } +}