Merge #2: Minisafe daemon backbone

512b5b7d120e929b6e2f275d4427d8c5207d6b85 daemon: gate startup unit test to UNIX only (Antoine Poinsot)
ad3297755889a5eb14745e2908a8185eedef6b3e Don't compile daemon and cli binaries on Windows (Antoine Poinsot)
11cc4dc2e689981a308e99882eb7b9a4c66f2117 Cargo: pin rusqlite dependency for MSRV (Antoine Poinsot)
7340c13142b84e1709c765a6a88018ac7177d55a daemon: implement daemonize for UNIX platforms (Antoine Poinsot)
c095346e17bbbbbf8f6877119ef8a5e4ee33273c Introduce the Bitcoin network interface along with a bitcoind module (Antoine Poinsot)
8626e05a55f8e0141ce5ffd917f0f39497b34c63 ci: basic GA for unit tests on various platforms (Antoine Poinsot)
b0196ab5294def4afbe4b9f5d130785e189c392e daemon: introduce an SQLite database (Antoine Poinsot)
989a2cf8fde3178a41e600c684756c3270b240bd daemon: introduce the DaemonHandle, which for now only creates a datadir (Antoine Poinsot)
1b3519644802de3cfef1723f2f144dc08fe1f358 daemon: backbone and configuration parsing (Antoine Poinsot)

Pull request description:

  This implements the configuration, database backend, bitcoind connection and initialization of the Minisafe daemon.

  A lot of code was ported from revaultd and miradord, although the interface (for bitcoind and the database) changed and many things were re-written. This contains some basic unit tests for startup and database initialization. I intend to port the functional test framework from revaultd in a future PR.

ACKs for top commit:
  darosior:
    ACK 512b5b7d120e929b6e2f275d4427d8c5207d6b85 -- most of this code was reviewed and tested in `revaultd` and `miradord`. I also manually tested it.

Tree-SHA512: a4c1384798b976d25391513f6455f96b73ed3b07b668ff1cf1f3a77e6bce3b1e2f559df1128dbf47e30a77c4bf30d177176b6400a34e7237a5227053f34b8dd5
This commit is contained in:
Antoine Poinsot 2022-07-25 11:44:31 +02:00
commit 1aa9414927
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
15 changed files with 2829 additions and 0 deletions

44
.github/workflows/main.yml vendored Normal file
View File

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

494
Cargo.lock generated Normal file
View File

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

55
Cargo.toml Normal file
View File

@ -0,0 +1,55 @@
[package]
name = "minisafed"
version = "0.0.1"
authors = ["Antoine Poinsot <darosior@protonmail.com>"]
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"

174
src/bin/cli.rs Normal file
View File

@ -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] <command> [<param 1> <param 2> ...]");
process::exit(1);
}
// Returns (Maybe(special conf file), Raw, Method name, Maybe(List of parameters))
fn parse_args(mut args: Vec<String>) -> (Option<PathBuf>, bool, String, Vec<String>) {
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<String>) -> Json {
let method = Json::String(method);
let params = Json::Array(params.into_iter().map(from_str_hack).collect::<Vec<Json>>());
let mut object = serde_json::Map::<String, Json>::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>) -> 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<u8>, bytes_read: usize) -> Vec<u8> {
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::<Json>(&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,
}
}
}

69
src/bin/daemon.rs Normal file
View File

@ -0,0 +1,69 @@
use std::{
env,
io::{self, Write},
path::PathBuf,
process, time,
};
use minisafed::{config::Config, DaemonHandle};
fn parse_args(args: Vec<String>) -> Option<PathBuf> {
if args.len() == 1 {
return None;
}
if args.len() != 3 {
eprintln!("Unknown arguments '{:?}'.", args);
eprintln!("Only '--conf <configuration file path>' 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");
}

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(())
}

313
src/config.rs Normal file
View File

@ -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<T, D::Error>
where
D: Deserializer<'de>,
T: FromStr,
<T as 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<T: std::fmt::Display, S: Serializer>(
field: T,
s: S,
) -> Result<S::Ok, S::Error> {
s.serialize_str(&field.to_string())
}
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
pub fn serialize_duration<S: Serializer>(duration: &Duration, s: S) -> Result<S::Ok, S::Error> {
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<PathBuf>,
/// 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<DescriptorPublicKey>,
/// 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<DescriptorPublicKey>),
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<std::io::Error> 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/<network>/" directory in the XDG standard configuration directory for
/// all OSes but Linux-based ones, for which it's `~/.minisafe/<network>/`.
/// 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<PathBuf> {
#[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<PathBuf> {
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<PathBuf>) -> Result<Config, ConfigError> {
let config_file =
custom_path.unwrap_or(config_file_path().ok_or_else(|| ConfigError::DatadirNotFound)?);
let config = toml::from_slice::<Config>(&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::<Config>(&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::<Config>(&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<Config, toml::de::Error> = 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<Config, toml::de::Error> = 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"#));
}
}

69
src/daemonize.rs Normal file
View File

@ -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(())
}

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

@ -0,0 +1,6 @@
///! Database interface for Minisafe.
///!
///! Record wallet metadata, spent and unspent coins, ongoing transactions.
pub mod sqlite;
pub trait DatabaseInterface {}

235
src/database/sqlite/mod.rs Normal file
View File

@ -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<DescriptorPublicKey>),
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<io::Error> for SqliteDbError {
fn from(e: io::Error) -> Self {
SqliteDbError::FileCreation(e)
}
}
impl From<rusqlite::Error> 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<DescriptorPublicKey>,
}
#[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<FreshDbOptions>,
) -> Result<SqliteDb, SqliteDbError> {
// 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<SqliteConn, SqliteDbError> {
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<DescriptorPublicKey>,
) -> 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::<DescriptorPublicKey>::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::<DescriptorPublicKey>::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();
}
}

View File

@ -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<i32>,
pub block_hash: Option<bitcoin::BlockHash>,
}
impl TryFrom<&rusqlite::Row<'_>> for DbTip {
type Error = rusqlite::Error;
fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
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<i32> = row.get(1)?;
let block_hash: Option<Vec<u8>> = row.get(2)?;
let block_hash: Option<bitcoin::BlockHash> = 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<DescriptorPublicKey>,
pub deposit_derivation_index: bip32::ChildNumber,
}
impl TryFrom<&rusqlite::Row<'_>> for DbWallet {
type Error = rusqlite::Error;
fn try_from(row: &rusqlite::Row) -> Result<Self, Self::Error> {
let id = row.get(0)?;
let timestamp = row.get(1)?;
let desc_str: String = row.get(2)?;
let main_descriptor = Descriptor::<DescriptorPublicKey>::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,
})
}
}

View File

@ -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<F>(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<P, F, T>(
conn: &mut rusqlite::Connection,
stmt_str: &str,
params: P,
f: F,
) -> Result<Vec<T>, rusqlite::Error>
where
P: IntoIterator + rusqlite::Params,
P::Item: rusqlite::ToSql,
F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
// rustc says 'borrowed value does not live long enough'
let x = conn
.prepare(stmt_str)?
.query_map(params, f)?
.collect::<rusqlite::Result<Vec<T>>>();
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(())
}

430
src/lib.rs Normal file
View File

@ -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::<String>().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<io::Error> for StartupError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<SqliteDbError> for StartupError {
fn from(e: SqliteDbError) -> Self {
Self::Database(e)
}
}
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 {
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<Self, StartupError> {
#[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::<DescriptorPublicKey>::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();
}
}