Introduce the Bitcoin network interface along with a bitcoind module

This commit is contained in:
Antoine Poinsot 2022-07-23 10:44:28 +02:00
parent 8626e05a55
commit c095346e17
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
6 changed files with 1018 additions and 11 deletions

28
Cargo.lock generated
View File

@ -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",

View File

@ -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"

491
src/bitcoin/d/mod.rs Normal file
View File

@ -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<jsonrpc::error::Error> for BitcoindError {
fn from(e: jsonrpc::error::Error) -> Self {
Self::Server(e)
}
}
impl From<simple_http::Error> 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<BitcoinD, BitcoindError> {
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<usize>) -> 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<T, R: Fn() -> Result<T, BitcoindError>>(
&self,
request: R,
) -> Result<T, BitcoindError> {
let mut error: Option<BitcoindError> = 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::<simple_http::Error>() {
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<serde_json::value::RawValue>],
) -> Result<Json, BitcoindError> {
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<serde_json::value::RawValue>]) -> 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<serde_json::value::RawValue>],
) -> Result<Json, BitcoindError> {
self.make_request(&self.node_client, method, params)
}
fn make_wallet_request(
&self,
method: &str,
params: &[Box<serde_json::value::RawValue>],
) -> 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<String> {
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<String> {
self.make_node_request("unloadwallet", &params!(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<String> {
let res = self.make_node_request(
"createwallet",
&params!(
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<DescriptorPublicKey>) -> Option<String> {
let descriptors = vec![serde_json::json!({
"desc": descriptor.to_string(),
"timestamp": "now",
"active": false,
})];
let res = self.make_wallet_request("importdescriptors", &params!(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<String> {
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::<Vec<String>>()
}
/// Create the watchonly wallet on bitcoind, and import it the main descriptor.
pub fn create_watchonly_wallet(
&self,
main_descriptor: &Descriptor<DescriptorPublicKey>,
) -> 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",
&params!(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<DescriptorPublicKey>,
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(())
}
}

6
src/bitcoin/mod.rs Normal file
View File

@ -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 {}

259
src/bitcoind/mod.rs Normal file
View File

@ -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<DatabaseError> for BitcoindError {
fn from(e: DatabaseError) -> Self {
Self::Custom(format!("Database error in bitcoind thread: {}", e))
}
}
impl From<simple_http::Error> for BitcoindError {
fn from(e: simple_http::Error) -> Self {
Self::Server(Error::Transport(Box::new(e)))
}
}
impl From<revault_tx::Error> 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<BitcoinD, BitcoindError> {
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<WalletTransaction> {
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<BitcoindMessageOut>,
revaultd: Arc<RwLock<RevaultD>>,
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(())
}

View File

@ -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<SqliteDbError> for StartupError {
}
}
impl From<BitcoindError> 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::<DescriptorPublicKey>::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();
}