From c095346e17bbbbbf8f6877119ef8a5e4ee33273c Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sat, 23 Jul 2022 10:44:28 +0200 Subject: [PATCH] Introduce the Bitcoin network interface along with a bitcoind module --- Cargo.lock | 28 +++ Cargo.toml | 3 + src/bitcoin/d/mod.rs | 491 +++++++++++++++++++++++++++++++++++++++++++ src/bitcoin/mod.rs | 6 + src/bitcoind/mod.rs | 259 +++++++++++++++++++++++ src/lib.rs | 242 ++++++++++++++++++++- 6 files changed, 1018 insertions(+), 11 deletions(-) create mode 100644 src/bitcoin/d/mod.rs create mode 100644 src/bitcoin/mod.rs create mode 100644 src/bitcoind/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f389a817..7ef83ecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,15 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64-compat" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" +dependencies = [ + "byteorder", +] + [[package]] name = "bech32" version = "0.8.1" @@ -76,6 +85,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + [[package]] name = "cc" version = "1.0.73" @@ -170,6 +185,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +[[package]] +name = "jsonrpc" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8423b78fc94d12ef1a4a9d13c348c9a78766dda0cc18817adf0faf77e670c8" +dependencies = [ + "base64-compat", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "libc" version = "0.2.126" @@ -209,6 +236,7 @@ dependencies = [ "backtrace", "dirs", "fern", + "jsonrpc", "log", "miniscript", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index c1b74ba3..d9035e07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,3 +39,6 @@ fern = "0.6" backtrace = "0.3" rusqlite = { version = "0.28", features = ["bundled", "unlock_notify"] } + +# To talk to bitcoind +jsonrpc = "0.12" diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs new file mode 100644 index 00000000..59e45397 --- /dev/null +++ b/src/bitcoin/d/mod.rs @@ -0,0 +1,491 @@ +///! Implementation of the Bitcoin interface using bitcoind. +///! +///! We use the RPC interface and a watchonly descriptor wallet. +use crate::config; + +use std::{fs, io, time::Duration}; + +use jsonrpc::{ + arg, + client::Client, + simple_http::{self, SimpleHttpTransport}, +}; +use miniscript::{bitcoin, Descriptor, DescriptorPublicKey}; + +use serde_json::Value as Json; + +// If bitcoind takes more than 3 minutes to answer one of our queries, fail. +const RPC_SOCKET_TIMEOUT: u64 = 180; + +// Number of retries the client is allowed to do in case of timeout or i/o error +// while communicating with the bitcoin daemon. +// A retry happens every 1 second, this makes us give up after one minute. +const BITCOIND_RETRY_LIMIT: usize = 60; + +// The minimum bitcoind version that can be used with revaultd. +const MIN_BITCOIND_VERSION: u64 = 239900; + +/// An error in the bitcoind interface. +#[derive(Debug)] +pub enum BitcoindError { + CookieFile(io::Error), + /// Bitcoind server error. + Server(jsonrpc::error::Error), + /// They replied to a batch request omitting some responses. + BatchMissingResponse, + WalletCreation(String), + DescriptorImport(String), + WalletLoading(String), + MissingOrTooManyWallet, + InvalidVersion(u64), + NetworkMismatch(String /*config*/, String /*bitcoind*/), + MissingDescriptor, +} + +impl BitcoindError { + /// Is bitcoind just starting ? + pub fn is_warming_up(&self) -> bool { + match self { + // https://github.com/bitcoin/bitcoin/blob/dca80ffb45fcc8e6eedb6dc481d500dedab4248b/src/rpc/protocol.h#L49 + BitcoindError::Server(jsonrpc::error::Error::Rpc(jsonrpc::error::RpcError { + code, + .. + })) => *code == -28, + _ => false, + } + } +} + +impl std::fmt::Display for BitcoindError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + BitcoindError::CookieFile(e) => write!(f, "Reading bitcoind cookie file: {}", e), + BitcoindError::Server(ref e) => write!(f, "Bitcoind RPC server error: {}", e), + BitcoindError::BatchMissingResponse => write!( + f, + "Bitcoind server replied without enough responses to our batched request" + ), + BitcoindError::WalletCreation(s) => write!(f, "Error creating watchonly wallet: {}", s), + BitcoindError::DescriptorImport(s) => write!( + f, + "Error importing descriptor. Response from bitcoind: '{}'", + s + ), + BitcoindError::WalletLoading(s) => { + write!(f, "Error when loading watchonly wallet: '{}'.", s) + } + BitcoindError::InvalidVersion(v) => { + write!( + f, + "Invalid bitcoind version '{}', minimum supported is '{}'.", + v, MIN_BITCOIND_VERSION + ) + } + BitcoindError::NetworkMismatch(conf_net, bitcoind_net) => { + write!( + f, + "Network mismatch. We are supposed to run on '{}' but bitcoind is on '{}'.", + conf_net, bitcoind_net + ) + } + BitcoindError::MissingOrTooManyWallet => { + write!( + f, + "No, or too many, watchonly wallet(s) loaded on bitcoind." + ) + } + BitcoindError::MissingDescriptor => { + write!(f, "The watchonly wallet loaded on bitcoind does not have the main descriptor imported.") + } + } + } +} + +impl std::error::Error for BitcoindError {} + +impl From for BitcoindError { + fn from(e: jsonrpc::error::Error) -> Self { + Self::Server(e) + } +} + +impl From for BitcoindError { + fn from(e: simple_http::Error) -> Self { + jsonrpc::error::Error::Transport(Box::new(e)).into() + } +} + +pub struct BitcoinD { + node_client: Client, + watchonly_client: Client, + watchonly_wallet_path: String, + /// How many times we'll retry upon failure to send a request. + retries: usize, +} + +macro_rules! params { + ($($param:expr),* $(,)?) => { + [ + $( + arg($param), + )* + ] + }; +} + +impl BitcoinD { + /// Create a new bitcoind interface. This tests the connection to bitcoind and disables retries + /// on failure to send a request. + pub fn new( + config: &config::BitcoindConfig, + watchonly_wallet_path: String, + ) -> Result { + let cookie_string = + fs::read_to_string(&config.cookie_path).map_err(BitcoindError::CookieFile)?; + + // Create a dummy client with a low timeout first to test the connection + let dummy_node_client = Client::with_transport( + SimpleHttpTransport::builder() + .url(&config.addr.to_string()) + .map_err(BitcoindError::from)? + .timeout(Duration::from_secs(3)) + .cookie_auth(cookie_string.clone()) + .build(), + ); + let req = dummy_node_client.build_request("echo", &[]); + dummy_node_client.send_request(req.clone())?; + + let node_client = Client::with_transport( + SimpleHttpTransport::builder() + .url(&config.addr.to_string()) + .map_err(BitcoindError::from)? + .timeout(Duration::from_secs(RPC_SOCKET_TIMEOUT)) + .cookie_auth(cookie_string.clone()) + .build(), + ); + + // Create a dummy client with a low timeout first to test the connection + let url = format!("http://{}/wallet/{}", config.addr, watchonly_wallet_path); + let dummy_wo_client = Client::with_transport( + SimpleHttpTransport::builder() + .url(&url) + .map_err(BitcoindError::from)? + .timeout(Duration::from_secs(3)) + .cookie_auth(cookie_string.clone()) + .build(), + ); + let req = dummy_wo_client.build_request("echo", &[]); + dummy_wo_client.send_request(req.clone())?; + + let watchonly_url = format!("http://{}/wallet/{}", config.addr, watchonly_wallet_path); + let watchonly_client = Client::with_transport( + SimpleHttpTransport::builder() + .url(&watchonly_url) + .map_err(BitcoindError::from)? + .timeout(Duration::from_secs(RPC_SOCKET_TIMEOUT)) + .cookie_auth(cookie_string.clone()) + .build(), + ); + + Ok(BitcoinD { + node_client, + watchonly_client, + watchonly_wallet_path, + retries: 0, + }) + } + + /// Set how many times we'll retry a failed request. If passed None will set to default. + pub fn with_retry_limit(mut self, retry_limit: Option) -> Self { + self.retries = retry_limit.unwrap_or(BITCOIND_RETRY_LIMIT); + self + } + + /// Wrapper to retry a request sent to bitcoind upon IO failure + /// according to the configured number of retries. + fn retry Result>( + &self, + request: R, + ) -> Result { + let mut error: Option = None; + for i in 0..self.retries + 1 { + match request() { + Ok(res) => return Ok(res), + Err(e) => { + if e.is_warming_up() { + error = Some(e) + } else if let BitcoindError::Server(jsonrpc::Error::Transport(ref err)) = e { + match err.downcast_ref::() { + Some(simple_http::Error::Timeout) + | Some(simple_http::Error::SocketError(_)) + | Some(simple_http::Error::HttpErrorCode(503)) => { + std::thread::sleep(Duration::from_secs(1)); + log::debug!("Retrying RPC request to bitcoind: attempt #{}", i); + error = Some(e); + } + _ => return Err(e), + } + } else { + return Err(e); + } + } + } + } + + Err(error.expect("Always set if we reach this point")) + } + + fn make_request<'a, 'b>( + &self, + client: &Client, + method: &'a str, + params: &'b [Box], + ) -> Result { + self.retry(|| { + let req = client.build_request(method, params); + log::trace!("Sending to bitcoind: {:#?}", req); + match client.send_request(req) { + Ok(resp) => { + let res = resp.result().map_err(BitcoindError::Server)?; + log::trace!("Got from bitcoind: {:#?}", res); + + return Ok(res); + } + Err(e) => Err(BitcoindError::Server(e)), + } + }) + } + + fn make_node_request(&self, method: &str, params: &[Box]) -> Json { + self.make_request(&self.node_client, method, params) + .expect("We must not fail to make a request for more than a minute") + } + + fn make_fallible_node_request( + &self, + method: &str, + params: &[Box], + ) -> Result { + self.make_request(&self.node_client, method, params) + } + + fn make_wallet_request( + &self, + method: &str, + params: &[Box], + ) -> Json { + self.make_request(&self.watchonly_client, method, params) + .expect("We must not fail to make a request for more than a minute") + } + + fn get_bitcoind_version(&self) -> u64 { + self.make_node_request("getnetworkinfo", &[]) + .get("version") + .map(Json::as_u64) + .flatten() + .expect("Missing or invalid 'version' in 'getnetworkinfo' result?") + } + + fn get_network_bip70(&self) -> String { + self.make_node_request("getblockchaininfo", &[]) + .get("chain") + .map(Json::as_str) + .flatten() + .expect("Missing or invalid 'chain' in 'getblockchaininfo' result?") + .to_string() + } + + fn list_wallets(&self) -> Vec { + self.make_node_request("listwallets", &[]) + .as_array() + .expect("API break, 'listwallets' didn't return an array.") + .iter() + .map(|json_str| { + json_str + .as_str() + .expect("API break: 'listwallets' contains a non-string value") + .to_string() + }) + .collect() + } + + fn unload_wallet(&self, wallet_path: String) -> Option { + self.make_node_request("unloadwallet", ¶ms!(Json::String(wallet_path),)) + .get("warning") + .expect("No 'warning' in 'unloadwallet' response?") + .as_str() + .and_then(|w| { + if w.is_empty() { + None + } else { + Some(w.to_string()) + } + }) + } + + fn create_wallet(&self, wallet_path: String) -> Option { + let res = self.make_node_request( + "createwallet", + ¶ms!( + Json::String(wallet_path), + Json::Bool(true), // watchonly + Json::Bool(true), // blank + ), + ); + + if let Some(warning) = res.get("warning").map(Json::as_str).flatten() { + return Some(warning.to_string()); + } + if res.get("name").is_none() { + return Some("Unknown error when create watchonly wallet".to_string()); + } + + None + } + + // TODO: rescan feature will probably need another timestamp than 'now' + fn import_descriptor(&self, descriptor: &Descriptor) -> Option { + let descriptors = vec![serde_json::json!({ + "desc": descriptor.to_string(), + "timestamp": "now", + "active": false, + })]; + + let res = self.make_wallet_request("importdescriptors", ¶ms!(Json::Array(descriptors))); + let all_succeeded = res + .as_array() + .map(|results| { + results.iter().all(|res| { + res.get("success") + .map(Json::as_bool) + .flatten() + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if all_succeeded { + None + } else { + Some(res.to_string()) + } + } + + fn list_descriptors(&self) -> Vec { + self.make_wallet_request("listdescriptors", &[]) + .get("descriptors") + .and_then(Json::as_array) + .expect("Missing or invalid 'descriptors' field in 'listdescriptors' response") + .iter() + .map(|elem| { + elem.get("desc") + .and_then(Json::as_str) + .expect( + "Missing or invalid 'desc' field in 'listdescriptors' response's entries", + ) + .to_string() + }) + .collect::>() + } + + /// Create the watchonly wallet on bitcoind, and import it the main descriptor. + pub fn create_watchonly_wallet( + &self, + main_descriptor: &Descriptor, + ) -> Result<(), BitcoindError> { + // Remove any leftover. This can happen if we delete the watchonly wallet but don't restart + // bitcoind. + while self.list_wallets().contains(&self.watchonly_wallet_path) { + log::info!("Found a leftover watchonly wallet loaded on bitcoind. Removing it."); + if let Some(e) = self.unload_wallet(self.watchonly_wallet_path.clone()) { + log::error!( + "Unloading wallet '{}': '{}'", + &self.watchonly_wallet_path, + e + ); + } + } + + // Now create the wallet and import the main descriptor. + if let Some(err) = self.create_wallet(self.watchonly_wallet_path.clone()) { + return Err(BitcoindError::WalletCreation(err)); + } + if let Some(err) = self.import_descriptor(main_descriptor) { + return Err(BitcoindError::DescriptorImport(err)); + } + + Ok(()) + } + + pub fn maybe_load_watchonly_wallet(&self) -> Result<(), BitcoindError> { + match self.make_fallible_node_request( + "loadwallet", + ¶ms!(Json::String(self.watchonly_wallet_path.clone()),), + ) { + Err(e) => { + if e.to_string().contains("is already loaded") { + Ok(()) + } else { + Err(e) + } + } + Ok(res) => { + if let Some(warning) = res.get("warning").map(Json::as_str).flatten() { + Err(BitcoindError::WalletLoading(warning.to_string())) + } else if res.get("name").is_none() { + Err(BitcoindError::WalletLoading(res.to_string())) + } else { + Ok(()) + } + } + } + } + + /// Perform various sanity checks on the bitcoind instance. + pub fn sanity_check( + &self, + main_descriptor: &Descriptor, + config_network: bitcoin::Network, + ) -> Result<(), BitcoindError> { + // Check the minimum supported bitcoind version + let version = self.get_bitcoind_version(); + if version < MIN_BITCOIND_VERSION { + return Err(BitcoindError::InvalidVersion(version)); + } + + // Check bitcoind is running on the right network + let bitcoind_net = self.get_network_bip70(); + let bip70_net = match config_network { + bitcoin::Network::Bitcoin => "main", + bitcoin::Network::Testnet => "test", + bitcoin::Network::Regtest => "regtest", + bitcoin::Network::Signet => "signet", + }; + if bitcoind_net != bip70_net { + return Err(BitcoindError::NetworkMismatch( + bip70_net.to_string(), + bitcoind_net, + )); + } + + // Check our watchonly wallet is loaded + if self + .list_wallets() + .iter() + .filter(|s| s == &&self.watchonly_wallet_path) + .count() + != 1 + { + return Err(BitcoindError::MissingOrTooManyWallet); + } + + // Check our main descriptor is imported in this wallet. + if !self + .list_descriptors() + .contains(&main_descriptor.to_string()) + { + return Err(BitcoindError::MissingDescriptor); + } + + Ok(()) + } +} diff --git a/src/bitcoin/mod.rs b/src/bitcoin/mod.rs new file mode 100644 index 00000000..b3af0811 --- /dev/null +++ b/src/bitcoin/mod.rs @@ -0,0 +1,6 @@ +///! Interface to the Bitcoin network. +///! +///! Broadcast transactions, poll for new unspent coins, gather fee estimates. +pub mod d; + +pub trait BitcoinInterface {} diff --git a/src/bitcoind/mod.rs b/src/bitcoind/mod.rs new file mode 100644 index 00000000..834a8b0a --- /dev/null +++ b/src/bitcoind/mod.rs @@ -0,0 +1,259 @@ +pub mod interface; +pub mod poller; +pub mod utils; + +use crate::config::BitcoindConfig; +use crate::{database::DatabaseError, revaultd::RevaultD, threadmessages::BitcoindMessageOut}; +use interface::{BitcoinD, WalletTransaction}; +use poller::poller_main; +use revault_tx::bitcoin::{Network, Txid}; + +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::Receiver, + Arc, RwLock, + }, + thread, + time::Duration, +}; + +use jsonrpc::{ + error::{Error, RpcError}, + simple_http, +}; + +/// Number of retries the client is allowed to do in case of timeout or i/o error +/// while communicating with the bitcoin daemon. +/// A retry happens every 1 second, this makes us give up after one minute. +const BITCOIND_RETRY_LIMIT: usize = 60; + +/// The minimum bitcoind version that can be used with revaultd. +const MIN_BITCOIND_VERSION: u64 = 220000; + +/// An error happened in the bitcoind-manager thread +#[derive(Debug)] +pub enum BitcoindError { + /// It can be related to us.. + Custom(String), + /// Or directly to bitcoind's RPC server + Server(Error), + /// They replied to a batch request omitting some responses + BatchMissingResponse, + RevaultTx(revault_tx::Error), +} + +impl BitcoindError { + /// Is bitcoind just starting ? + pub fn is_warming_up(&self) -> bool { + match self { + // https://github.com/bitcoin/bitcoin/blob/dca80ffb45fcc8e6eedb6dc481d500dedab4248b/src/rpc/protocol.h#L49 + BitcoindError::Server(Error::Rpc(RpcError { code, .. })) => *code == -28, + _ => false, + } + } +} + +impl std::fmt::Display for BitcoindError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + BitcoindError::Custom(ref s) => write!(f, "Bitcoind manager error: {}", s), + BitcoindError::Server(ref e) => write!(f, "Bitcoind server error: {}", e), + BitcoindError::BatchMissingResponse => write!( + f, + "Bitcoind server replied without enough responses to our batched request" + ), + BitcoindError::RevaultTx(ref s) => write!(f, "Bitcoind manager error: {}", s), + } + } +} + +impl std::error::Error for BitcoindError {} + +// FIXME: remove this (and probably the 'Custom' variant too. If we fail to access the DB we should +// panic. +impl From for BitcoindError { + fn from(e: DatabaseError) -> Self { + Self::Custom(format!("Database error in bitcoind thread: {}", e)) + } +} + +impl From for BitcoindError { + fn from(e: simple_http::Error) -> Self { + Self::Server(Error::Transport(Box::new(e))) + } +} + +impl From for BitcoindError { + fn from(e: revault_tx::Error) -> Self { + Self::RevaultTx(e) + } +} + +fn check_bitcoind_network( + bitcoind: &BitcoinD, + config_network: &Network, +) -> Result<(), BitcoindError> { + let chaininfo = bitcoind.getblockchaininfo()?; + let chain = chaininfo + .get("chain") + .and_then(|c| c.as_str()) + .ok_or_else(|| { + BitcoindError::Custom("No valid 'chain' in getblockchaininfo response?".to_owned()) + })?; + let bip70_net = match config_network { + Network::Bitcoin => "main", + Network::Testnet => "test", + Network::Regtest => "regtest", + Network::Signet => "signet", + }; + + if !bip70_net.eq(chain) { + return Err(BitcoindError::Custom(format!( + "Wrong network, bitcoind is on '{}' but our config says '{}' ({})", + chain, bip70_net, config_network + ))); + } + + Ok(()) +} + +fn check_bitcoind_version(bitcoind: &BitcoinD) -> Result<(), BitcoindError> { + let network_info = bitcoind.getnetworkinfo()?; + let bitcoind_version = network_info + .get("version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + BitcoindError::Custom("No valid 'version' in getnetworkinfo response?".to_owned()) + })?; + + if bitcoind_version < MIN_BITCOIND_VERSION { + return Err(BitcoindError::Custom(format!( + "Revaultd needs bitcoind v{} or greater to operate but v{} was found", + MIN_BITCOIND_VERSION, bitcoind_version + ))); + } + + Ok(()) +} + +/// Some sanity checks to be done at startup to make sure our bitcoind isn't going to fail under +/// our feet for a legitimate reason. +fn bitcoind_sanity_checks( + bitcoind: &BitcoinD, + bitcoind_config: &BitcoindConfig, +) -> Result<(), BitcoindError> { + check_bitcoind_version(bitcoind)?; + check_bitcoind_network(bitcoind, &bitcoind_config.network)?; + Ok(()) +} + +/// Connects to and sanity checks bitcoind. +pub fn start_bitcoind(revaultd: &mut RevaultD) -> Result { + let bitcoind = BitcoinD::new( + &revaultd.bitcoind_config, + revaultd + .watchonly_wallet_file() + .expect("Wallet id is set at startup in setup_db()"), + revaultd + .cpfp_wallet_file() + .expect("Wallet id is set at startup in setup_db()"), + ) + .map_err(|e| BitcoindError::Custom(format!("Could not connect to bitcoind: {}", e)))?; + + while let Err(e) = bitcoind_sanity_checks(&bitcoind, &revaultd.bitcoind_config) { + if e.is_warming_up() { + log::info!("Bitcoind is warming up. Waiting for it to be back up."); + thread::sleep(Duration::from_secs(3)) + } else { + return Err(e); + } + } + + Ok(bitcoind.with_retry_limit(BITCOIND_RETRY_LIMIT)) +} + +fn wallet_transaction(bitcoind: &BitcoinD, txid: Txid) -> Option { + bitcoind + .get_wallet_transaction(&txid) + .map_err(|res| { + log::trace!( + "Got '{:?}' from bitcoind when requesting wallet transaction '{}'", + res, + txid + ); + res + }) + .ok() +} + +/// The bitcoind event loop. +/// Listens for bitcoind requests (wallet / chain) and poll bitcoind every 30 seconds, +/// updating our state accordingly. +pub fn bitcoind_main_loop( + rx: Receiver, + revaultd: Arc>, + bitcoind: BitcoinD, +) -> Result<(), BitcoindError> { + let bitcoind = Arc::new(RwLock::new(bitcoind)); + // The verification progress announced by bitcoind *at startup* thus won't be updated + // after startup check. Should be *exactly* 1.0 when synced, but hey, floats so we are + // careful. + let sync_progress = Arc::new(RwLock::new(0.0f64)); + // Used to shutdown the poller thread + let shutdown = Arc::new(AtomicBool::new(false)); + + // We use a thread to 1) wait for bitcoind to be synced 2) poll listunspent + let poller_thread = std::thread::spawn({ + let _bitcoind = bitcoind.clone(); + let _sync_progress = sync_progress.clone(); + let _shutdown = shutdown.clone(); + move || poller_main(revaultd, _bitcoind, _sync_progress, _shutdown) + }); + + for msg in rx { + match msg { + BitcoindMessageOut::Shutdown => { + log::info!("Bitcoind received shutdown from main. Exiting."); + shutdown.store(true, Ordering::Relaxed); + poller_thread + .join() + .expect("Joining bitcoind poller thread"); + return Ok(()); + } + BitcoindMessageOut::SyncProgress(resp_tx) => { + resp_tx.send(*sync_progress.read().unwrap()).map_err(|e| { + BitcoindError::Custom(format!( + "Sending synchronization progress to main thread: {}", + e + )) + })?; + } + BitcoindMessageOut::WalletTransaction(txid, resp_tx) => { + log::trace!("Received 'wallettransaction' from main thread"); + // FIXME: what if bitcoind isn't synced? + resp_tx + .send(wallet_transaction(&bitcoind.read().unwrap(), txid)) + .map_err(|e| { + BitcoindError::Custom(format!( + "Sending wallet transaction to main thread: {}", + e + )) + })?; + } + BitcoindMessageOut::BroadcastTransactions(txs, resp_tx) => { + log::trace!("Received 'broadcastransactions' from main thread"); + resp_tx + .send(bitcoind.read().unwrap().broadcast_transactions(&txs)) + .map_err(|e| { + BitcoindError::Custom(format!( + "Sending transactions broadcast result to main thread: {}", + e + )) + })?; + } + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index dcef60f5..fd0b65c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ +mod bitcoin; pub mod config; mod database; use crate::{ + bitcoin::d::{BitcoinD, BitcoindError}, config::{config_folder_path, Config}, database::sqlite::{FreshDbOptions, SqliteDb, SqliteDbError}, }; @@ -47,6 +49,7 @@ pub enum StartupError { DefaultDataDirNotFound, DatadirCreation(path::PathBuf, io::Error), Database(SqliteDbError), + Bitcoind(BitcoindError), } impl fmt::Display for StartupError { @@ -61,7 +64,8 @@ impl fmt::Display for StartupError { f, "Could not create data directory at '{}': '{}'", dir_path.display(), e ), - Self::Database(e) => write!(f, "Error initializing database: '{}'.", e) + Self::Database(e) => write!(f, "Error initializing database: '{}'.", e), + Self::Bitcoind(e) => write!(f, "Error setting up bitcoind interface: '{}'.", e), } } } @@ -80,6 +84,12 @@ impl From for StartupError { } } +impl From for StartupError { + fn from(e: BitcoindError) -> Self { + Self::Bitcoind(e) + } +} + fn create_datadir(datadir_path: &path::Path) -> Result<(), StartupError> { #[cfg(unix)] return { @@ -124,6 +134,7 @@ impl DaemonHandle { log::info!("Created a new data directory at '{}'", data_dir.display()); } + // Then set up the database let db_path: path::PathBuf = [data_dir.as_path(), path::Path::new("minisafed.sqlite3")] .iter() .collect(); @@ -137,6 +148,27 @@ impl DaemonHandle { }; let db = SqliteDb::new(db_path, options)?; db.sanity_check(config.bitcoind_config.network, &config.main_descriptor)?; + log::info!("Database initialized and checked."); + + // Now set up the bitcoind interface + let wo_path: path::PathBuf = [ + data_dir.as_path(), + path::Path::new("minisafed_watchonly_wallet"), + ] + .iter() + .collect(); + let bitcoind = BitcoinD::new( + &config.bitcoind_config, + wo_path.to_str().expect("Must be valid unicode").to_string(), + )?; + if fresh_data_dir { + bitcoind.create_watchonly_wallet(&config.main_descriptor)?; + log::info!("Created a new watchonly wallet on bitcoind."); + } + bitcoind.maybe_load_watchonly_wallet()?; + bitcoind.sanity_check(&config.main_descriptor, config.bitcoind_config.network)?; + bitcoind.with_retry_limit(None); + log::info!("Connection to bitcoind established and checked."); Ok(Self {}) } @@ -152,7 +184,141 @@ mod tests { use crate::config::BitcoindConfig; use miniscript::{bitcoin, Descriptor, DescriptorPublicKey}; - use std::{env, fs, net, path, process, str::FromStr, thread, time}; + use std::{ + env, fs, + io::{BufRead, BufReader, Write}, + net, path, process, + str::FromStr, + thread, time, + }; + + // Read all bytes from the socket until the end of a JSON object, good enough approximation. + fn read_til_json_end(stream: &mut net::TcpStream) { + stream + .set_read_timeout(Some(time::Duration::from_secs(5))) + .unwrap(); + let mut reader = BufReader::new(stream); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + + if line.starts_with("Authorization") { + let mut buf = vec![0; 256]; + reader.read_until(b'}', &mut buf).unwrap(); + return; + } + } + } + + // Respond to the two "echo" sent at startup to sanity check the connection + fn complete_sanity_check(server: &net::TcpListener) { + let echo_resp = + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[]}\n".as_bytes(); + + // Read the first echo, respond to it + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(echo_resp).unwrap(); + stream.flush().unwrap(); + + // Read the second echo, respond to it + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(echo_resp).unwrap(); + stream.flush().unwrap(); + } + + // Send them a pruned getblockchaininfo telling them we are at version 23.99 + fn complete_version_check(server: &net::TcpListener) { + let net_resp = + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"version\":239900}}\n" + .as_bytes(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(net_resp).unwrap(); + stream.flush().unwrap(); + } + + // Send them a pruned getblockchaininfo telling them we are on mainnet + fn complete_network_check(server: &net::TcpListener) { + let net_resp = + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"chain\":\"main\"}}\n" + .as_bytes(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(net_resp).unwrap(); + stream.flush().unwrap(); + } + + // Send them responses for the calls involved when creating a fresh wallet + fn complete_wallet_creation(server: &net::TcpListener) { + let net_resp = + ["HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[]}\n".as_bytes()] + .concat(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(&net_resp).unwrap(); + stream.flush().unwrap(); + + let net_resp = [ + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"name\":\"dummy\"}}\n" + .as_bytes(), + ] + .concat(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(&net_resp).unwrap(); + stream.flush().unwrap(); + + let net_resp = [ + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[{\"success\":true}]}\n" + .as_bytes(), + ] + .concat(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(&net_resp).unwrap(); + stream.flush().unwrap(); + } + + // Send them a dummy result to loadwallet. + fn complete_wallet_loading(server: &net::TcpListener) { + let net_resp = + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"name\":\"dummy\"}}\n" + .as_bytes(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(net_resp).unwrap(); + stream.flush().unwrap(); + } + + // Send them a response to 'listwallets' with the watchonly wallet path + fn complete_wallet_check<'a>(server: &net::TcpListener, watchonly_wallet_path: &'a str) { + let net_resp = [ + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[\"".as_bytes(), + watchonly_wallet_path.as_bytes(), + "\"]}\n".as_bytes(), + ] + .concat(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(&net_resp).unwrap(); + stream.flush().unwrap(); + } + + // Send them a response to 'listdescriptors' with the main descriptor + fn complete_desc_check<'a>(server: &net::TcpListener, desc: &'a str) { + let net_resp = [ + "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"descriptors\":[{\"desc\":\"".as_bytes(), + desc.as_bytes(), + "\"}]}}\n".as_bytes(), + ] + .concat(); + let (mut stream, _) = server.accept().unwrap(); + read_til_json_end(&mut stream); + stream.write_all(&net_resp).unwrap(); + stream.flush().unwrap(); + } #[test] fn daemon_startup() { @@ -165,24 +331,78 @@ mod tests { let data_dir: path::PathBuf = [tmp_dir.as_path(), path::Path::new("datadir")] .iter() .collect(); + let wo_path: path::PathBuf = [ + data_dir.as_path(), + path::Path::new("bitcoin"), + path::Path::new("minisafed_watchonly_wallet"), + ] + .iter() + .collect(); + let wo_path = wo_path.to_str().unwrap().to_string(); - let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; + // Configure a dummy bitcoind + let network = bitcoin::Network::Bitcoin; + let cookie: path::PathBuf = [ + tmp_dir.as_path(), + path::Path::new(&format!( + "dummy_bitcoind_{:?}.cookie", + thread::current().id() + )), + ] + .iter() + .collect(); + fs::write(&cookie, &[0; 32]).unwrap(); // Will overwrite should it exist already + let addr: net::SocketAddr = + net::SocketAddrV4::new(net::Ipv4Addr::new(127, 0, 0, 1), 0).into(); + let server = net::TcpListener::bind(&addr).unwrap(); + let addr = server.local_addr().unwrap(); + let bitcoind_config = BitcoindConfig { + network, + addr, + cookie_path: cookie.clone(), + poll_interval_secs: time::Duration::from_secs(2), + }; + + // Create a dummy config with this bitcoind + let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))#39x77spy"; let desc = Descriptor::::from_str(desc_str).unwrap(); let config = Config { - bitcoind_config: BitcoindConfig { - network: bitcoin::Network::Bitcoin, - cookie_path: path::PathBuf::new(), - addr: net::SocketAddr::new(net::IpAddr::V4(net::Ipv4Addr::LOCALHOST), 0), - poll_interval_secs: time::Duration::from_secs(1), - }, + bitcoind_config, data_dir: Some(data_dir.clone()), daemon: None, log_level: log::LevelFilter::Debug, main_descriptor: desc, }; - let handle = DaemonHandle::start(config).unwrap(); - handle.shutdown(); + // Start the daemon in a new thread so the current one acts as the bitcoind server. + let daemon_thread = thread::spawn({ + let config = config.clone(); + move || { + let handle = DaemonHandle::start(config).unwrap(); + handle.shutdown(); + } + }); + complete_sanity_check(&server); + complete_wallet_creation(&server); + complete_wallet_loading(&server); + complete_version_check(&server); + complete_network_check(&server); + complete_wallet_check(&server, &wo_path); + complete_desc_check(&server, desc_str); + daemon_thread.join().unwrap(); + + // The datadir is created now, so if we restart it it won't create the wo wallet. + let daemon_thread = thread::spawn(move || { + let handle = DaemonHandle::start(config).unwrap(); + handle.shutdown(); + }); + complete_sanity_check(&server); + complete_wallet_loading(&server); + complete_version_check(&server); + complete_network_check(&server); + complete_wallet_check(&server, &wo_path); + complete_desc_check(&server, desc_str); + daemon_thread.join().unwrap(); fs::remove_dir_all(&tmp_dir).unwrap(); }