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:
commit
1aa9414927
44
.github/workflows/main.yml
vendored
Normal file
44
.github/workflows/main.yml
vendored
Normal 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
494
Cargo.lock
generated
Normal 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
55
Cargo.toml
Normal 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
174
src/bin/cli.rs
Normal 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
69
src/bin/daemon.rs
Normal 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
491
src/bitcoin/d/mod.rs
Normal 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", ¶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<String> {
|
||||
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<DescriptorPublicKey>) -> Option<String> {
|
||||
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<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",
|
||||
¶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<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
6
src/bitcoin/mod.rs
Normal 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
259
src/bitcoind/mod.rs
Normal 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
313
src/config.rs
Normal 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
69
src/daemonize.rs
Normal 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
6
src/database/mod.rs
Normal 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
235
src/database/sqlite/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
90
src/database/sqlite/schema.rs
Normal file
90
src/database/sqlite/schema.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
94
src/database/sqlite/utils.rs
Normal file
94
src/database/sqlite/utils.rs
Normal 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
430
src/lib.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user