diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..bec2d57c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,44 @@ +name: CI + +on: [pull_request] + +jobs: + linter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt + override: true + - name: rustfmt + run: cargo fmt -- --check + + unit_tests: + needs: linter + strategy: + matrix: + toolchain: + - 1.48 + - nightly + os: + - ubuntu-latest + - macOS-latest + - windows-latest + runs-on: ${{ matrix.os }} + steps: + - name: Checkout source code + uses: actions/checkout@v2 + - name: Install Rust ${{ matrix.toolchain }} toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.toolchain }} + override: true + profile: minimal + - name: Test on Rust ${{ matrix.toolchain }} (only Windows) + if: matrix.os == 'windows-latest' + run: cargo test --verbose --no-default-features + - name: Test on Rust ${{ matrix.toolchain }} (non Windows) + if: matrix.os != 'windows-latest' + run: cargo test --verbose --color always -- --nocapture diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..534a5388 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,494 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "backtrace" +version = "0.3.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab84319d616cfb654d03394f38ab7e6f0919e181b1b57e1fd15e7fb4077d9a7" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b" + +[[package]] +name = "bitcoin" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a41df6ad9642c5c15ae312dd3d074de38fd3eb7cc87ad4ce10f90292a83fe4d" +dependencies = [ + "bech32", + "bitcoin_hashes", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "006cc91e1a1d99819bc5b8214be3555c1f0611b169f527a1fdc54ed1f2b745b0" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "dirs" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fern" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdd7b0849075e79ee9a1836df22c717d1eba30451796fdc631b04565dd11e2a" +dependencies = [ + "log", +] + +[[package]] +name = "getrandom" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "libsqlite3-sys" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ecc4273169aeb654a26ba8c7e087947caad92c9d121886eafa6446d4ebe138" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "minisafed" +version = "0.0.1" +dependencies = [ + "backtrace", + "dirs", + "fern", + "jsonrpc", + "libc", + "log", + "miniscript", + "rusqlite", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "miniscript" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e292b58407dfbf1384e5aca8428d3b0f2eaa09d24cb17088f6db0b7ca31194a" +dependencies = [ + "bitcoin", + "serde", +] + +[[package]] +name = "miniz_oxide" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" +dependencies = [ + "adler", +] + +[[package]] +name = "object" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21158b2c33aa6d4561f1c0a6ea283ca92bc54802a93b263e910746d679a7eb53" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "proc-macro2" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534cfe58d6a18cc17120fbf4635d53d14691c1fe4d951064df9bd326178d7d5a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba4d3462c8b2e4d7f4fcfcf2b296dc6b65404fbbc7b63daa37fd485c149daf7" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" + +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + +[[package]] +name = "secp256k1" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d03ceae636d0fed5bae6a7f4f664354c5f4fcedf6eef053fef17e49f837d0a" +dependencies = [ + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957da2573cde917463ece3570eab4a0b3f19de6f1646cde62e6fd3868f566036" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc855a42c7967b7c369eb5860f7164ef1f6f81c20c7cc1141f2a604e18723b03" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2122636b9fe3b81f1cb25099fcf2d3f542cdb1d45940d56c713158884a05da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" + +[[package]] +name = "syn" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..8cddec3d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "minisafed" +version = "0.0.1" +authors = ["Antoine Poinsot "] +edition = "2018" +repository = "https://github.com/revault/minisafed" +license-file = "LICENCE" +keywords = ["bitcoin", "wallet", "safe", "script", "miniscript", "inheritance", "recovery"] +description = "Minisafe wallet daemon" +exclude = [".github/", ".cirrus.yml", "tests/", "test_data/", "contrib/", "pyproject.toml"] + +[[bin]] +name = "minisafed" +path = "src/bin/daemon.rs" +required-features = ["jsonrpc_server"] + +[[bin]] +name = "minisafe-cli" +path = "src/bin/cli.rs" +required-features = ["jsonrpc_server"] + +[features] +default = ["jsonrpc_server"] +jsonrpc_server = [] + +[dependencies] +# For managing transactions (it re-exports the bitcoin crate) +miniscript = { version = "6.0.0", features = ["compiler", "use-serde"] } + +# Don't reinvent the wheel +dirs = "3.0" + +# We use TOML for the config, and JSON for RPC +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" +serde_json = { version = "1.0", features = ["raw_value"] } + +# Logging stuff +log = "0.4" +fern = "0.6" + +# In order to have a backtrace on panic, because the +# stdlib does not have a programmatic interface yet +# to work with our custom panic hook. +backtrace = "0.3" + +# Pinned to this version because they broke the MSRV in 0.27... +# FIXME: this is unfortunate, we don't receive the updates (sometimes critical) from SQLite. +rusqlite = { version = "0.26.3", features = ["bundled", "unlock_notify"] } + +# To talk to bitcoind +jsonrpc = "0.12" + +# Used for daemonization +libc = "0.2" diff --git a/src/bin/cli.rs b/src/bin/cli.rs new file mode 100644 index 00000000..229d1438 --- /dev/null +++ b/src/bin/cli.rs @@ -0,0 +1,174 @@ +use minisafed::config::{config_folder_path, Config}; + +use std::{ + env, + io::{Read, Write}, + path::PathBuf, + process, +}; + +use serde_json::Value as Json; + +use std::os::unix::net::UnixStream; + +// Exits with error +fn show_usage() { + eprintln!("Usage:"); + eprintln!(" revault-cli [--conf conf_path] [--raw] [ ...]"); + process::exit(1); +} + +// Returns (Maybe(special conf file), Raw, Method name, Maybe(List of parameters)) +fn parse_args(mut args: Vec) -> (Option, bool, String, Vec) { + if args.len() < 2 { + eprintln!("Not enough arguments."); + show_usage(); + } + + args.remove(0); // Program name + + let mut args = args.into_iter(); + let mut raw = false; + let mut conf_file = None; + + loop { + match args.next().as_deref() { + Some("--conf") => { + if args.len() < 2 { + eprintln!("Not enough arguments."); + show_usage(); + } + + conf_file = Some(PathBuf::from(args.next().expect("Just checked"))); + } + Some("--raw") => { + if args.len() < 1 { + eprintln!("Not enough arguments."); + show_usage(); + } + raw = true; + } + Some(method) => return (conf_file, raw, method.to_owned(), args.collect()), + None => { + // Should never happen... + eprintln!("Not enough arguments."); + show_usage(); + } + } + } +} + +// Defaults to String Value when parsing fails, as it fails to parse outpoints otherwise... +fn from_str_hack(token: String) -> Json { + match serde_json::from_str(&token) { + Ok(json) => json, + Err(_) => Json::String(token), + } +} + +fn rpc_request(method: String, params: Vec) -> Json { + let method = Json::String(method); + let params = Json::Array(params.into_iter().map(from_str_hack).collect::>()); + let mut object = serde_json::Map::::new(); + object.insert("jsonrpc".to_string(), Json::String("2.0".to_string())); + object.insert( + "id".to_string(), + Json::String(format!("revault-cli-{}", process::id())), + ); + object.insert("method".to_string(), method); + object.insert("params".to_string(), params); + + Json::Object(object) +} + +fn socket_file(conf_file: Option) -> PathBuf { + let config = Config::from_file(conf_file).unwrap_or_else(|e| { + eprintln!("Error getting config: {}", e); + process::exit(1); + }); + let data_dir = config + .data_dir + .unwrap_or_else(|| config_folder_path().unwrap()); + let data_dir = data_dir.to_str().expect("Datadir is valid unicode"); + + [ + data_dir, + config.bitcoind_config.network.to_string().as_str(), + "revaultd_rpc", + ] + .iter() + .collect() +} + +fn trimmed(mut vec: Vec, bytes_read: usize) -> Vec { + vec.truncate(bytes_read); + + // Until there is some whatever-newline character, pop. + while let Some(byte) = vec.last() { + // Of course, we assume utf-8 + if !(&0x0a..=&0x0d).contains(&byte) { + break; + } + vec.pop(); + } + + vec +} + +fn main() { + let args = env::args().collect(); + let (conf_file, raw, method, params) = parse_args(args); + let request = rpc_request(method, params); + let socket_file = socket_file(conf_file); + let mut raw_response = vec![0; 256]; + + let mut socket = UnixStream::connect(&socket_file).unwrap_or_else(|e| { + eprintln!("Could not connect to {:?}: '{}'", socket_file, e); + process::exit(1); + }); + socket + .write_all(request.to_string().as_bytes()) + .unwrap_or_else(|e| { + eprintln!("Writing to {:?}: '{}'", &socket_file, e); + process::exit(1); + }); + + let mut total_read = 0; + loop { + let n = socket + .read(&mut raw_response[total_read..]) + .unwrap_or_else(|e| { + eprintln!("Reading from {:?}: '{}'", &socket_file, e); + process::exit(1); + }); + total_read += n; + if total_read == raw_response.len() { + raw_response.resize(2 * total_read, 0); + continue; + } + + // FIXME: do actual incremental parsing instead of this hack!! + raw_response = trimmed(raw_response, total_read); + match serde_json::from_slice::(&raw_response) { + Ok(response) => { + if response.get("id") == request.get("id") { + if raw { + print!("{}", response); + } else if let Some(r) = response.get("result") { + println!("{:#}", serde_json::json!({ "result": r })); + } else if let Some(e) = response.get("error") { + println!("{:#}", serde_json::json!({ "error": e })); + } else { + log::warn!( + "revaultd response doesn't contain result or error: '{}'", + response + ); + println!("{:#}", response); + } + return; + } + } + Err(_) => continue, + } + } +} diff --git a/src/bin/daemon.rs b/src/bin/daemon.rs new file mode 100644 index 00000000..ae0b31b1 --- /dev/null +++ b/src/bin/daemon.rs @@ -0,0 +1,69 @@ +use std::{ + env, + io::{self, Write}, + path::PathBuf, + process, time, +}; + +use minisafed::{config::Config, DaemonHandle}; + +fn parse_args(args: Vec) -> Option { + if args.len() == 1 { + return None; + } + + if args.len() != 3 { + eprintln!("Unknown arguments '{:?}'.", args); + eprintln!("Only '--conf ' is supported."); + process::exit(1); + } + + Some(PathBuf::from(args[2].to_owned())) +} + +fn setup_logger(log_level: log::LevelFilter) -> Result<(), fern::InitError> { + let dispatcher = fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "[{}][{}][{}] {}", + time::SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap_or_else(|e| { + println!("Can't get time since epoch: '{}'. Using a dummy value.", e); + time::Duration::from_secs(0) + }) + .as_secs(), + record.target(), + record.level(), + message + )) + }) + .level(log_level); + + dispatcher.chain(std::io::stdout()).apply()?; + + Ok(()) +} + +fn main() { + let args = env::args().collect(); + let conf_file = parse_args(args); + + let config = Config::from_file(conf_file).unwrap_or_else(|e| { + eprintln!("Error parsing config: {}", e); + process::exit(1); + }); + setup_logger(config.log_level).unwrap_or_else(|e| { + eprintln!("Error setting up logger: {}", e); + process::exit(1); + }); + + let _ = DaemonHandle::start(config).unwrap_or_else(|e| { + // The panic hook will log::error + panic!("Starting Minisafe daemon: {}", e); + }); + + // We are always logging to stdout, should it be then piped to the log file (if self) or + // not. So just make sure that all messages were actually written. + io::stdout().flush().expect("Flushing stdout"); +} 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/config.rs b/src/config.rs new file mode 100644 index 00000000..425d559f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,313 @@ +use std::{net::SocketAddr, path::PathBuf, str::FromStr, time::Duration}; + +use miniscript::{ + bitcoin::Network, + descriptor::{Descriptor, DescriptorPublicKey}, + ForEach, ForEachKey, +}; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + +fn deserialize_fromstr<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: FromStr, + ::Err: std::fmt::Display, +{ + let string = String::deserialize(deserializer)?; + T::from_str(&string) + .map_err(|e| de::Error::custom(format!("Error parsing descriptor '{}': '{}'", string, e))) +} + +pub fn serialize_to_string( + field: T, + s: S, +) -> Result { + s.serialize_str(&field.to_string()) +} + +fn deserialize_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let secs = u64::deserialize(deserializer)?; + Ok(Duration::from_secs(secs)) +} +pub fn serialize_duration(duration: &Duration, s: S) -> Result { + s.serialize_u64(duration.as_secs()) +} + +fn default_loglevel() -> log::LevelFilter { + log::LevelFilter::Info +} + +fn default_poll_interval() -> Duration { + Duration::from_secs(30) +} + +#[cfg(unix)] +fn default_daemon() -> bool { + false +} + +/// Everything we need to know for talking to bitcoind serenely +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BitcoindConfig { + /// The network we are operating on, one of "bitcoin", "testnet", "regtest" + pub network: Network, + /// Path to bitcoind's cookie file, to authenticate the RPC connection + pub cookie_path: PathBuf, + /// The IP:port bitcoind's RPC is listening on + pub addr: SocketAddr, + /// The poll interval for bitcoind + #[serde( + deserialize_with = "deserialize_duration", + serialize_with = "serialize_duration", + default = "default_poll_interval" + )] + pub poll_interval_secs: Duration, +} + +/// Static informations we require to operate +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Config { + /// An optional custom data directory + pub data_dir: Option, + /// Whether to daemonize the process + #[cfg(unix)] + #[serde(default = "default_daemon")] + pub daemon: bool, + /// What messages to log + #[serde( + deserialize_with = "deserialize_fromstr", + serialize_with = "serialize_to_string", + default = "default_loglevel" + )] + pub log_level: log::LevelFilter, + /// The descriptor to use for sending/receiving coins + #[serde( + deserialize_with = "deserialize_fromstr", + serialize_with = "serialize_to_string" + )] + pub main_descriptor: Descriptor, + /// Everything we need to know to talk to bitcoind + pub bitcoind_config: BitcoindConfig, +} + +#[derive(PartialEq, Eq, Debug)] +pub enum ConfigError { + DatadirNotFound, + FileNotFound, + ReadingFile(String), + UnexpectedDescriptor(Descriptor), + Unexpected(String), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match &self { + Self::DatadirNotFound => write!(f, "Could not locate the configuration directory."), + Self::FileNotFound => write!(f, "Could not locate the configuration file."), + Self::ReadingFile(e) => write!(f, "Failed to read configuration file: {}", e), + Self::UnexpectedDescriptor(desc) => write!( + f, + "Unexpected descriptor '{}'. We only support wsh() descriptors for now.", + desc + ), + Self::Unexpected(e) => write!(f, "Configuration error: {}", e), + } + } +} + +impl From for ConfigError { + fn from(e: std::io::Error) -> Self { + match e.kind() { + std::io::ErrorKind::NotFound => Self::FileNotFound, + _ => Self::ReadingFile(e.to_string()), + } + } +} + +impl std::error::Error for ConfigError {} + +/// Get the absolute path to the minisafe configuration folder. +/// +/// It's a "minisafe//" directory in the XDG standard configuration directory for +/// all OSes but Linux-based ones, for which it's `~/.minisafe//`. +/// There is only one config file at `minisafe/config.toml`, which specifies the network. +/// Rationale: we want to have the database, RPC socket, etc.. in the same folder as the +/// configuration file but for Linux the XDG specifoes a data directory (`~/.local/share/`) +/// different from the configuration one (`~/.config/`). +pub fn config_folder_path() -> Option { + #[cfg(target_os = "linux")] + let configs_dir = dirs::home_dir(); + + #[cfg(not(target_os = "linux"))] + let configs_dir = dirs::config_dir(); + + if let Some(mut path) = configs_dir { + #[cfg(target_os = "linux")] + path.push(".minisafe"); + + #[cfg(not(target_os = "linux"))] + path.push("Minisafe"); + + return Some(path); + } + + None +} + +fn config_file_path() -> Option { + config_folder_path().map(|mut path| { + path.push("minisafe.toml"); + path + }) +} + +impl Config { + /// Get our static configuration out of a mandatory configuration file. + /// + /// We require all settings to be set in the configuration file, and only in the configuration + /// file. We don't allow to set them via the command line or environment variables to avoid a + /// futile duplication. + pub fn from_file(custom_path: Option) -> Result { + let config_file = + custom_path.unwrap_or(config_file_path().ok_or_else(|| ConfigError::DatadirNotFound)?); + + let config = toml::from_slice::(&std::fs::read(&config_file)?) + .map_err(|e| ConfigError::ReadingFile(format!("Parsing configuration file: {}", e)))?; + config.check()?; + + Ok(config) + } + + /// Make sure the settings are sane. + pub fn check(&self) -> Result<(), ConfigError> { + // Check the network of the xpubs in the descriptors + let expected_network = match self.bitcoind_config.network { + Network::Bitcoin => Network::Bitcoin, + _ => Network::Testnet, + }; + let unexpected_net = self.main_descriptor.for_each_key(|pkpkh| { + let xpub = match pkpkh { + // For DescriptorPublicKey, Pk::Hash == Self. + ForEach::Key(xpub) => xpub, + ForEach::Hash(xpub) => xpub, + }; + if let DescriptorPublicKey::XPub(xpub) = xpub { + xpub.xkey.network != expected_network + } else { + false + } + }); + if unexpected_net { + return Err(ConfigError::Unexpected(format!( + "Our bitcoin network is {} but one xpub is not for network {}", + self.bitcoind_config.network, expected_network + ))); + } + + // TODO: check the semantics of the main descriptor + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{config_file_path, Config}; + + // Test the format of the configuration file + #[test] + fn toml_config() { + // A valid config + let toml_str = r#" + data_dir = "/home/wizardsardine/custom/folder/" + daemon = false + log_level = "debug" + main_descriptor = "wsh(andor(thresh(1,pk(xpub6BaZSKgpaVvibu2k78QsqeDWXp92xLHZxiu1WoqLB9hKhsBf3miBUDX7PJLgSPvkj66ThVHTqdnbXpeu8crXFmDUd4HeM4s4miQS2xsv3Qb/*)),and_v(v:multi(2,03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a,0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce),older(4)),thresh(2,pkh(xpub6AHA9hZDN11k2ijHMeS5QqHx2KP9aMBRhTDqANMnwVtdyw2TDYRmF8PjpvwUFcL1Et8Hj59S3gTSMcUQ5gAqTz3Wd8EsMTmF3DChhqPQBnU/*),a:pkh(xpub6AaffFGfH6WXfm6pwWzmUMuECQnoLeB3agMKaLyEBZ5ZVfwtnS5VJKqXBt8o5ooCWVy2H87GsZshp7DeKE25eWLyd1Ccuh2ZubQUkgpiVux/*))))#532k8uvf" + + [bitcoind_config] + network = "bitcoin" + cookie_path = "/home/user/.bitcoin/.cookie" + addr = "127.0.0.1:8332" + poll_interval_secs = 18 + "#.trim_start().replace(" ", ""); + toml::from_str::(&toml_str).expect("Deserializing toml_str"); + + // A valid, round-tripping, config + let toml_str = r#" + data_dir = '/home/wizardsardine/custom/folder/' + daemon = false + log_level = 'TRACE' + main_descriptor = 'wsh(andor(thresh(1,pk(xpub6BaZSKgpaVvibu2k78QsqeDWXp92xLHZxiu1WoqLB9hKhsBf3miBUDX7PJLgSPvkj66ThVHTqdnbXpeu8crXFmDUd4HeM4s4miQS2xsv3Qb/*)),and_v(v:multi(2,03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a,0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce),older(4)),thresh(2,pkh(xpub6AHA9hZDN11k2ijHMeS5QqHx2KP9aMBRhTDqANMnwVtdyw2TDYRmF8PjpvwUFcL1Et8Hj59S3gTSMcUQ5gAqTz3Wd8EsMTmF3DChhqPQBnU/*),a:pkh(xpub6AaffFGfH6WXfm6pwWzmUMuECQnoLeB3agMKaLyEBZ5ZVfwtnS5VJKqXBt8o5ooCWVy2H87GsZshp7DeKE25eWLyd1Ccuh2ZubQUkgpiVux/*))))#532k8uvf' + + [bitcoind_config] + network = 'bitcoin' + cookie_path = '/home/user/.bitcoin/.cookie' + addr = '127.0.0.1:8332' + poll_interval_secs = 18 + "#.trim_start().replace(" ", ""); + let parsed = toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let serialized = toml::to_string_pretty(&parsed).expect("Serializing to toml"); + #[cfg(unix)] // On non-UNIX there is no 'daemon' member. + assert_eq!(toml_str, serialized); + + // Invalid desc checksum + let toml_str = r#" + daemon = false + log_level = "trace" + data_dir = "/home/wizardsardine/custom/folder/" + + # The main descriptor semantics aren't checked, yet. + main_descriptor = "wsh(andor(thresh(1,pk(xpub6BaZSKgpaVvibu2k78QsqeDWXp92xLHZxiu1WoqLB9hKhsBf3miBUDX7PJLgSPvkj66ThVHTqdnbXpeu8crXFmDUd4HeM4s4miQS2xsv3Qb/*)),and_v(v:multi(2,03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a,0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce),older(4)),thresh(2,pkh(xpub6AHA9hZDN11k2ijHMeS5QqHx2KP9aMBRhTDqANMnwVtdyw2TDYRmF8PjpvwUFcL1Et8Hj59S3gTSMcUQ5gAqTz3Wd8EsMTmF3DChhqPQBnU/*),a:pkh(xpub6AaffFGfH6WXfm6pwWzmUMuECQnoLeB3agMKaLyEBZ5ZVfwtnS5VJKqXBt8o5ooCWVy2H87GsZshp7DeKE25eWLyd1Ccuh2ZubQUkgpiVux/*))))#532k88vf" + + [bitcoind_config] + network = "bitcoin" + cookie_path = "/home/user/.bitcoin/.cookie" + addr = "127.0.0.1:8332" + poll_interval_secs = 18 + "#; + let config_res: Result = toml::from_str(toml_str); + config_res.expect_err("Deserializing an invalid toml_str"); + + // Not enough parameters: missing the network + let toml_str = r#" + daemon = false + log_level = "trace" + data_dir = "/home/wizardsardine/custom/folder/" + + # The main descriptor semantics aren't checked, yet. + main_descriptor = "wsh(andor(thresh(1,pk(xpub6BaZSKgpaVvibu2k78QsqeDWXp92xLHZxiu1WoqLB9hKhsBf3miBUDX7PJLgSPvkj66ThVHTqdnbXpeu8crXFmDUd4HeM4s4miQS2xsv3Qb/*)),and_v(v:multi(2,03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a,0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce),older(4)),thresh(2,pkh(xpub6AHA9hZDN11k2ijHMeS5QqHx2KP9aMBRhTDqANMnwVtdyw2TDYRmF8PjpvwUFcL1Et8Hj59S3gTSMcUQ5gAqTz3Wd8EsMTmF3DChhqPQBnU/*),a:pkh(xpub6AaffFGfH6WXfm6pwWzmUMuECQnoLeB3agMKaLyEBZ5ZVfwtnS5VJKqXBt8o5ooCWVy2H87GsZshp7DeKE25eWLyd1Ccuh2ZubQUkgpiVux/*))))#532k8uvf" + + [bitcoind_config] + cookie_path = "/home/user/.bitcoin/.cookie" + addr = "127.0.0.1:8332" + poll_interval_secs = 18 + "#; + let config_res: Result = toml::from_str(toml_str); + config_res.expect_err("Deserializing an invalid toml_str"); + } + + #[test] + fn config_directory() { + let filepath = config_file_path().expect("Getting config file path"); + + #[cfg(target_os = "linux")] + { + assert!(filepath.as_path().starts_with("/home/")); + assert!(filepath.as_path().ends_with(".minisafe/minisafe.toml")); + } + + #[cfg(target_os = "macos")] + assert!(filepath + .as_path() + .ends_with("Library/Application Support/Minisafe/minisafe.toml")); + + #[cfg(target_os = "windows")] + assert!(filepath + .as_path() + .ends_with(r#"AppData\Roaming\Minisafe\minisafe.toml"#)); + } +} diff --git a/src/daemonize.rs b/src/daemonize.rs new file mode 100644 index 00000000..0ce62afe --- /dev/null +++ b/src/daemonize.rs @@ -0,0 +1,69 @@ +use std::env::set_current_dir; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::prelude::*; +use std::os::unix::io::AsRawFd; +use std::path::Path; + +// This code was highly inspired from Frank Denis (@jedisct1) 'daemonize-simple' crate, +// available at https://github.com/jedisct1/rust-daemonize-simple/blob/master/src/unix.rs . +// MIT licensed according to https://github.com/jedisct1/rust-daemonize-simple/blob/master/Cargo.toml +pub unsafe fn daemonize( + chdir: &Path, + pid_file: &Path, + log_file: &Path, +) -> Result<(), &'static str> { + match libc::fork() { + -1 => return Err("fork() failed"), + 0 => {} + _ => { + libc::_exit(0); + } + } + libc::setsid(); + match libc::fork() { + -1 => return Err("Second fork() failed"), + 0 => {} + _ => { + libc::_exit(0); + } + }; + + let fd = OpenOptions::new() + .read(true) + .open("/dev/null") + .map_err(|_| "Unable to open the stdin file")?; + if libc::dup2(fd.as_raw_fd(), 0) == -1 { + return Err("dup2(stdin) failed"); + } + let fd = OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .map_err(|_| "Unable to open the stdout file")?; + if libc::dup2(fd.as_raw_fd(), 1) == -1 { + return Err("dup2(stdout) failed"); + } + let fd = OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .map_err(|_| "Unable to open the stderr file")?; + if libc::dup2(fd.as_raw_fd(), 2) == -1 { + return Err("dup2(stderr) failed"); + } + + let pid = match libc::getpid() { + -1 => return Err("getpid() failed"), + pid => pid, + }; + let pid_str = format!("{}", pid); + File::create(pid_file) + .map_err(|_| "Creating the PID file failed")? + .write_all(pid_str.as_bytes()) + .map_err(|_| "Writing to the PID file failed")?; + + set_current_dir(chdir).map_err(|_| "chdir() failed")?; + + Ok(()) +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 00000000..bf9e9bc9 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,6 @@ +///! Database interface for Minisafe. +///! +///! Record wallet metadata, spent and unspent coins, ongoing transactions. +pub mod sqlite; + +pub trait DatabaseInterface {} diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs new file mode 100644 index 00000000..87ceeb5c --- /dev/null +++ b/src/database/sqlite/mod.rs @@ -0,0 +1,235 @@ +///! Implementation of the database interface using SQLite. +///! +///! We use a bundled SQLite that is compiled with SQLITE_THREADSAFE. Sqlite.org states: +///! > Multi-thread. In this mode, SQLite can be safely used by multiple threads provided that +///! > no single database connection is used simultaneously in two or more threads. +///! +///! We leverage SQLite's `unlock_notify` feature to synchronize writes accross connection. More +///! about it at https://sqlite.org/unlock_notify.html. +mod schema; +mod utils; + +use schema::{DbTip, DbWallet}; +use utils::{create_fresh_db, db_query}; + +use std::{convert::TryInto, fmt, io, path}; + +use miniscript::{bitcoin, Descriptor, DescriptorPublicKey}; + +const DB_VERSION: i64 = 0; + +#[derive(Debug)] +pub enum SqliteDbError { + FileCreation(io::Error), + FileNotFound(path::PathBuf), + UnsupportedVersion(i64), + InvalidNetwork(bitcoin::Network), + DescriptorMismatch(Descriptor), + Rusqlite(rusqlite::Error), +} + +impl std::fmt::Display for SqliteDbError { + fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result { + match self { + SqliteDbError::FileCreation(e) => { + write!(f, "Error when create SQLite database file: '{}'", e) + } + SqliteDbError::FileNotFound(p) => { + write!(f, "SQLite database file not found at '{}'.", p.display()) + } + SqliteDbError::UnsupportedVersion(v) => { + write!(f, "Unsupported database version '{}'.", v) + } + SqliteDbError::InvalidNetwork(net) => { + write!(f, "Database was created for network '{}'.", net) + } + SqliteDbError::DescriptorMismatch(desc) => { + write!(f, "Database descriptor mismatch: '{}'.", desc) + } + SqliteDbError::Rusqlite(e) => write!(f, "SQLite error: '{}'", e), + } + } +} + +impl std::error::Error for SqliteDbError {} + +impl From for SqliteDbError { + fn from(e: io::Error) -> Self { + SqliteDbError::FileCreation(e) + } +} + +impl From for SqliteDbError { + fn from(e: rusqlite::Error) -> Self { + SqliteDbError::Rusqlite(e) + } +} + +#[derive(Debug, Clone)] +pub struct FreshDbOptions { + pub bitcoind_network: bitcoin::Network, + pub main_descriptor: Descriptor, +} + +#[derive(Debug, Clone)] +pub struct SqliteDb { + db_path: path::PathBuf, +} + +impl SqliteDb { + /// Instanciate an SQLite database either from an existing database file or by creating a fresh + /// one. + pub fn new( + db_path: path::PathBuf, + fresh_options: Option, + ) -> Result { + // Create the database if needed, and make sure the db file exists. + if let Some(options) = fresh_options { + create_fresh_db(&db_path, options)?; + log::info!("Created a fresh database at {}.", db_path.display()); + } + if !db_path.exists() { + return Err(SqliteDbError::FileNotFound(db_path.to_path_buf())); + } + + Ok(SqliteDb { db_path }) + } + + /// Get a new connection to the database. + pub fn connection(&self) -> Result { + let conn = rusqlite::Connection::open(&self.db_path)?; + conn.busy_timeout(std::time::Duration::from_secs(60))?; + Ok(SqliteConn { conn }) + } + + /// Perform startup sanity checks. + pub fn sanity_check( + &self, + bitcoind_network: bitcoin::Network, + main_descriptor: &Descriptor, + ) -> Result<(), SqliteDbError> { + let mut conn = self.connection()?; + + // Check if there database isn't from the future. + // NOTE: we'll do migration there eventually. Until then be strict on the check. + let db_version = conn.db_version(); + if db_version != DB_VERSION { + return Err(SqliteDbError::UnsupportedVersion(db_version)); + } + + // The config and the db should be on the same network. + let db_tip = conn.db_tip(); + if db_tip.network != bitcoind_network { + return Err(SqliteDbError::InvalidNetwork(db_tip.network)); + } + + // The config and db descriptors must match! + let db_wallet = conn.db_wallet(); + if &db_wallet.main_descriptor != main_descriptor { + return Err(SqliteDbError::DescriptorMismatch(db_wallet.main_descriptor)); + } + + Ok(()) + } +} + +pub struct SqliteConn { + conn: rusqlite::Connection, +} + +impl SqliteConn { + pub fn db_version(&mut self) -> i64 { + db_query( + &mut self.conn, + "SELECT version FROM version", + rusqlite::params![], + |row| { + let version: i64 = row.get(0)?; + Ok(version) + }, + ) + .expect("db must not fail") + .pop() + .expect("There is always a row in the version table") + } + + /// Get the network tip. + pub fn db_tip(&mut self) -> DbTip { + db_query( + &mut self.conn, + "SELECT * FROM tip", + rusqlite::params![], + |row| row.try_into(), + ) + .expect("Db must not fail") + .pop() + .expect("There is always a row in the tip table") + } + + /// Get the information about the wallet. + pub fn db_wallet(&mut self) -> DbWallet { + db_query( + &mut self.conn, + "SELECT * FROM wallets", + rusqlite::params![], + |row| row.try_into(), + ) + .expect("Db must not fail") + .pop() + .expect("There is always a row in the wallet table") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{env, fs, path, process, str::FromStr, thread}; + + #[test] + fn db_startup_sanity_checks() { + let tmp_dir = env::temp_dir().join(format!( + "minisafed-unit-tests-{}-{:?}", + process::id(), + thread::current().id() + )); + fs::create_dir_all(&tmp_dir).unwrap(); + + let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")] + .iter() + .collect(); + assert!(SqliteDb::new(db_path.clone(), None) + .unwrap_err() + .to_string() + .contains("database file not found")); + + let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; + let desc = Descriptor::::from_str(desc_str).unwrap(); + let options = FreshDbOptions { + bitcoind_network: bitcoin::Network::Bitcoin, + main_descriptor: desc.clone(), + }; + + let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + db.sanity_check(bitcoin::Network::Testnet, &desc) + .unwrap_err() + .to_string() + .contains("Database was created for network"); + fs::remove_file(&db_path).unwrap(); + let other_desc_str = "wsh(andor(pk(037a27a76ebf33594c785e4fa41607860a960bb5aa3039654297b05bff57e4f9a9),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))"; + let other_desc = Descriptor::::from_str(other_desc_str).unwrap(); + let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + db.sanity_check(bitcoin::Network::Bitcoin, &other_desc) + .unwrap_err() + .to_string() + .contains("Database descriptor mismatch"); + fs::remove_file(&db_path).unwrap(); + // TODO: version check + + let db = SqliteDb::new(db_path.clone(), Some(options.clone())).unwrap(); + db.sanity_check(bitcoin::Network::Bitcoin, &desc).unwrap(); + let db = SqliteDb::new(db_path.clone(), None).unwrap(); + db.sanity_check(bitcoin::Network::Bitcoin, &desc).unwrap(); + + fs::remove_dir_all(&tmp_dir).unwrap(); + } +} diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs new file mode 100644 index 00000000..d58fb06b --- /dev/null +++ b/src/database/sqlite/schema.rs @@ -0,0 +1,90 @@ +use std::{convert::TryFrom, str::FromStr}; + +use miniscript::{ + bitcoin::{self, consensus::encode, util::bip32}, + Descriptor, DescriptorPublicKey, +}; + +pub const SCHEMA: &str = "\ +CREATE TABLE version ( + version INTEGER NOT NULL +); + +/* About the Bitcoin network. */ +CREATE TABLE tip ( + network TEXT NOT NULL, + blockheight INTEGER, + blockhash BLOB +); + +/* This stores metadata about our wallet. We only support single wallet for + * now (and the foreseeable future). + */ +CREATE TABLE wallets ( + id INTEGER PRIMARY KEY NOT NULL, + timestamp INTEGER NOT NULL, + main_descriptor TEXT NOT NULL, + deposit_derivation_index INTEGER NOT NULL +); +"; + +/// A row in the "tip" table. +#[derive(Clone, Debug)] +pub struct DbTip { + pub network: bitcoin::Network, + pub block_height: Option, + pub block_hash: Option, +} + +impl TryFrom<&rusqlite::Row<'_>> for DbTip { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let network: String = row.get(0)?; + let network = bitcoin::Network::from_str(&network) + .expect("Insane database: can't parse network string"); + + let block_height: Option = row.get(1)?; + let block_hash: Option> = row.get(2)?; + let block_hash: Option = block_hash + .map(|h| encode::deserialize(&h).expect("Insane database: can't parse network string")); + + Ok(DbTip { + network, + block_height, + block_hash, + }) + } +} + +/// A row in the "wallets" table. +#[derive(Clone, Debug)] +pub struct DbWallet { + pub id: i64, + pub timestamp: u32, + pub main_descriptor: Descriptor, + pub deposit_derivation_index: bip32::ChildNumber, +} + +impl TryFrom<&rusqlite::Row<'_>> for DbWallet { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let id = row.get(0)?; + let timestamp = row.get(1)?; + + let desc_str: String = row.get(2)?; + let main_descriptor = Descriptor::::from_str(&desc_str) + .expect("Insane database: can't parse deposit descriptor"); + + let der_idx: u32 = row.get(3)?; + let deposit_derivation_index = bip32::ChildNumber::from(der_idx); + + Ok(DbWallet { + id, + timestamp, + main_descriptor, + deposit_derivation_index, + }) + } +} diff --git a/src/database/sqlite/utils.rs b/src/database/sqlite/utils.rs new file mode 100644 index 00000000..40b4f3f7 --- /dev/null +++ b/src/database/sqlite/utils.rs @@ -0,0 +1,94 @@ +use crate::database::sqlite::{schema::SCHEMA, FreshDbOptions, SqliteDbError, DB_VERSION}; + +use std::{convert::TryInto, fs, path, time}; + +/// Perform a set of modifications to the database inside a single transaction +pub fn db_exec(conn: &mut rusqlite::Connection, modifications: F) -> Result<(), rusqlite::Error> +where + F: FnOnce(&rusqlite::Transaction) -> rusqlite::Result<()>, +{ + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + modifications(&tx)?; + tx.commit() +} + +/// Internal helper for queries boilerplate +pub fn db_query( + conn: &mut rusqlite::Connection, + stmt_str: &str, + params: P, + f: F, +) -> Result, rusqlite::Error> +where + P: IntoIterator + rusqlite::Params, + P::Item: rusqlite::ToSql, + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + // rustc says 'borrowed value does not live long enough' + let x = conn + .prepare(stmt_str)? + .query_map(params, f)? + .collect::>>(); + + x +} + +// Sqlite supports up to i64, thus rusqlite prevents us from inserting u64's. +// We use this to panic rather than inserting a truncated integer into the database (as we'd have +// done by using `n as u32`). +fn timestamp_to_u32(n: u64) -> u32 { + n.try_into() + .expect("Is this the year 2106 yet? Misconfigured system clock.") +} + +// Create the db file with RW permissions only for the user +pub fn create_db_file(db_path: &path::Path) -> Result<(), std::io::Error> { + let mut options = fs::OpenOptions::new(); + let options = options.read(true).write(true).create_new(true); + + #[cfg(unix)] + return { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600).open(db_path)?; + Ok(()) + }; + + #[cfg(not(unix))] + return { + // TODO: permissions for Windows... + options.open(db_path)?; + Ok(()) + }; +} + +pub fn create_fresh_db(db_path: &path::Path, options: FreshDbOptions) -> Result<(), SqliteDbError> { + create_db_file(db_path)?; + + let timestamp = time::SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .map(|dur| timestamp_to_u32(dur.as_secs())) + .expect("System clock went backward the epoch?"); + + let mut conn = rusqlite::Connection::open(db_path)?; + db_exec(&mut conn, |tx| { + tx.execute_batch(SCHEMA)?; + tx.execute( + "INSERT INTO version (version) VALUES (?1)", + rusqlite::params![DB_VERSION], + )?; + tx.execute( + "INSERT INTO tip (network, blockheight, blockhash) VALUES (?1, NULL, NULL)", + rusqlite::params![options.bitcoind_network.to_string()], + )?; + tx.execute( + "INSERT INTO wallets (timestamp, main_descriptor, deposit_derivation_index) \ + VALUES (?1, ?2, ?3)", + rusqlite::params![timestamp, options.main_descriptor.to_string(), 0,], + )?; + + Ok(()) + })?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..a4c6618a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,430 @@ +mod bitcoin; +pub mod config; +#[cfg(unix)] +mod daemonize; +mod database; + +use crate::{ + bitcoin::d::{BitcoinD, BitcoindError}, + config::{config_folder_path, Config}, + database::sqlite::{FreshDbOptions, SqliteDb, SqliteDbError}, +}; + +use std::{error, fmt, fs, io, path}; + +#[cfg(not(test))] +use std::{panic, process}; +// A panic in any thread should stop the main thread, and print the panic. +#[cfg(not(test))] +fn setup_panic_hook() { + panic::set_hook(Box::new(move |panic_info| { + let file = panic_info + .location() + .map(|l| l.file()) + .unwrap_or_else(|| "'unknown'"); + let line = panic_info + .location() + .map(|l| l.line().to_string()) + .unwrap_or_else(|| "'unknown'".to_string()); + + let bt = backtrace::Backtrace::new(); + let info = panic_info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic_info.payload().downcast_ref::().cloned()); + log::error!( + "panic occurred at line {} of file {}: {:?}\n{:?}", + line, + file, + info, + bt + ); + + process::exit(1); + })); +} + +#[derive(Debug)] +pub enum StartupError { + Io(io::Error), + DefaultDataDirNotFound, + DatadirCreation(path::PathBuf, io::Error), + Database(SqliteDbError), + Bitcoind(BitcoindError), + #[cfg(unix)] + Daemonization(&'static str), +} + +impl fmt::Display for StartupError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "{}", e), + Self::DefaultDataDirNotFound => write!( + f, + "Not data directory was specified and a default path could not be determined for this platform." + ), + Self::DatadirCreation(dir_path, e) => write!( + f, + "Could not create data directory at '{}': '{}'", dir_path.display(), e + ), + Self::Database(e) => write!(f, "Error initializing database: '{}'.", e), + Self::Bitcoind(e) => write!(f, "Error setting up bitcoind interface: '{}'.", e), + #[cfg(unix)] + Self::Daemonization(e) => write!(f, "Error when daemonizing: '{}'.", e), + } + } +} + +impl error::Error for StartupError {} + +impl From for StartupError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +impl From for StartupError { + fn from(e: SqliteDbError) -> Self { + Self::Database(e) + } +} + +impl From for StartupError { + fn from(e: BitcoindError) -> Self { + Self::Bitcoind(e) + } +} + +fn create_datadir(datadir_path: &path::Path) -> Result<(), StartupError> { + #[cfg(unix)] + return { + use fs::DirBuilder; + use std::os::unix::fs::DirBuilderExt; + + let mut builder = DirBuilder::new(); + builder + .mode(0o700) + .recursive(true) + .create(datadir_path) + .map_err(|e| StartupError::DatadirCreation(datadir_path.to_path_buf(), e)) + }; + + // TODO: permissions on Windows.. + #[cfg(not(unix))] + return { + fs::create_dir_all(datadir_path) + .map_err(|e| StartupError::DatadirCreation(datadir_path.to_path_buf(), e)) + }; +} + +pub struct DaemonHandle {} + +impl DaemonHandle { + /// This starts the Minisafe daemon. Call `shutdown` to shut it down. + /// + /// **Note**: we internally use threads, and set a panic hook. A downstream application must + /// not overwrite this panic hook. + pub fn start(config: Config) -> Result { + #[cfg(not(test))] + setup_panic_hook(); + + // First, check the data directory + let mut data_dir = config + .data_dir + .unwrap_or(config_folder_path().ok_or(StartupError::DefaultDataDirNotFound)?); + data_dir.push(config.bitcoind_config.network.to_string()); + let fresh_data_dir = !data_dir.as_path().exists(); + if fresh_data_dir { + create_datadir(&data_dir)?; + 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(); + let options = if fresh_data_dir { + Some(FreshDbOptions { + bitcoind_network: config.bitcoind_config.network, + main_descriptor: config.main_descriptor.clone(), + }) + } else { + None + }; + 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."); + + // If we are on a UNIX system and they told us to daemonize, do it now. + // NOTE: it's safe to daemonize now, as we don't carry any open DB connection + // https://www.sqlite.org/howtocorrupt.html#_carrying_an_open_database_connection_across_a_fork_ + #[cfg(unix)] + if config.daemon { + log::info!("Daemonizing"); + let log_file = data_dir.as_path().join("log"); + let pid_file = data_dir.as_path().join("revaultd.pid"); + unsafe { + daemonize::daemonize(&data_dir, &log_file, &pid_file) + .map_err(StartupError::Daemonization)?; + } + } + + Ok(Self {}) + } + + // NOTE: this moves out the data as it should not be reused after shutdown + /// Shut down the Minisafe daemon. + pub fn shutdown(self) {} +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::config::BitcoindConfig; + + use miniscript::{bitcoin, Descriptor, DescriptorPublicKey}; + 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() { + let tmp_dir = env::temp_dir().join(format!( + "minisafed-unit-tests-{}-{:?}", + process::id(), + thread::current().id() + )); + fs::create_dir_all(&tmp_dir).unwrap(); + 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(); + + // 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, + data_dir: Some(data_dir.clone()), + #[cfg(unix)] + daemon: false, + log_level: log::LevelFilter::Debug, + main_descriptor: desc, + }; + + // 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(); + } +}