Merge #11: JSONRPC2 server
798bf67e21977ad18ee680a14e23e534f64a3ea5 commands: derive Deserialize for commands results (Antoine Poinsot) fe5de96312dc8d57f982c060d4b9b045c2194163 Shorten the paths to the dummy datadirs in the unit tests (Antoine Poinsot) 069bca546a23ce1b1714b9564973f13a6f090536 jsonrpc: don't use a Mutex to share the DaemonControl between threads (Antoine Poinsot) e6fcdd5b095232729cbeef4bd42bf6e57d77ffde jsonrpc: don't umask in unit tests (Antoine Poinsot) 14bd3890dcf6c5dc9fbe25992710c80c9ff107c7 doc: add an API.md documenting the JSONRPC API. (Antoine Poinsot) eeac67dc0a7724ef4ca9003b7e734f546e62c0ae qa: shut down the daemon via the JSONRPC interface at teardown (Antoine Poinsot) 726209cc0a07d12c71041573313d5b34def30bbd jsonrpc: add a 'stop' command (Antoine Poinsot) d03c46996798d9e5d68858c4d4631e6b3295f9b3 Introduce a testutils module with a DummyMinisafe for unit tests (Antoine Poinsot) e510c0a30dd18445d595e403d5cddd8fe04ef1b6 config: separate the Bitcoin and bitcoind-specifc settings (Antoine Poinsot) ea3595349d0051912ce187ca27de7a6df9b6e7e6 Accept a custom database interface when starting the daemon (Antoine Poinsot) f365effacd12d0d3fce105dbc59e7ccf5f85488c Accept a custom Bitcoin interface when starting the daemon (Antoine Poinsot) 93098be7dcb82808f6644eee97005c6607eb0cda tests: implement the connection to the daemon's RPC server (Antoine Poinsot) 0d55d6c45599c2a4996f10c5066041369c06d9b1 jsonrpc: a simple JSONRPC2 server (Antoine Poinsot) dd1b353a36feb04b1af5553e9c71763957996af0 commands: derive serde::Serialize for results (Antoine Poinsot) 86c1d32662e7fda9887da5f799e42cce73228448 bitcoin: name the Bitcoin poller thread (Antoine Poinsot) 6b2e90181484d5dd0ed4ee23b78ab705d98b5c36 bit poller: fix a off logged percentage (Antoine Poinsot) 6ff43fcd9543acb79d6f4c343e102f5029f5480f daemon: log the name of the current thread, too. (Antoine Poinsot) a067daf16b99a8bdaad1034f2e539c60108a1b4a tests: use a ranged descriptor (Antoine Poinsot) 5228ee370bad72efd98d27e2181fb1ee1c27bae2 tests: remove mistakenly committed file (Antoine Poinsot) Pull request description: On top of #9, this introduces a simple JSONRPC server calling into the exposed commands. I started by reusing code from revaultd, but ended up rewriting it entirely. Compared to it, it t is simpler, smaller and dependency-less. In order to sanity check the whole server startup and teardown in unit tests, this took a detour by introducing a `testutils` module with mocked Bitcoin and database interfaces. Those will be useful later. This introduces a JSONRPC-specifc command: `stop`, which shuts down the daemon. We add a `doc/API.md` adapted from #1 with documentation for the existing commands. ACKs for top commit: darosior: self-ACK 798bf67e21977ad18ee680a14e23e534f64a3ea5 -- tested using following PRs #13 and #17. Tree-SHA512: 2eb07c29904e1f71f7375aa5729d5f3bfc5649627a92ef6e49dd273f91a8d4c4243b495eb87d09fa44198ef125066161cf6a2c86af14b4135d806c8baca217b2
This commit is contained in:
commit
b548451292
66
doc/API.md
Normal file
66
doc/API.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Minisafe API
|
||||
|
||||
`minisafe` exposes a [JSON-RPC 2.0](https://www.jsonrpc.org/specification)
|
||||
interface over a Unix Domain socket.
|
||||
|
||||
Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`.
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| [`stop`](#stop) | Stops the minisafe daemon |
|
||||
| [`getinfo`](#getinfo) | Get general information about the daemon |
|
||||
| [`getnewaddress`](#getnewaddress) | Get a new receiving address |
|
||||
|
||||
# Reference
|
||||
|
||||
## General
|
||||
|
||||
### `stop`
|
||||
|
||||
Stops the minisafe daemon.
|
||||
|
||||
#### Response
|
||||
|
||||
Returns an empty response.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------ | ----------- |
|
||||
|
||||
### `getinfo`
|
||||
|
||||
General information about the daemon
|
||||
|
||||
#### Request
|
||||
|
||||
This command does not take any parameter for now.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------- | ----------------------------------------------------------- |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ------- | -------------------------------------------------------------------------------------------- |
|
||||
| `version` | string | Version following the [SimVer](http://www.simver.org/) format |
|
||||
| `network` | string | Answer can be `mainnet`, `testnet`, `regtest` |
|
||||
| `blockheight` | integer | Current block height |
|
||||
| `sync` | float | The synchronization progress as percentage (`0 < sync < 1`) |
|
||||
| `descriptors` | object | Object with the name of the descriptor as key and the descriptor string as value |
|
||||
|
||||
### `getnewaddress`
|
||||
|
||||
Get a new address for receiving coins. This will always generate a new address regardless of whether
|
||||
it was used or not.
|
||||
|
||||
#### Request
|
||||
|
||||
This command does not take any parameter for now.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------- | ----------------------------------------------------------- |
|
||||
|
||||
#### Response
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------ | ------------------ |
|
||||
| `address` | string | A Bitcoin address |
|
||||
@ -14,7 +14,7 @@ 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> ...]");
|
||||
eprintln!(" minisafe-cli [--conf conf_path] [--raw] <command> [<param 1> <param 2> ...]");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ fn rpc_request(method: String, params: Vec<String>) -> Json {
|
||||
object.insert("jsonrpc".to_string(), Json::String("2.0".to_string()));
|
||||
object.insert(
|
||||
"id".to_string(),
|
||||
Json::String(format!("revault-cli-{}", process::id())),
|
||||
Json::String(format!("minisafe-cli-{}", process::id())),
|
||||
);
|
||||
object.insert("method".to_string(), method);
|
||||
object.insert("params".to_string(), params);
|
||||
@ -93,8 +93,8 @@ fn socket_file(conf_file: Option<PathBuf>) -> PathBuf {
|
||||
|
||||
[
|
||||
data_dir,
|
||||
config.bitcoind_config.network.to_string().as_str(),
|
||||
"revaultd_rpc",
|
||||
config.bitcoin_config.network.to_string().as_str(),
|
||||
"minisafed_rpc",
|
||||
]
|
||||
.iter()
|
||||
.collect()
|
||||
@ -127,7 +127,7 @@ fn main() {
|
||||
process::exit(1);
|
||||
});
|
||||
socket
|
||||
.write_all(request.to_string().as_bytes())
|
||||
.write_all(&[request.to_string().as_bytes(), b"\n"].concat())
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("Writing to {:?}: '{}'", &socket_file, e);
|
||||
process::exit(1);
|
||||
@ -160,7 +160,7 @@ fn main() {
|
||||
println!("{:#}", serde_json::json!({ "error": e }));
|
||||
} else {
|
||||
log::warn!(
|
||||
"revaultd response doesn't contain result or error: '{}'",
|
||||
"minisafed response doesn't contain result or error: '{}'",
|
||||
response
|
||||
);
|
||||
println!("{:#}", response);
|
||||
|
||||
@ -2,7 +2,7 @@ use std::{
|
||||
env,
|
||||
io::{self, Write},
|
||||
path::PathBuf,
|
||||
process, time,
|
||||
process, thread, time,
|
||||
};
|
||||
|
||||
use minisafe::{config::Config, DaemonHandle};
|
||||
@ -25,7 +25,7 @@ fn setup_logger(log_level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
let dispatcher = fern::Dispatch::new()
|
||||
.format(|out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"[{}][{}][{}] {}",
|
||||
"[{}][{}][{}][thread {}] {}",
|
||||
time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap_or_else(|e| {
|
||||
@ -35,6 +35,7 @@ fn setup_logger(log_level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
.as_secs(),
|
||||
record.target(),
|
||||
record.level(),
|
||||
thread::current().name().unwrap_or("unnamed"),
|
||||
message
|
||||
))
|
||||
})
|
||||
@ -58,11 +59,13 @@ fn main() {
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
let daemon = DaemonHandle::start(config).unwrap_or_else(|e| {
|
||||
let daemon = DaemonHandle::start_default(config).unwrap_or_else(|e| {
|
||||
// The panic hook will log::error
|
||||
panic!("Starting Minisafe daemon: {}", e);
|
||||
});
|
||||
daemon.shutdown();
|
||||
daemon
|
||||
.rpc_server()
|
||||
.expect("JSONRPC server must terminate cleanly");
|
||||
|
||||
// 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.
|
||||
|
||||
@ -9,7 +9,7 @@ use std::sync;
|
||||
use miniscript::bitcoin;
|
||||
|
||||
/// Information about the best block in the chain
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
|
||||
pub struct BlockChainTip {
|
||||
pub hash: bitcoin::BlockHash,
|
||||
pub height: i32,
|
||||
@ -28,20 +28,33 @@ pub trait BitcoinInterface: Send {
|
||||
fn is_in_chain(&self, tip: &BlockChainTip) -> bool;
|
||||
}
|
||||
|
||||
impl BitcoinInterface for sync::Arc<sync::RwLock<d::BitcoinD>> {
|
||||
impl BitcoinInterface for d::BitcoinD {
|
||||
fn sync_progress(&self) -> f64 {
|
||||
self.read().unwrap().sync_progress()
|
||||
self.sync_progress()
|
||||
}
|
||||
|
||||
fn chain_tip(&self) -> BlockChainTip {
|
||||
self.read().unwrap().chain_tip()
|
||||
self.chain_tip()
|
||||
}
|
||||
|
||||
fn is_in_chain(&self, tip: &BlockChainTip) -> bool {
|
||||
self.read()
|
||||
.unwrap()
|
||||
.get_block_hash(tip.height)
|
||||
self.get_block_hash(tip.height)
|
||||
.map(|bh| bh == tip.hash)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way?
|
||||
impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>> {
|
||||
fn sync_progress(&self) -> f64 {
|
||||
self.lock().unwrap().sync_progress()
|
||||
}
|
||||
|
||||
fn chain_tip(&self) -> BlockChainTip {
|
||||
self.lock().unwrap().chain_tip()
|
||||
}
|
||||
|
||||
fn is_in_chain(&self, tip: &BlockChainTip) -> bool {
|
||||
self.lock().unwrap().is_in_chain(tip)
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,8 +39,8 @@ fn update_tip(bit: &impl BitcoinInterface, db_conn: &mut Box<dyn DatabaseConnect
|
||||
/// Main event loop. Repeatedly polls the Bitcoin interface until told to stop through the
|
||||
/// `shutdown` atomic.
|
||||
pub fn looper(
|
||||
bit: impl BitcoinInterface,
|
||||
db: impl DatabaseInterface,
|
||||
bit: sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
|
||||
db: sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
|
||||
shutdown: sync::Arc<atomic::AtomicBool>,
|
||||
poll_interval: time::Duration,
|
||||
) {
|
||||
@ -63,7 +63,7 @@ pub fn looper(
|
||||
let sync_progress = bit.sync_progress();
|
||||
log::info!(
|
||||
"Block chain synchronization progress: {:.2}%",
|
||||
sync_progress
|
||||
sync_progress * 100.0
|
||||
);
|
||||
synced = sync_progress == 1.0;
|
||||
if !synced {
|
||||
|
||||
@ -18,15 +18,18 @@ pub struct Poller {
|
||||
|
||||
impl Poller {
|
||||
pub fn start(
|
||||
bit: impl BitcoinInterface + 'static,
|
||||
db: impl DatabaseInterface + 'static,
|
||||
bit: sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
|
||||
db: sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
|
||||
poll_interval: time::Duration,
|
||||
) -> Poller {
|
||||
let shutdown = sync::Arc::from(atomic::AtomicBool::from(false));
|
||||
let handle = thread::spawn({
|
||||
let shutdown = shutdown.clone();
|
||||
move || looper(bit, db, shutdown, poll_interval)
|
||||
});
|
||||
let handle = thread::Builder::new()
|
||||
.name("Bitcoin poller".to_string())
|
||||
.spawn({
|
||||
let shutdown = shutdown.clone();
|
||||
move || looper(bit, db, shutdown, poll_interval)
|
||||
})
|
||||
.expect("Must not fail");
|
||||
|
||||
Poller { shutdown, handle }
|
||||
}
|
||||
@ -35,4 +38,9 @@ impl Poller {
|
||||
self.shutdown.store(true, atomic::Ordering::Relaxed);
|
||||
self.handle.join().expect("The poller loop must not fail");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn test_stop(&mut self) {
|
||||
self.shutdown.store(true, atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,20 +2,21 @@
|
||||
//!
|
||||
//! External interface to the Minisafe daemon.
|
||||
|
||||
use crate::{DaemonControl, VERSION};
|
||||
use crate::{bitcoin::BitcoinInterface, database::DatabaseInterface, DaemonControl, VERSION};
|
||||
|
||||
use miniscript::{
|
||||
bitcoin,
|
||||
descriptor::{self, DescriptorTrait},
|
||||
TranslatePk2,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
impl DaemonControl {
|
||||
/// Get information about the current state of the daemon
|
||||
pub fn get_info(&self) -> GetInfoResult {
|
||||
GetInfoResult {
|
||||
version: VERSION.to_string(),
|
||||
network: self.config.bitcoind_config.network,
|
||||
network: self.config.bitcoin_config.network,
|
||||
blockheight: self.bitcoin.chain_tip().height,
|
||||
sync: self.bitcoin.sync_progress(),
|
||||
descriptors: GetInfoDescriptors {
|
||||
@ -26,29 +27,31 @@ impl DaemonControl {
|
||||
|
||||
/// Get a new deposit address. This will always generate a new deposit address, regardless of
|
||||
/// whether it was actually used.
|
||||
pub fn get_new_address(&self) -> bitcoin::Address {
|
||||
pub fn get_new_address(&self) -> GetAddressResult {
|
||||
let mut db_conn = self.db.connection();
|
||||
let index = db_conn.derivation_index();
|
||||
// TODO: handle should we wrap around instead of failing?
|
||||
db_conn.update_derivation_index(index.increment().expect("TODO: handle wraparound"));
|
||||
self.config
|
||||
let address = self
|
||||
.config
|
||||
.main_descriptor
|
||||
// TODO: have a descriptor newtype along with a derived descriptor one.
|
||||
.derive(index.into())
|
||||
.translate_pk2(|xpk| xpk.derive_public_key(&self.secp))
|
||||
.expect("All pubkeys were derived, no wildcard.")
|
||||
.address(self.config.bitcoind_config.network)
|
||||
.expect("It's a wsh() descriptor")
|
||||
.address(self.config.bitcoin_config.network)
|
||||
.expect("It's a wsh() descriptor");
|
||||
GetAddressResult { address }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetInfoDescriptors {
|
||||
pub main: descriptor::Descriptor<descriptor::DescriptorPublicKey>,
|
||||
}
|
||||
|
||||
/// Information about the daemon
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetInfoResult {
|
||||
pub version: String,
|
||||
pub network: bitcoin::Network,
|
||||
@ -56,3 +59,44 @@ pub struct GetInfoResult {
|
||||
pub sync: f64,
|
||||
pub descriptors: GetInfoDescriptors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetAddressResult {
|
||||
pub address: bitcoin::Address,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutils::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn getinfo() {
|
||||
let ms = DummyMinisafe::new();
|
||||
// We can query getinfo
|
||||
ms.handle.control.get_info();
|
||||
ms.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn getnewaddress() {
|
||||
let ms = DummyMinisafe::new();
|
||||
|
||||
let control = &ms.handle.control;
|
||||
// We can get an address
|
||||
let addr = control.get_new_address().address;
|
||||
assert_eq!(
|
||||
addr,
|
||||
bitcoin::Address::from_str(
|
||||
"bc1qgudekhcrejgtlx3yhlvdul7t4q76e5lhm0vtcsndxs6aslh4r9jsqkqhwu"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
// We won't get the same twice.
|
||||
let addr2 = control.get_new_address().address;
|
||||
assert_ne!(addr, addr2);
|
||||
|
||||
ms.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,16 +50,21 @@ fn default_daemon() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// TODO: separate Bitcoin config and bitcoind-specific config.
|
||||
/// 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
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BitcoinConfig {
|
||||
/// The network we are operating on, one of "bitcoin", "testnet", "regtest", "signet"
|
||||
pub network: Network,
|
||||
/// The poll interval for the Bitcoin interface
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_duration",
|
||||
serialize_with = "serialize_duration",
|
||||
@ -90,8 +95,19 @@ pub struct Config {
|
||||
serialize_with = "serialize_to_string"
|
||||
)]
|
||||
pub main_descriptor: Descriptor<DescriptorPublicKey>,
|
||||
/// Everything we need to know to talk to bitcoind
|
||||
pub bitcoind_config: BitcoindConfig,
|
||||
/// Settings for the Bitcoin interface
|
||||
pub bitcoin_config: BitcoinConfig,
|
||||
/// Settings specific to bitcoind as the Bitcoin interface
|
||||
pub bitcoind_config: Option<BitcoindConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn data_dir(&self) -> Option<PathBuf> {
|
||||
self.data_dir
|
||||
.as_ref()
|
||||
.map(Clone::clone)
|
||||
.or_else(config_folder_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
@ -185,7 +201,7 @@ impl 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 {
|
||||
let expected_network = match self.bitcoin_config.network {
|
||||
Network::Bitcoin => Network::Bitcoin,
|
||||
_ => Network::Testnet,
|
||||
};
|
||||
@ -204,7 +220,7 @@ impl Config {
|
||||
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
|
||||
self.bitcoin_config.network, expected_network
|
||||
)));
|
||||
}
|
||||
|
||||
@ -228,11 +244,13 @@ mod tests {
|
||||
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]
|
||||
[bitcoin_config]
|
||||
network = "bitcoin"
|
||||
poll_interval_secs = 18
|
||||
|
||||
[bitcoind_config]
|
||||
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");
|
||||
|
||||
@ -243,11 +261,13 @@ mod tests {
|
||||
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]
|
||||
[bitcoin_config]
|
||||
network = 'bitcoin'
|
||||
poll_interval_secs = 18
|
||||
|
||||
[bitcoind_config]
|
||||
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");
|
||||
@ -263,16 +283,18 @@ mod tests {
|
||||
# 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]
|
||||
[bitcoin_config]
|
||||
network = "bitcoin"
|
||||
poll_interval_secs = 18
|
||||
|
||||
[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");
|
||||
|
||||
// Not enough parameters: missing the network
|
||||
// Not enough parameters: missing the Bitcoin network
|
||||
let toml_str = r#"
|
||||
daemon = false
|
||||
log_level = "trace"
|
||||
@ -281,10 +303,12 @@ mod tests {
|
||||
# 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"
|
||||
|
||||
[bitcoin_config]
|
||||
poll_interval_secs = 18
|
||||
|
||||
[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");
|
||||
|
||||
@ -8,6 +8,8 @@ use crate::{
|
||||
database::sqlite::{schema::DbTip, SqliteConn, SqliteDb},
|
||||
};
|
||||
|
||||
use std::sync;
|
||||
|
||||
use miniscript::bitcoin::util::bip32;
|
||||
|
||||
pub trait DatabaseInterface: Send {
|
||||
@ -20,6 +22,13 @@ impl DatabaseInterface for SqliteDb {
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way?
|
||||
impl DatabaseInterface for sync::Arc<sync::Mutex<dyn DatabaseInterface>> {
|
||||
fn connection(&self) -> Box<dyn DatabaseConnection> {
|
||||
self.lock().unwrap().connection()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DatabaseConnection {
|
||||
/// Get the tip of the best chain we've seen.
|
||||
fn chain_tip(&mut self) -> Option<BlockChainTip>;
|
||||
|
||||
@ -219,7 +219,8 @@ impl SqliteConn {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{env, fs, path, process, str::FromStr, thread};
|
||||
use crate::testutils::*;
|
||||
use std::{fs, path, str::FromStr};
|
||||
|
||||
fn dummy_options() -> FreshDbOptions {
|
||||
let desc_str = "wsh(andor(pk(03b506a1dbe57b4bf48c95e0c7d417b87dd3b4349d290d2e7e9ba72c912652d80a),older(10000),pk(0295e7f5d12a2061f1fd2286cefec592dff656a19f55f4f01305d6aa56630880ce)))";
|
||||
@ -232,11 +233,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn db_startup_sanity_checks() {
|
||||
let tmp_dir = env::temp_dir().join(format!(
|
||||
"minisafed-unit-tests-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
let tmp_dir = tmp_dir();
|
||||
fs::create_dir_all(&tmp_dir).unwrap();
|
||||
|
||||
let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")]
|
||||
@ -277,11 +274,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn db_tip_update() {
|
||||
let tmp_dir = env::temp_dir().join(format!(
|
||||
"minisafed-unit-tests-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
let tmp_dir = tmp_dir();
|
||||
fs::create_dir_all(&tmp_dir).unwrap();
|
||||
|
||||
let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("minisafed.sqlite3")]
|
||||
|
||||
18
src/jsonrpc/api.rs
Normal file
18
src/jsonrpc/api.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use crate::{
|
||||
jsonrpc::{Error, Request, Response},
|
||||
DaemonControl,
|
||||
};
|
||||
|
||||
/// Handle an incoming JSONRPC2 request.
|
||||
pub fn handle_request(control: &DaemonControl, req: Request) -> Result<Response, Error> {
|
||||
let result = match req.method.as_str() {
|
||||
"getinfo" => serde_json::json!(&control.get_info()),
|
||||
"getnewaddress" => serde_json::json!(&control.get_new_address()),
|
||||
"stop" => serde_json::json!({}),
|
||||
_ => {
|
||||
return Err(Error::method_not_found());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Response::success(req.id, result))
|
||||
}
|
||||
161
src/jsonrpc/mod.rs
Normal file
161
src/jsonrpc/mod.rs
Normal file
@ -0,0 +1,161 @@
|
||||
mod api;
|
||||
pub mod server;
|
||||
|
||||
use std::{error, fmt};
|
||||
|
||||
use serde::{self, Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(untagged)]
|
||||
pub enum Params {
|
||||
Array(Vec<serde_json::Value>),
|
||||
Map(serde_json::Map<String, serde_json::Value>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(untagged)]
|
||||
pub enum ReqId {
|
||||
Num(u64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
/// A JSONRPC2 request. See https://www.jsonrpc.org/specification#request_object.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
/// Version. Must be "2.0".
|
||||
pub jsonrpc: String,
|
||||
/// Command name.
|
||||
pub method: String,
|
||||
/// Command parameters.
|
||||
pub params: Option<Params>,
|
||||
/// Request identifier.
|
||||
pub id: ReqId,
|
||||
}
|
||||
|
||||
/// JSONRPC2 error codes. See https://www.jsonrpc.org/specification#error_object.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum ErrorCode {
|
||||
/// The method does not exist / is not available.
|
||||
MethodNotFound,
|
||||
/// Invalid method parameter(s).
|
||||
InvalidParams,
|
||||
/// Reserved for implementation-defined server-errors.
|
||||
ServerError(i64),
|
||||
}
|
||||
|
||||
impl Into<i64> for &ErrorCode {
|
||||
fn into(self) -> i64 {
|
||||
match self {
|
||||
ErrorCode::MethodNotFound => -32601,
|
||||
ErrorCode::InvalidParams => -32602,
|
||||
ErrorCode::ServerError(code) => *code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for ErrorCode {
|
||||
fn from(code: i64) -> ErrorCode {
|
||||
match code {
|
||||
-32601 => ErrorCode::MethodNotFound,
|
||||
-32602 => ErrorCode::InvalidParams,
|
||||
code => ErrorCode::ServerError(code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deserialize<'a> for ErrorCode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<ErrorCode, D::Error>
|
||||
where
|
||||
D: Deserializer<'a>,
|
||||
{
|
||||
let code: i64 = Deserialize::deserialize(deserializer)?;
|
||||
Ok(code.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ErrorCode {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_i64(self.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// JSONRPC2 error response. See https://www.jsonrpc.org/specification#error_object.
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Error {
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
message: message.into(),
|
||||
code,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn method_not_found() -> Error {
|
||||
Error::new(ErrorCode::MethodNotFound, "Method not found")
|
||||
}
|
||||
|
||||
pub fn invalid_params<M>(message: impl Into<String>) -> Error {
|
||||
Error::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("Invalid params: {}", message.into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let code: i64 = (&self.code).into();
|
||||
write!(f, "{}: {}", code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for Error {}
|
||||
|
||||
/// JSONRPC2 response. See https://www.jsonrpc.org/specification#response_object.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Response {
|
||||
/// Version. Must be "2.0".
|
||||
jsonrpc: String,
|
||||
/// Required on success. Must not exist on error.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
/// Required on error. Must not exist on success.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<Error>,
|
||||
/// Request identifier.
|
||||
id: ReqId,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
fn new(id: ReqId, result: Option<serde_json::Value>, error: Option<Error>) -> Response {
|
||||
Response {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
result,
|
||||
error,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success(id: ReqId, result: serde_json::Value) -> Response {
|
||||
Response::new(id, Some(result), None)
|
||||
}
|
||||
|
||||
pub fn error(id: ReqId, error: Error) -> Response {
|
||||
Response::new(id, None, Some(error))
|
||||
}
|
||||
}
|
||||
429
src/jsonrpc/server.rs
Normal file
429
src/jsonrpc/server.rs
Normal file
@ -0,0 +1,429 @@
|
||||
//! JSONRPC2 server
|
||||
//!
|
||||
//! This module implements the connections and streams handling logic for receiving
|
||||
//! JSONRPC2 requests on a Unix Domain Socket.
|
||||
|
||||
use crate::{
|
||||
jsonrpc::{api, Request, Response},
|
||||
DaemonControl,
|
||||
};
|
||||
|
||||
use std::{
|
||||
io,
|
||||
os::unix::net,
|
||||
path,
|
||||
sync::{self, atomic},
|
||||
thread, time,
|
||||
};
|
||||
|
||||
// Maximum number of concurrent RPC connections we may accept.
|
||||
const MAX_CONNECTIONS: u32 = 16;
|
||||
|
||||
// Read a command from the stream.
|
||||
//
|
||||
// In order to both treat commands separately (respond as soon as we read one), and support
|
||||
// multiple commands in a single read or in multiple parts, we are given the context as writable
|
||||
// arguments:
|
||||
// - `buf` is the buffer used to read from the socket. It will be extended as needed. It must be
|
||||
// initialized.
|
||||
// - `end`: The index of the end of the data read from the stream. Since `buf` needs to be
|
||||
// initialized with dummy values, it can be very different from `buf.len()`. Used to not check
|
||||
// for the separator character in the parts of the buffer with dummy values.
|
||||
// - `cursor`: The index at which we checked for the separator character (`\n`). Used to not
|
||||
// check twice for it on the same buffer chunk.
|
||||
fn read_command(
|
||||
stream: &mut dyn io::Read,
|
||||
buf: &mut Vec<u8>,
|
||||
end: &mut usize,
|
||||
cursor: &mut usize,
|
||||
) -> Result<Option<Request>, io::Error> {
|
||||
assert!(!buf.is_empty());
|
||||
|
||||
loop {
|
||||
// First off, check if there are no existing commands in the buffer.
|
||||
let pos = buf[*cursor..*end].iter().position(|byt| byt == &b'\n');
|
||||
log::trace!(
|
||||
"pos: {:?}, buf[cur..end]: {:?}",
|
||||
pos,
|
||||
String::from_utf8_lossy(&buf[*cursor..*end])
|
||||
);
|
||||
if let Some(pos) = pos {
|
||||
log::trace!(
|
||||
"Parsing Request from: {:?}",
|
||||
String::from_utf8_lossy(&buf[..*cursor + pos])
|
||||
);
|
||||
// TODO: don't return an io::Error here, instead try to parse a Request. Failing that,
|
||||
// try to parse a serde_json::Value. Then return accordingly a JSONRPC "malformed
|
||||
// request" or "invalid JSON" error.
|
||||
let req: Request = serde_json::from_slice(&buf[..*cursor + pos])?;
|
||||
*buf = buf[pos + 1..].to_vec(); // FIXME: can we avoid reallocating here?
|
||||
*cursor = 0;
|
||||
*end -= pos + 1;
|
||||
|
||||
return Ok(Some(req));
|
||||
}
|
||||
|
||||
// If nothing can be gathered from the buffer, continue reading.
|
||||
let new_read = stream.read(&mut buf[*end..])?;
|
||||
if new_read == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// If we filled the buffer, increase its size and try again.
|
||||
*end += new_read;
|
||||
let buffer_filled = *end == buf.len();
|
||||
if buffer_filled {
|
||||
buf.resize(buf.len() * 2, 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all messages from this connection.
|
||||
fn connection_handler(
|
||||
control: DaemonControl,
|
||||
mut stream: net::UnixStream,
|
||||
shutdown: sync::Arc<atomic::AtomicBool>,
|
||||
) -> Result<(), io::Error> {
|
||||
let mut buf = vec![0; 2048];
|
||||
let mut end = 0;
|
||||
let mut cursor = 0;
|
||||
|
||||
while !shutdown.load(atomic::Ordering::Relaxed) {
|
||||
let req = match read_command(&mut stream, &mut buf, &mut end, &mut cursor)? {
|
||||
Some(req) => req,
|
||||
None => {
|
||||
// Connection closed.
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let req_id = req.id.clone();
|
||||
if &req.method == "stop" {
|
||||
shutdown.store(true, atomic::Ordering::Relaxed);
|
||||
log::info!("Stopping the minisafe daemon.");
|
||||
}
|
||||
|
||||
log::trace!("JSONRPC request: {:?}", serde_json::to_string(&req));
|
||||
let response =
|
||||
api::handle_request(&control, req).unwrap_or_else(|e| Response::error(req_id, e));
|
||||
log::trace!("JSONRPC response: {:?}", serde_json::to_string(&response));
|
||||
if let Err(e) = serde_json::to_writer(&stream, &response) {
|
||||
log::error!("Error writing response: '{}'", e);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// FIXME: have a decent way to share the DaemonControl between connections. Maybe make it Clone?
|
||||
/// The main event loop. Wait for connections, and treat requests sent through them.
|
||||
pub fn rpcserver_loop(
|
||||
listener: net::UnixListener,
|
||||
daemon_control: DaemonControl,
|
||||
) -> Result<(), io::Error> {
|
||||
// Keep it simple. We don't need great performances so just treat each connection in
|
||||
// its thread, with a given maximum number of connections.
|
||||
let connections_counter = sync::Arc::from(atomic::AtomicU32::new(0));
|
||||
let shutdown = sync::Arc::from(atomic::AtomicBool::new(false));
|
||||
|
||||
listener.set_nonblocking(true)?;
|
||||
while !shutdown.load(atomic::Ordering::Relaxed) {
|
||||
let (connection, _) = match listener.accept() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
thread::sleep(time::Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
log::trace!("New JSONRPC connection");
|
||||
|
||||
while connections_counter.load(atomic::Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
thread::sleep(time::Duration::from_millis(50));
|
||||
}
|
||||
connections_counter.fetch_add(1, atomic::Ordering::Relaxed);
|
||||
|
||||
let handler_id = connections_counter.load(atomic::Ordering::Relaxed);
|
||||
thread::Builder::new()
|
||||
.name(format!("minisafe-jsonrpc-{}", handler_id))
|
||||
.spawn({
|
||||
let control = daemon_control.clone();
|
||||
let counter = connections_counter.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
|
||||
move || {
|
||||
if let Err(e) = connection_handler(control, connection, shutdown) {
|
||||
log::error!("Error while handling connection {}: '{}'", handler_id, e);
|
||||
} else {
|
||||
log::trace!("Connection {} terminated without error.", handler_id);
|
||||
}
|
||||
counter.fetch_sub(1, atomic::Ordering::Relaxed);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Tries to bind to the socket, if we are told it's already in use try to connect
|
||||
// to check there is actually someone listening and it's not a leftover from a
|
||||
// crash.
|
||||
fn bind(socket_path: &path::Path) -> Result<net::UnixListener, io::Error> {
|
||||
match net::UnixListener::bind(socket_path) {
|
||||
Ok(l) => Ok(l),
|
||||
Err(e) => {
|
||||
if e.kind() == io::ErrorKind::AddrInUse {
|
||||
return match net::UnixStream::connect(socket_path) {
|
||||
Ok(_) => Err(e),
|
||||
Err(_) => {
|
||||
// Ok, no one's here. Just delete the socket and bind.
|
||||
log::debug!("Removing leftover rpc socket.");
|
||||
std::fs::remove_file(socket_path)?;
|
||||
net::UnixListener::bind(socket_path)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind to the UDS at `socket_path`
|
||||
pub fn rpcserver_setup(socket_path: &path::Path) -> Result<net::UnixListener, io::Error> {
|
||||
log::debug!("Binding socket at {}", socket_path.display());
|
||||
// Create the socket with RW permissions only for the user
|
||||
#[cfg(not(test))]
|
||||
let old_umask = unsafe { libc::umask(0o177) };
|
||||
let listener = bind(&socket_path);
|
||||
#[cfg(not(test))]
|
||||
unsafe {
|
||||
libc::umask(old_umask);
|
||||
}
|
||||
|
||||
listener
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
jsonrpc::{Params, ReqId},
|
||||
testutils::*,
|
||||
};
|
||||
|
||||
use std::{env, fs, io::Write, process};
|
||||
|
||||
fn read_one_command(socket_path: &path::Path) -> thread::JoinHandle<Option<Request>> {
|
||||
let listener = rpcserver_setup(socket_path).unwrap();
|
||||
thread::spawn(move || {
|
||||
let (mut conn, _) = listener.accept().unwrap();
|
||||
let mut buf = vec![0; 32];
|
||||
let mut end = 0;
|
||||
let mut cursor = 0;
|
||||
read_command(&mut conn, &mut buf, &mut end, &mut cursor).unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn read_all_commands(socket_path: &path::Path) -> thread::JoinHandle<Vec<Request>> {
|
||||
let listener = rpcserver_setup(socket_path).unwrap();
|
||||
thread::spawn(move || {
|
||||
let (mut conn, _) = listener.accept().unwrap();
|
||||
let mut buf = vec![0; 32];
|
||||
let mut end = 0;
|
||||
let mut cursor = 0;
|
||||
let mut reqs = Vec::new();
|
||||
|
||||
loop {
|
||||
match read_command(&mut conn, &mut buf, &mut end, &mut cursor).unwrap() {
|
||||
Some(req) => {
|
||||
reqs.push(req);
|
||||
}
|
||||
None => return reqs,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn write_messages(socket_path: &path::Path, messages: &[&[u8]]) {
|
||||
let mut client = net::UnixStream::connect(&socket_path).unwrap();
|
||||
for mess in messages {
|
||||
client.write_all(&mess).unwrap();
|
||||
// Simulate throttling, this mimics real conditions and actually triggered a crash.
|
||||
thread::sleep(time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_read_single() {
|
||||
let socket_path = env::temp_dir().join(format!(
|
||||
"minisafed-jsonrpc-socket-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
|
||||
// A simple dummy request
|
||||
let t = read_all_commands(&socket_path);
|
||||
let req = br#"{"jsonrpc": "2.0", "id": 0, "method": "test", "params": {"a": "b"}}"#;
|
||||
let parsed_req: Request = serde_json::from_slice(req).unwrap();
|
||||
write_messages(&socket_path, &[req, b"\n"]);
|
||||
let read_req = t.join().unwrap();
|
||||
assert_eq!(parsed_req, read_req[0]);
|
||||
|
||||
// Same, but with params as a list and a string id
|
||||
let t = read_one_command(&socket_path);
|
||||
let req = br#"{"jsonrpc": "2.0", "id": "987-abc", "method": "test", "params": ["a", 10]}"#;
|
||||
let parsed_req: Request = serde_json::from_slice(req).unwrap();
|
||||
write_messages(&socket_path, &[req, b"\n"]);
|
||||
let read_req = t.join().unwrap().unwrap();
|
||||
assert_eq!(parsed_req, read_req);
|
||||
|
||||
fs::remove_file(&socket_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_read_parts() {
|
||||
let socket_path = env::temp_dir().join(format!(
|
||||
"minisafed-jsonrpc-socket-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
|
||||
// A single request written in two parts
|
||||
let t = read_one_command(&socket_path);
|
||||
let req = br#"{"jsonrpc": "2.0", "id": 0, "method": "test", "params": ["a", 10]}"#;
|
||||
let parsed_req: Request = serde_json::from_slice(req).unwrap();
|
||||
write_messages(
|
||||
&socket_path,
|
||||
&[&req[..req.len() / 2], &req[req.len() / 2..], b"\n"],
|
||||
);
|
||||
let read_req = t.join().unwrap().unwrap();
|
||||
assert_eq!(parsed_req, read_req);
|
||||
|
||||
// A single request written in many parts
|
||||
let t = read_one_command(&socket_path);
|
||||
let req = br#"{"jsonrpc": "2.0", "id": 0, "method": "test", "params": ["a", 10]}"#;
|
||||
let parsed_req: Request = serde_json::from_slice(req).unwrap();
|
||||
let tmp: Vec<Vec<u8>> = req.into_iter().map(|c| vec![*c]).collect();
|
||||
let mut to_send: Vec<&[u8]> = tmp.iter().map(|v| v.as_slice()).collect();
|
||||
to_send.push(b"\n");
|
||||
write_messages(&socket_path, &to_send);
|
||||
let read_req = t.join().unwrap().unwrap();
|
||||
assert_eq!(parsed_req, read_req);
|
||||
|
||||
fs::remove_file(&socket_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_read_multiple() {
|
||||
let socket_path = env::temp_dir().join(format!(
|
||||
"minisafed-jsonrpc-socket-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
|
||||
// Multiple requests, in parts
|
||||
let t = read_all_commands(&socket_path);
|
||||
let reqs = [
|
||||
&br#"{"jsonrpc": "2.0", "id": 20478, "me"#[..],
|
||||
br#"thod": "test", "params": ["a", 10]}"#,
|
||||
b"\n",
|
||||
br#"{"jsonrpc": "2.0", "id": 20479, "method": "testADZ", "params": {}}"#,
|
||||
b"\n",
|
||||
br#"{"jsonrpc": "2.0", "id": 20499, "method": "t"#,
|
||||
br#"e_edzA", "params": {"ttt": 980}}"#,
|
||||
b"\n",
|
||||
];
|
||||
let parsed_reqs: Vec<Request> = vec![
|
||||
serde_json::from_slice(&[reqs[0], reqs[1]].concat()).unwrap(),
|
||||
serde_json::from_slice(reqs[3]).unwrap(),
|
||||
serde_json::from_slice(&[reqs[5], reqs[6]].concat()).unwrap(),
|
||||
];
|
||||
write_messages(&socket_path, &reqs);
|
||||
let read_reqs = t.join().unwrap();
|
||||
assert_eq!(parsed_reqs, read_reqs);
|
||||
|
||||
// The same requests, sent at once.
|
||||
let t = read_all_commands(&socket_path);
|
||||
let req_parts = [
|
||||
&br#"{"jsonrpc": "2.0", "id": 20478, "method": "test", "params": ["a", 10]}"#[..],
|
||||
b"\n",
|
||||
br#"{"jsonrpc": "2.0", "id": 20479, "method": "testADZ", "params": {}}"#,
|
||||
b"\n",
|
||||
br#"{"jsonrpc": "2.0", "id": 20499, "method": "te_edzA", "params": {"ttt": 980}}"#,
|
||||
b"\n",
|
||||
]
|
||||
.concat();
|
||||
write_messages(&socket_path, &[req_parts.as_slice()]);
|
||||
let read_reqs = t.join().unwrap();
|
||||
assert_eq!(parsed_reqs, read_reqs);
|
||||
|
||||
fs::remove_file(&socket_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_read_linebreak() {
|
||||
let socket_path = env::temp_dir().join(format!(
|
||||
"minisafed-jsonrpc-socket-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
|
||||
// Multiple requests, in parts
|
||||
let t = read_one_command(&socket_path);
|
||||
let mut params = serde_json::map::Map::new();
|
||||
params.insert(
|
||||
"dummy param".to_string(),
|
||||
"dummy value
|
||||
with line
|
||||
breaks"
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
let req = Request {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: "dummy".to_string(),
|
||||
params: Some(Params::Map(params)),
|
||||
id: ReqId::Num(0),
|
||||
};
|
||||
write_messages(&socket_path, &[&serde_json::to_vec(&req).unwrap(), b"\n"]);
|
||||
let read_req = t.join().unwrap().unwrap();
|
||||
assert_eq!(req, read_req);
|
||||
|
||||
fs::remove_file(&socket_path).unwrap();
|
||||
}
|
||||
|
||||
// TODO: debug on MacOS
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn server_sanity_check() {
|
||||
let ms = DummyMinisafe::new();
|
||||
let socket_path: path::PathBuf = [
|
||||
ms.tmp_dir.as_path(),
|
||||
path::Path::new("d"),
|
||||
path::Path::new("bitcoin"),
|
||||
path::Path::new("minisafed_rpc"),
|
||||
]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let t = thread::spawn(move || ms.rpc_server().unwrap());
|
||||
while !socket_path.exists() {
|
||||
thread::sleep(time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let stop_req = Request {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: "stop".to_string(),
|
||||
params: None,
|
||||
id: ReqId::Num(0),
|
||||
};
|
||||
write_messages(
|
||||
&socket_path,
|
||||
&[&serde_json::to_vec(&stop_req).unwrap(), b"\n"],
|
||||
);
|
||||
|
||||
t.join().unwrap();
|
||||
}
|
||||
}
|
||||
244
src/lib.rs
244
src/lib.rs
@ -5,15 +5,21 @@ pub mod config;
|
||||
mod daemonize;
|
||||
mod database;
|
||||
pub mod descriptors;
|
||||
#[cfg(feature = "jsonrpc_server")]
|
||||
mod jsonrpc;
|
||||
#[cfg(test)]
|
||||
mod testutils;
|
||||
|
||||
pub use miniscript;
|
||||
|
||||
#[cfg(feature = "jsonrpc_server")]
|
||||
use crate::jsonrpc::server::{rpcserver_loop, rpcserver_setup};
|
||||
use crate::{
|
||||
bitcoin::{
|
||||
d::{BitcoinD, BitcoindError},
|
||||
poller, BitcoinInterface,
|
||||
},
|
||||
config::{config_folder_path, Config},
|
||||
config::Config,
|
||||
database::{
|
||||
sqlite::{FreshDbOptions, SqliteDb, SqliteDbError},
|
||||
DatabaseInterface,
|
||||
@ -76,6 +82,7 @@ pub enum StartupError {
|
||||
Io(io::Error),
|
||||
DefaultDataDirNotFound,
|
||||
DatadirCreation(path::PathBuf, io::Error),
|
||||
MissingBitcoindConfig,
|
||||
Database(SqliteDbError),
|
||||
Bitcoind(BitcoindError),
|
||||
#[cfg(unix)]
|
||||
@ -94,6 +101,10 @@ impl fmt::Display for StartupError {
|
||||
f,
|
||||
"Could not create data directory at '{}': '{}'", dir_path.display(), e
|
||||
),
|
||||
Self::MissingBitcoindConfig => write!(
|
||||
f,
|
||||
"Our Bitcoin interface is bitcoind but we have no 'bitcoind_config' entry in the configuration."
|
||||
),
|
||||
Self::Database(e) => write!(f, "Error initializing database: '{}'.", e),
|
||||
Self::Bitcoind(e) => write!(f, "Error setting up bitcoind interface: '{}'.", e),
|
||||
#[cfg(unix)]
|
||||
@ -144,18 +155,74 @@ fn create_datadir(datadir_path: &path::Path) -> Result<(), StartupError> {
|
||||
};
|
||||
}
|
||||
|
||||
// Connect to the SQLite database. Create it if starting fresh, and do some sanity checks.
|
||||
// If all went well, returns the interface to the SQLite database.
|
||||
fn setup_sqlite(
|
||||
config: &Config,
|
||||
data_dir: &path::Path,
|
||||
fresh_data_dir: bool,
|
||||
) -> Result<SqliteDb, StartupError> {
|
||||
let db_path: path::PathBuf = [data_dir, path::Path::new("minisafed.sqlite3")]
|
||||
.iter()
|
||||
.collect();
|
||||
let options = if fresh_data_dir {
|
||||
Some(FreshDbOptions {
|
||||
bitcoind_network: config.bitcoin_config.network,
|
||||
main_descriptor: config.main_descriptor.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sqlite = SqliteDb::new(db_path, options)?;
|
||||
sqlite.sanity_check(config.bitcoin_config.network, &config.main_descriptor)?;
|
||||
log::info!("Database initialized and checked.");
|
||||
|
||||
Ok(sqlite)
|
||||
}
|
||||
|
||||
// Connect to bitcoind. Setup the watchonly wallet, and do some sanity checks.
|
||||
// If all went well, returns the interface to bitcoind.
|
||||
fn setup_bitcoind(
|
||||
config: &Config,
|
||||
data_dir: &path::Path,
|
||||
fresh_data_dir: bool,
|
||||
) -> Result<BitcoinD, StartupError> {
|
||||
// Now set up the bitcoind interface
|
||||
let wo_path: path::PathBuf = [data_dir, path::Path::new("minisafed_watchonly_wallet")]
|
||||
.iter()
|
||||
.collect();
|
||||
let bitcoind = BitcoinD::new(
|
||||
config
|
||||
.bitcoind_config
|
||||
.as_ref()
|
||||
.ok_or(StartupError::MissingBitcoindConfig)?,
|
||||
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.try_load_watchonly_wallet();
|
||||
bitcoind.sanity_check(&config.main_descriptor, config.bitcoin_config.network)?;
|
||||
log::info!("Connection to bitcoind established and checked.");
|
||||
|
||||
Ok(bitcoind.with_retry_limit(None))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DaemonControl {
|
||||
config: Config,
|
||||
bitcoin: Box<dyn BitcoinInterface>,
|
||||
db: Box<dyn DatabaseInterface>,
|
||||
bitcoin: sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
|
||||
// FIXME: Should we require Sync on DatabaseInterface rather than using a Mutex?
|
||||
db: sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
|
||||
secp: secp256k1::Secp256k1<secp256k1::VerifyOnly>,
|
||||
}
|
||||
|
||||
impl DaemonControl {
|
||||
pub fn new(
|
||||
config: Config,
|
||||
bitcoin: Box<dyn BitcoinInterface>,
|
||||
db: Box<dyn DatabaseInterface>,
|
||||
bitcoin: sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
|
||||
db: sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
|
||||
) -> DaemonControl {
|
||||
let secp = secp256k1::Secp256k1::verification_only();
|
||||
DaemonControl {
|
||||
@ -175,18 +242,26 @@ pub struct DaemonHandle {
|
||||
impl DaemonHandle {
|
||||
/// This starts the Minisafe daemon. Call `shutdown` to shut it down.
|
||||
///
|
||||
/// You may specify a custom Bitcoin interface through the `bitcoin` parameter. If `None`, the
|
||||
/// default Bitcoin interface (`bitcoind` JSONRPC) will be used.
|
||||
/// You may specify a custom Database interface through the `db` parameter. If `None`, the
|
||||
/// default Database interface (SQLite) will be used.
|
||||
///
|
||||
/// **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> {
|
||||
pub fn start(
|
||||
config: Config,
|
||||
bitcoin: Option<impl BitcoinInterface + 'static>,
|
||||
db: Option<impl DatabaseInterface + 'static>,
|
||||
) -> Result<Self, StartupError> {
|
||||
#[cfg(not(test))]
|
||||
setup_panic_hook();
|
||||
|
||||
// First, check the data directory
|
||||
let mut data_dir = config
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or(config_folder_path().ok_or(StartupError::DefaultDataDirNotFound)?);
|
||||
data_dir.push(config.bitcoind_config.network.to_string());
|
||||
.data_dir()
|
||||
.ok_or(StartupError::DefaultDataDirNotFound)?;
|
||||
data_dir.push(config.bitcoin_config.network.to_string());
|
||||
let fresh_data_dir = !data_dir.as_path().exists();
|
||||
if fresh_data_dir {
|
||||
create_datadir(&data_dir)?;
|
||||
@ -194,39 +269,24 @@ impl DaemonHandle {
|
||||
}
|
||||
|
||||
// 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 = match db {
|
||||
Some(db) => sync::Arc::from(sync::Mutex::from(db)),
|
||||
None => sync::Arc::from(sync::Mutex::from(setup_sqlite(
|
||||
&config,
|
||||
&data_dir,
|
||||
fresh_data_dir,
|
||||
)?)) as sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
|
||||
};
|
||||
let sqlite = SqliteDb::new(db_path, options)?;
|
||||
sqlite.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.try_load_watchonly_wallet();
|
||||
bitcoind.sanity_check(&config.main_descriptor, config.bitcoind_config.network)?;
|
||||
log::info!("Connection to bitcoind established and checked.");
|
||||
// Now, set up the Bitcoin interface.
|
||||
let bit = match bitcoin {
|
||||
Some(bit) => sync::Arc::from(sync::Mutex::from(bit)),
|
||||
None => sync::Arc::from(sync::Mutex::from(setup_bitcoind(
|
||||
&config,
|
||||
&data_dir,
|
||||
fresh_data_dir,
|
||||
)?)) as sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
|
||||
};
|
||||
|
||||
// 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
|
||||
@ -243,15 +303,14 @@ impl DaemonHandle {
|
||||
}
|
||||
|
||||
// Spawn the bitcoind poller with a retry limit high enough that we'd fail after that.
|
||||
let bitcoind = sync::Arc::from(sync::RwLock::from(bitcoind.with_retry_limit(None)));
|
||||
let bitcoin_poller = poller::Poller::start(
|
||||
bitcoind.clone(),
|
||||
sqlite.clone(),
|
||||
config.bitcoind_config.poll_interval_secs,
|
||||
bit.clone(),
|
||||
db.clone(),
|
||||
config.bitcoin_config.poll_interval_secs,
|
||||
);
|
||||
|
||||
// Finally, set up the API.
|
||||
let control = DaemonControl::new(config, Box::from(bitcoind), Box::from(sqlite));
|
||||
let control = DaemonControl::new(config, bit, db);
|
||||
|
||||
Ok(Self {
|
||||
control,
|
||||
@ -259,23 +318,69 @@ impl DaemonHandle {
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the Minisafe daemon with the default Bitcoin and database interfaces (`bitcoind` RPC
|
||||
/// and SQLite).
|
||||
pub fn start_default(config: Config) -> Result<DaemonHandle, StartupError> {
|
||||
DaemonHandle::start(config, Option::<BitcoinD>::None, Option::<SqliteDb>::None)
|
||||
}
|
||||
|
||||
/// Start the JSONRPC server and listen for incoming commands until we die.
|
||||
/// Like DaemonHandle::shutdown(), this stops the Bitcoin poller at teardown.
|
||||
#[cfg(feature = "jsonrpc_server")]
|
||||
pub fn rpc_server(self) -> Result<(), io::Error> {
|
||||
let DaemonHandle {
|
||||
control,
|
||||
bitcoin_poller: poller,
|
||||
} = self;
|
||||
|
||||
let rpc_socket: path::PathBuf = [
|
||||
control
|
||||
.config
|
||||
.data_dir()
|
||||
.expect("Didn't fail at startup, must not now")
|
||||
.as_path(),
|
||||
path::Path::new(&control.config.bitcoin_config.network.to_string()),
|
||||
path::Path::new("minisafed_rpc"),
|
||||
]
|
||||
.iter()
|
||||
.collect();
|
||||
let listener = rpcserver_setup(&rpc_socket)?;
|
||||
log::info!("JSONRPC server started.");
|
||||
|
||||
rpcserver_loop(listener, control)?;
|
||||
log::info!("JSONRPC server stopped.");
|
||||
|
||||
poller.stop();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NOTE: this moves out the data as it should not be reused after shutdown
|
||||
/// Shut down the Minisafe daemon.
|
||||
pub fn shutdown(self) {
|
||||
self.bitcoin_poller.stop();
|
||||
}
|
||||
|
||||
// We need a shutdown utility that does not move for implementing Drop for the DummyMinisafe
|
||||
#[cfg(test)]
|
||||
pub fn test_shutdown(&mut self) {
|
||||
self.bitcoin_poller.test_stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::BitcoindConfig;
|
||||
use crate::{
|
||||
config::{BitcoinConfig, BitcoindConfig},
|
||||
testutils::*,
|
||||
};
|
||||
|
||||
use miniscript::{bitcoin, Descriptor, DescriptorPublicKey};
|
||||
use std::{
|
||||
env, fs,
|
||||
fs,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net, path, process,
|
||||
net, path,
|
||||
str::FromStr,
|
||||
thread, time,
|
||||
};
|
||||
@ -420,13 +525,13 @@ mod tests {
|
||||
stream.flush().unwrap();
|
||||
}
|
||||
|
||||
// TODO: we could move the dummy bitcoind thread stuff to the bitcoind module to test the
|
||||
// bitcoind interface, and use the DummyMinisafe from testutils to sanity check the startup.
|
||||
// Note that startup as checked by this unit test is also tested in the functional test
|
||||
// framework.
|
||||
#[test]
|
||||
fn daemon_startup() {
|
||||
let tmp_dir = env::temp_dir().join(format!(
|
||||
"minisafed-unit-tests-{}-{:?}",
|
||||
process::id(),
|
||||
thread::current().id()
|
||||
));
|
||||
let tmp_dir = tmp_dir();
|
||||
fs::create_dir_all(&tmp_dir).unwrap();
|
||||
let data_dir: path::PathBuf = [tmp_dir.as_path(), path::Path::new("datadir")]
|
||||
.iter()
|
||||
@ -456,18 +561,21 @@ mod tests {
|
||||
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 {
|
||||
let bitcoin_config = BitcoinConfig {
|
||||
network,
|
||||
poll_interval_secs: time::Duration::from_secs(2),
|
||||
};
|
||||
let bitcoind_config = BitcoindConfig {
|
||||
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(xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/*),older(10000),pk(xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/*)))#tk6wzexy";
|
||||
let desc = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
|
||||
let config = Config {
|
||||
bitcoind_config,
|
||||
bitcoin_config,
|
||||
bitcoind_config: Some(bitcoind_config),
|
||||
data_dir: Some(data_dir.clone()),
|
||||
#[cfg(unix)]
|
||||
daemon: false,
|
||||
@ -479,22 +587,8 @@ mod tests {
|
||||
let daemon_thread = thread::spawn({
|
||||
let config = config.clone();
|
||||
move || {
|
||||
let handle = DaemonHandle::start(config).unwrap();
|
||||
// TODO: avoid scope creep. We should move the bitcoind-specific checks to the
|
||||
// bitcoind module, test the startup with a mocked bitcoind interface, and not test
|
||||
// commands here but in the commands module.
|
||||
let addr = handle.control.get_new_address();
|
||||
let addr2 = handle.control.get_new_address();
|
||||
assert_eq!(
|
||||
addr,
|
||||
bitcoin::Address::from_str(
|
||||
"bc1qdu9dama0pwc6fd9lj4sqzq4f728y5q2ucqyj55mfzfvuxr268zks7yajm3"
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_ne!(addr, addr2);
|
||||
let handle = DaemonHandle::start_default(config).unwrap();
|
||||
handle.shutdown();
|
||||
addr
|
||||
}
|
||||
});
|
||||
complete_sanity_check(&server);
|
||||
@ -505,13 +599,11 @@ mod tests {
|
||||
complete_wallet_check(&server, &wo_path);
|
||||
complete_desc_check(&server, desc_str);
|
||||
complete_sync_check(&server);
|
||||
let addr = daemon_thread.join().unwrap();
|
||||
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();
|
||||
// TODO: avoid scope creep. See above comment.
|
||||
assert_ne!(handle.control.get_new_address(), addr);
|
||||
let handle = DaemonHandle::start_default(config).unwrap();
|
||||
handle.shutdown();
|
||||
});
|
||||
complete_sanity_check(&server);
|
||||
|
||||
144
src/testutils.rs
Normal file
144
src/testutils.rs
Normal file
@ -0,0 +1,144 @@
|
||||
use crate::{
|
||||
bitcoin::{BitcoinInterface, BlockChainTip},
|
||||
config::{BitcoinConfig, Config},
|
||||
database::{DatabaseConnection, DatabaseInterface},
|
||||
DaemonHandle,
|
||||
};
|
||||
|
||||
use std::{env, fs, io, path, process, str::FromStr, sync, thread, time};
|
||||
|
||||
use miniscript::{
|
||||
bitcoin::{self, util::bip32},
|
||||
descriptor,
|
||||
};
|
||||
|
||||
pub struct DummyBitcoind {}
|
||||
|
||||
impl BitcoinInterface for DummyBitcoind {
|
||||
fn sync_progress(&self) -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn chain_tip(&self) -> BlockChainTip {
|
||||
let hash = bitcoin::BlockHash::from_str(
|
||||
"000000007bc154e0fa7ea32218a72fe2c1bb9f86cf8c9ebf9a715ed27fdb229a",
|
||||
)
|
||||
.unwrap();
|
||||
let height = 100;
|
||||
BlockChainTip { hash, height }
|
||||
}
|
||||
|
||||
fn is_in_chain(&self, _: &BlockChainTip) -> bool {
|
||||
// No reorg
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyDb {
|
||||
curr_index: bip32::ChildNumber,
|
||||
curr_tip: Option<BlockChainTip>,
|
||||
}
|
||||
|
||||
impl DummyDb {
|
||||
pub fn new() -> DummyDb {
|
||||
DummyDb {
|
||||
curr_index: 0.into(),
|
||||
curr_tip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseInterface for sync::Arc<sync::RwLock<DummyDb>> {
|
||||
fn connection(&self) -> Box<dyn DatabaseConnection> {
|
||||
Box::new(DummyDbConn { db: self.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyDbConn {
|
||||
db: sync::Arc<sync::RwLock<DummyDb>>,
|
||||
}
|
||||
|
||||
impl DatabaseConnection for DummyDbConn {
|
||||
fn chain_tip(&mut self) -> Option<BlockChainTip> {
|
||||
self.db.read().unwrap().curr_tip
|
||||
}
|
||||
|
||||
fn update_tip(&mut self, tip: &BlockChainTip) {
|
||||
self.db.write().unwrap().curr_tip = Some(*tip);
|
||||
}
|
||||
|
||||
fn derivation_index(&mut self) -> bip32::ChildNumber {
|
||||
self.db.read().unwrap().curr_index
|
||||
}
|
||||
|
||||
fn update_derivation_index(&mut self, index: bip32::ChildNumber) {
|
||||
self.db.write().unwrap().curr_index = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DummyMinisafe {
|
||||
pub tmp_dir: path::PathBuf,
|
||||
pub handle: DaemonHandle,
|
||||
}
|
||||
|
||||
static mut COUNTER: sync::atomic::AtomicUsize = sync::atomic::AtomicUsize::new(0);
|
||||
fn uid() -> usize {
|
||||
unsafe {
|
||||
let uid = COUNTER.load(sync::atomic::Ordering::Relaxed);
|
||||
COUNTER.fetch_add(1, sync::atomic::Ordering::Relaxed);
|
||||
uid
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tmp_dir() -> path::PathBuf {
|
||||
env::temp_dir().join(format!(
|
||||
"minisafed-{}-{:?}-{}",
|
||||
process::id(),
|
||||
thread::current().id(),
|
||||
uid(),
|
||||
))
|
||||
}
|
||||
|
||||
impl DummyMinisafe {
|
||||
pub fn new() -> DummyMinisafe {
|
||||
let tmp_dir = tmp_dir();
|
||||
fs::create_dir_all(&tmp_dir).unwrap();
|
||||
// Use a shorthand for 'datadir', to avoid overflowing SUN_LEN on MacOS.
|
||||
let data_dir: path::PathBuf = [tmp_dir.as_path(), path::Path::new("d")].iter().collect();
|
||||
|
||||
let network = bitcoin::Network::Bitcoin;
|
||||
let bitcoin_config = BitcoinConfig {
|
||||
network,
|
||||
poll_interval_secs: time::Duration::from_secs(2),
|
||||
};
|
||||
|
||||
let owner_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/*").unwrap();
|
||||
let heir_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/*").unwrap();
|
||||
let desc = crate::descriptors::inheritance_descriptor(owner_key, heir_key, 10_000).unwrap();
|
||||
let config = Config {
|
||||
bitcoin_config,
|
||||
bitcoind_config: None,
|
||||
data_dir: Some(data_dir.clone()),
|
||||
#[cfg(unix)]
|
||||
daemon: false,
|
||||
log_level: log::LevelFilter::Debug,
|
||||
main_descriptor: desc,
|
||||
};
|
||||
|
||||
let db = sync::Arc::from(sync::RwLock::from(DummyDb::new()));
|
||||
let handle = DaemonHandle::start(config, Some(DummyBitcoind {}), Some(db)).unwrap();
|
||||
DummyMinisafe { tmp_dir, handle }
|
||||
}
|
||||
|
||||
#[cfg(feature = "jsonrpc_server")]
|
||||
pub fn rpc_server(self) -> Result<(), io::Error> {
|
||||
self.handle.rpc_server()?;
|
||||
fs::remove_dir_all(&self.tmp_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self.handle.shutdown();
|
||||
fs::remove_dir_all(&self.tmp_dir).unwrap();
|
||||
}
|
||||
}
|
||||
@ -117,7 +117,7 @@ def minisafed(bitcoind, directory):
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
|
||||
|
||||
main_desc = "wsh(or_d(pk(02869ef67283b4bc9af9d8366efb31f718018bfd5970a69b3d16f22f51228f73dc),and_v(v:pkh(03bb4dc7ed08cc633893f457553ad941ff82195342467d350dbb63773dd17f113b),older(157680))))"
|
||||
main_desc = "wsh(or_d(pk(tpubD9vQiBdDxYzU1V5D5UUmMTXF9FZC13PuQDs4aiv6rF7UCKQFvtVKZguYakX12C2bt8736ksioxu9Y9Nmp18gj4jDeNJEEqrBPEZXAxe5YcQ/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/*),older(157680))))"
|
||||
|
||||
minisafed = Minisafed(
|
||||
datadir,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from test_framework.utils import (
|
||||
UnixDomainSocketRpc,
|
||||
TailableProc,
|
||||
VERBOSE,
|
||||
LOG_LEVEL,
|
||||
@ -22,6 +24,8 @@ class Minisafed(TailableProc):
|
||||
|
||||
self.conf_file = os.path.join(datadir, "config.toml")
|
||||
self.cmd_line = [MINISAFED_PATH, "--conf", f"{self.conf_file}"]
|
||||
socket_path = os.path.join(os.path.join(datadir, "regtest"), "minisafed_rpc")
|
||||
self.rpc = UnixDomainSocketRpc(socket_path)
|
||||
|
||||
with open(self.conf_file, "w") as f:
|
||||
f.write(f"data_dir = '{datadir}'\n")
|
||||
@ -30,11 +34,13 @@ class Minisafed(TailableProc):
|
||||
|
||||
f.write(f'main_descriptor = "{main_desc}"\n')
|
||||
|
||||
f.write("[bitcoind_config]\n")
|
||||
f.write("[bitcoin_config]\n")
|
||||
f.write('network = "regtest"\n')
|
||||
f.write("poll_interval_secs = 1\n")
|
||||
|
||||
f.write("[bitcoind_config]\n")
|
||||
f.write(f"cookie_path = '{bitcoind_cookie_path}'\n")
|
||||
f.write(f"addr = '127.0.0.1:{bitcoind_rpc_port}'\n")
|
||||
f.write("poll_interval_secs = 1\n")
|
||||
|
||||
def start(self):
|
||||
TailableProc.start(self)
|
||||
@ -42,10 +48,19 @@ class Minisafed(TailableProc):
|
||||
[
|
||||
"Database initialized and checked",
|
||||
"Connection to bitcoind established and checked.",
|
||||
"JSONRPC server started.",
|
||||
]
|
||||
)
|
||||
|
||||
def stop(self, timeout=5):
|
||||
try:
|
||||
self.rpc.stop()
|
||||
self.wait_for_log(
|
||||
"Stopping the minisafe daemon.",
|
||||
)
|
||||
self.proc.wait(timeout)
|
||||
except Exception as e:
|
||||
logging.error(f"{self.prefix} : error when calling stop: '{e}'")
|
||||
return TailableProc.stop(self)
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@ -1,811 +0,0 @@
|
||||
import bip32
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
||||
from ephemeral_port_reserve import reserve
|
||||
from nacl.public import PrivateKey as Curve25519Private
|
||||
from test_framework import serializations
|
||||
from test_framework.bitcoind import BitcoindRpcProxy
|
||||
from test_framework.coordinatord import Coordinatord
|
||||
from test_framework.cosignerd import Cosignerd
|
||||
from test_framework.miradord import Miradord
|
||||
from test_framework.revaultd import ManagerRevaultd, StakeholderRevaultd, StkManRevaultd
|
||||
from test_framework.utils import (
|
||||
get_descriptors,
|
||||
get_participants,
|
||||
finalize_input,
|
||||
wait_for,
|
||||
TIMEOUT,
|
||||
WT_PLUGINS_DIR,
|
||||
)
|
||||
|
||||
|
||||
class RevaultNetwork:
|
||||
# FIXME: we use a single bitcoind for all the wallets because it's much
|
||||
# more efficient. Eventually, we may have to test with separate ones.
|
||||
def __init__(
|
||||
self,
|
||||
root_dir,
|
||||
bitcoind,
|
||||
executor,
|
||||
postgres_user,
|
||||
postgres_pass,
|
||||
postgres_host="localhost",
|
||||
):
|
||||
self.root_dir = root_dir
|
||||
self.bitcoind = bitcoind
|
||||
self.daemons = []
|
||||
|
||||
self.executor = executor
|
||||
|
||||
self.postgres_user = postgres_user
|
||||
self.postgres_pass = postgres_pass
|
||||
self.postgres_host = postgres_host
|
||||
self.coordinator_port = reserve()
|
||||
|
||||
self.stk_wallets = []
|
||||
self.stkman_wallets = []
|
||||
self.man_wallets = []
|
||||
|
||||
self.csv = None
|
||||
self.emergency_address = None
|
||||
|
||||
self.bitcoind_proxy = None
|
||||
|
||||
def deploy(
|
||||
self,
|
||||
n_stakeholders,
|
||||
n_managers,
|
||||
n_stkmanagers=0,
|
||||
csv=None,
|
||||
managers_threshold=None,
|
||||
with_cosigs=True,
|
||||
with_watchtowers=True,
|
||||
with_cpfp=True,
|
||||
bitcoind_rpc_mocks=[],
|
||||
):
|
||||
"""
|
||||
Deploy a revault setup with {n_stakeholders} stakeholders, {n_managers}
|
||||
managers.
|
||||
"""
|
||||
# They didn't provide it, defaults to n_managers
|
||||
# PS: No I can't just managers_threshold=n_managers in the method's signature :(
|
||||
if managers_threshold == None:
|
||||
managers_threshold = n_managers + n_stkmanagers
|
||||
|
||||
assert n_stakeholders + n_stkmanagers >= 2, "Not enough stakeholders"
|
||||
assert n_managers + n_stkmanagers >= 1, "Not enough managers"
|
||||
assert managers_threshold <= n_managers + n_stkmanagers, "Invalid threshold"
|
||||
|
||||
# Connection info to bitcoind. Change the port depending on whether we are proxying
|
||||
# the daemons' requests.
|
||||
bitcoind_cookie = os.path.join(self.bitcoind.bitcoin_dir, "regtest", ".cookie")
|
||||
if len(bitcoind_rpc_mocks) > 0:
|
||||
self.bitcoind_proxy = BitcoindRpcProxy(
|
||||
self.bitcoind.rpcport, bitcoind_cookie, bitcoind_rpc_mocks
|
||||
)
|
||||
bitcoind_rpcport = self.bitcoind_proxy.rpcport
|
||||
else:
|
||||
bitcoind_rpcport = self.bitcoind.rpcport
|
||||
|
||||
(
|
||||
stkonly_keychains,
|
||||
stkonly_cosig_keychains,
|
||||
manonly_keychains,
|
||||
stkman_stk_keychains,
|
||||
stkman_cosig_keychains,
|
||||
stkman_man_keychains,
|
||||
) = get_participants(n_stakeholders, n_managers, n_stkmanagers, with_cosigs)
|
||||
stks_keychains = stkonly_keychains + stkman_stk_keychains
|
||||
cosigs_keychains = stkonly_cosig_keychains + stkman_cosig_keychains
|
||||
mans_keychains = manonly_keychains + stkman_man_keychains
|
||||
|
||||
if csv is None:
|
||||
# Not more than 6 months
|
||||
csv = random.randint(1, 26784)
|
||||
self.csv = csv
|
||||
|
||||
man_cpfp_seeds = [os.urandom(32) for _ in range(len(manonly_keychains))]
|
||||
man_cpfp_privs = [
|
||||
bip32.BIP32.from_seed(seed, network="test") for seed in man_cpfp_seeds
|
||||
]
|
||||
stkman_cpfp_seeds = [os.urandom(32) for _ in range(len(stkman_man_keychains))]
|
||||
stkman_cpfp_privs = [
|
||||
bip32.BIP32.from_seed(seed, network="test") for seed in stkman_cpfp_seeds
|
||||
]
|
||||
cpfp_xpubs = [c.get_xpub() for c in man_cpfp_privs + stkman_cpfp_privs]
|
||||
stks_xpubs = [stk.get_xpub() for stk in stks_keychains]
|
||||
cosigs_keys = [cosig.get_static_key().hex() for cosig in cosigs_keychains]
|
||||
mans_xpubs = [man.get_xpub() for man in mans_keychains]
|
||||
(self.deposit_desc, self.unvault_desc, self.cpfp_desc) = get_descriptors(
|
||||
stks_xpubs, cosigs_keys, mans_xpubs, managers_threshold, cpfp_xpubs, csv
|
||||
)
|
||||
# Generate a dummy 2of2 to be used as our Emergency address
|
||||
desc = "wsh(multi(2,cRE7qAArQYnFQK7S1gXFTArFT4UWvh8J2v2EUajRWXbWFvRzxoeF,\
|
||||
cTzcgRCmHNqUqZuZgvCPLUDXXrQSoVQpZiXQZWQzsLEytcTr6iXi))"
|
||||
checksum = self.bitcoind.rpc.getdescriptorinfo(desc)["checksum"]
|
||||
desc = f"{desc}#{checksum}"
|
||||
self.emergency_address = self.bitcoind.rpc.deriveaddresses(desc)[0]
|
||||
desc_import = self.bitcoind.rpc.importdescriptors(
|
||||
[
|
||||
{
|
||||
"desc": desc,
|
||||
"timestamp": "now",
|
||||
"label": "revault-emergency",
|
||||
}
|
||||
]
|
||||
)
|
||||
if not desc_import[0]["success"]:
|
||||
raise Exception(desc_import)
|
||||
|
||||
# FIXME: this is getting dirty.. We should re-centralize information
|
||||
# about each participant in specified data structures
|
||||
stkonly_cosigners_ports = []
|
||||
stkman_cosigners_ports = []
|
||||
|
||||
# The Noise keys are interdependant, so generate everything in advance
|
||||
# to avoid roundtrips
|
||||
coordinator_noisepriv = os.urandom(32)
|
||||
coordinator_noisepub = bytes(
|
||||
Curve25519Private(coordinator_noisepriv).public_key
|
||||
)
|
||||
|
||||
(stkonly_noiseprivs, stkonly_noisepubs) = ([], [])
|
||||
(stkonly_wt_noiseprivs, stkonly_wt_noisepubs) = ([], [])
|
||||
(stkonly_cosig_noiseprivs, stkonly_cosig_noisepubs) = ([], [])
|
||||
for i in range(len(stkonly_keychains)):
|
||||
stkonly_noiseprivs.append(os.urandom(32))
|
||||
stkonly_noisepubs.append(
|
||||
bytes(Curve25519Private(stkonly_noiseprivs[i]).public_key)
|
||||
)
|
||||
if with_cosigs:
|
||||
stkonly_cosig_noiseprivs.append(os.urandom(32))
|
||||
stkonly_cosig_noisepubs.append(
|
||||
bytes(Curve25519Private(stkonly_cosig_noiseprivs[i]).public_key)
|
||||
)
|
||||
# Unused yet
|
||||
stkonly_wt_noiseprivs.append(os.urandom(32))
|
||||
stkonly_wt_noisepubs.append(
|
||||
bytes(Curve25519Private(stkonly_wt_noiseprivs[i]).public_key)
|
||||
)
|
||||
|
||||
(stkman_noiseprivs, stkman_noisepubs) = ([], [])
|
||||
(stkman_wt_noiseprivs, stkman_wt_noisepubs) = ([], [])
|
||||
(stkman_cosig_noiseprivs, stkman_cosig_noisepubs) = ([], [])
|
||||
for i in range(len(stkman_stk_keychains)):
|
||||
stkman_noiseprivs.append(os.urandom(32))
|
||||
stkman_noisepubs.append(
|
||||
bytes(Curve25519Private(stkman_noiseprivs[i]).public_key)
|
||||
)
|
||||
if with_cosigs:
|
||||
stkman_cosig_noiseprivs.append(os.urandom(32))
|
||||
stkman_cosig_noisepubs.append(
|
||||
bytes(Curve25519Private(stkman_cosig_noiseprivs[i]).public_key)
|
||||
)
|
||||
# Unused yet
|
||||
stkman_wt_noiseprivs.append(os.urandom(32))
|
||||
stkman_wt_noisepubs.append(
|
||||
bytes(Curve25519Private(stkman_wt_noiseprivs[i]).public_key)
|
||||
)
|
||||
|
||||
(man_noiseprivs, man_noisepubs) = ([], [])
|
||||
for i in range(len(manonly_keychains)):
|
||||
man_noiseprivs.append(os.urandom(32))
|
||||
man_noisepubs.append(bytes(Curve25519Private(man_noiseprivs[i]).public_key))
|
||||
|
||||
logging.debug(
|
||||
f"Using Noise pubkeys:\n- Stakeholders: {stkonly_noisepubs + stkman_noisepubs}"
|
||||
f" (of which {len(stkman_noisepubs)} are also managers)"
|
||||
f"\n- Managers: {man_noisepubs}\n- Watchtowers:"
|
||||
f"{stkonly_wt_noisepubs + stkman_wt_noisepubs}\n"
|
||||
)
|
||||
|
||||
# Spin up the "Sync Server"
|
||||
coord_datadir = os.path.join(self.root_dir, "coordinatord")
|
||||
os.makedirs(coord_datadir, exist_ok=True)
|
||||
coordinatord = Coordinatord(
|
||||
coord_datadir,
|
||||
coordinator_noisepriv,
|
||||
man_noisepubs + stkman_noisepubs,
|
||||
stkonly_noisepubs + stkman_noisepubs,
|
||||
stkonly_wt_noisepubs + stkman_wt_noisepubs,
|
||||
self.coordinator_port,
|
||||
bitcoind_rpcport,
|
||||
bitcoind_cookie,
|
||||
self.postgres_user,
|
||||
self.postgres_pass,
|
||||
self.postgres_host,
|
||||
)
|
||||
coordinatord.start()
|
||||
self.daemons.append(coordinatord)
|
||||
|
||||
cosigners_info = []
|
||||
for (i, noisepub) in enumerate(stkonly_cosig_noisepubs):
|
||||
stkonly_cosigners_ports.append(reserve())
|
||||
cosigners_info.append(
|
||||
{
|
||||
"host": f"127.0.0.1:{stkonly_cosigners_ports[i]}",
|
||||
"noise_key": noisepub,
|
||||
}
|
||||
)
|
||||
for (i, noisepub) in enumerate(stkman_cosig_noisepubs):
|
||||
stkman_cosigners_ports.append(reserve())
|
||||
cosigners_info.append(
|
||||
{
|
||||
"host": f"127.0.0.1:{stkman_cosigners_ports[i]}",
|
||||
"noise_key": noisepub,
|
||||
}
|
||||
)
|
||||
|
||||
# Start daemons in parallel, as it takes a few seconds for each
|
||||
start_jobs = []
|
||||
# By default the watchtower should not revault anything
|
||||
default_wt_plugin = {
|
||||
"path": os.path.join(WT_PLUGINS_DIR, "revault_nothing.py"),
|
||||
"conf": {},
|
||||
}
|
||||
|
||||
# Spin up the stakeholders wallets and their cosigning servers
|
||||
for i, stk in enumerate(stkonly_keychains):
|
||||
if with_watchtowers:
|
||||
datadir = os.path.join(self.root_dir, f"miradord-{i}")
|
||||
os.makedirs(datadir)
|
||||
wt_listen_port = reserve()
|
||||
miradord = Miradord(
|
||||
datadir,
|
||||
str(self.deposit_desc),
|
||||
str(self.unvault_desc),
|
||||
str(self.cpfp_desc),
|
||||
self.emergency_address,
|
||||
wt_listen_port,
|
||||
stkonly_wt_noiseprivs[i],
|
||||
stkonly_noisepubs[i].hex(),
|
||||
coordinator_noisepub.hex(),
|
||||
self.coordinator_port,
|
||||
bitcoind_rpcport,
|
||||
bitcoind_cookie,
|
||||
plugins=[default_wt_plugin],
|
||||
)
|
||||
start_jobs.append(self.executor.submit(miradord.start))
|
||||
self.daemons.append(miradord)
|
||||
|
||||
datadir = os.path.join(self.root_dir, f"revaultd-stk-{i}")
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
stk_config = {
|
||||
"keychain": stk,
|
||||
"watchtowers": [
|
||||
{
|
||||
"host": f"127.0.0.1:{wt_listen_port}",
|
||||
"noise_key": stkonly_wt_noisepubs[i].hex(),
|
||||
}
|
||||
]
|
||||
if with_watchtowers
|
||||
else [],
|
||||
"emergency_address": self.emergency_address,
|
||||
}
|
||||
|
||||
revaultd = StakeholderRevaultd(
|
||||
datadir,
|
||||
str(self.deposit_desc),
|
||||
str(self.unvault_desc),
|
||||
str(self.cpfp_desc),
|
||||
stkonly_noiseprivs[i],
|
||||
coordinator_noisepub.hex(),
|
||||
self.coordinator_port,
|
||||
bitcoind_rpcport,
|
||||
bitcoind_cookie,
|
||||
stk_config,
|
||||
wt_process=miradord if with_watchtowers else None,
|
||||
)
|
||||
start_jobs.append(self.executor.submit(revaultd.start))
|
||||
self.stk_wallets.append(revaultd)
|
||||
|
||||
if with_cosigs:
|
||||
datadir = os.path.join(self.root_dir, f"cosignerd-stk-{i}")
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
|
||||
cosignerd = Cosignerd(
|
||||
datadir,
|
||||
stkonly_cosig_noiseprivs[i],
|
||||
stkonly_cosig_keychains[i].get_bitcoin_priv(),
|
||||
stkonly_cosigners_ports[i],
|
||||
man_noisepubs + stkman_noisepubs,
|
||||
)
|
||||
start_jobs.append(self.executor.submit(cosignerd.start))
|
||||
self.daemons.append(cosignerd)
|
||||
|
||||
# Spin up the stakeholder-managers wallets and their cosigning servers
|
||||
for i, stkman in enumerate(stkman_stk_keychains):
|
||||
if with_watchtowers:
|
||||
datadir = os.path.join(self.root_dir, f"miradord-stkman-{i}")
|
||||
os.makedirs(datadir)
|
||||
wt_listen_port = reserve()
|
||||
miradord = Miradord(
|
||||
datadir,
|
||||
str(self.deposit_desc),
|
||||
str(self.unvault_desc),
|
||||
str(self.cpfp_desc),
|
||||
self.emergency_address,
|
||||
wt_listen_port,
|
||||
stkman_wt_noiseprivs[i],
|
||||
stkman_noisepubs[i].hex(),
|
||||
coordinator_noisepub.hex(),
|
||||
self.coordinator_port,
|
||||
bitcoind_rpcport,
|
||||
bitcoind_cookie,
|
||||
plugins=[default_wt_plugin],
|
||||
)
|
||||
start_jobs.append(self.executor.submit(miradord.start))
|
||||
self.daemons.append(miradord)
|
||||
|
||||
datadir = os.path.join(self.root_dir, f"revaultd-stkman-{i}")
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
stk_config = {
|
||||
"keychain": stkman,
|
||||
"watchtowers": [
|
||||
{
|
||||
"host": f"127.0.0.1:{wt_listen_port}",
|
||||
"noise_key": stkman_wt_noisepubs[i].hex(),
|
||||
}
|
||||
]
|
||||
if with_watchtowers
|
||||
else [],
|
||||
"emergency_address": self.emergency_address,
|
||||
}
|
||||
man_config = {
|
||||
"keychain": stkman_man_keychains[i],
|
||||
"cosigners": cosigners_info,
|
||||
}
|
||||
|
||||
revaultd = StkManRevaultd(
|
||||
datadir,
|
||||
str(self.deposit_desc),
|
||||
str(self.unvault_desc),
|
||||
str(self.cpfp_desc),
|
||||
stkman_noiseprivs[i],
|
||||
coordinator_noisepub.hex(),
|
||||
self.coordinator_port,
|
||||
bitcoind_rpcport,
|
||||
bitcoind_cookie,
|
||||
stk_config,
|
||||
man_config,
|
||||
wt_process=miradord if with_watchtowers else None,
|
||||
cpfp_seed=stkman_cpfp_seeds[i] if with_cpfp else None,
|
||||
)
|
||||
start_jobs.append(self.executor.submit(revaultd.start))
|
||||
self.stkman_wallets.append(revaultd)
|
||||
|
||||
if with_cosigs:
|
||||
datadir = os.path.join(self.root_dir, f"cosignerd-stkman-{i}")
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
|
||||
cosignerd = Cosignerd(
|
||||
datadir,
|
||||
stkman_cosig_noiseprivs[i],
|
||||
stkman_cosig_keychains[i].get_bitcoin_priv(),
|
||||
stkman_cosigners_ports[i],
|
||||
man_noisepubs + stkman_noisepubs,
|
||||
)
|
||||
start_jobs.append(self.executor.submit(cosignerd.start))
|
||||
self.daemons.append(cosignerd)
|
||||
|
||||
# Spin up the managers (only) wallets
|
||||
for i, man in enumerate(manonly_keychains):
|
||||
datadir = os.path.join(self.root_dir, f"revaultd-man-{i}")
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
|
||||
man_config = {"keychain": man, "cosigners": cosigners_info}
|
||||
daemon = ManagerRevaultd(
|
||||
datadir,
|
||||
str(self.deposit_desc),
|
||||
str(self.unvault_desc),
|
||||
str(self.cpfp_desc),
|
||||
man_noiseprivs[i],
|
||||
coordinator_noisepub.hex(),
|
||||
self.coordinator_port,
|
||||
bitcoind_rpcport,
|
||||
bitcoind_cookie,
|
||||
man_config,
|
||||
cpfp_seed=man_cpfp_seeds[i] if with_cpfp else None,
|
||||
)
|
||||
start_jobs.append(self.executor.submit(daemon.start))
|
||||
self.man_wallets.append(daemon)
|
||||
|
||||
for j in start_jobs:
|
||||
j.result(TIMEOUT)
|
||||
|
||||
self.daemons += self.stk_wallets + self.stkman_wallets + self.man_wallets
|
||||
|
||||
def mans(self):
|
||||
return self.stkman_wallets + self.man_wallets
|
||||
|
||||
def stks(self):
|
||||
return self.stkman_wallets + self.stk_wallets
|
||||
|
||||
def participants(self):
|
||||
return self.stkman_wallets + self.stk_wallets + self.man_wallets
|
||||
|
||||
def man(self, n):
|
||||
"""Get the {n}th manager (including the stakeholder-managers first)"""
|
||||
mans = self.stkman_wallets + self.man_wallets
|
||||
return mans[n]
|
||||
|
||||
def stk(self, n):
|
||||
"""Get the {n}th stakeholder (including the stakeholder-managers first)"""
|
||||
stks = self.stkman_wallets + self.stk_wallets
|
||||
return stks[n]
|
||||
|
||||
def signed_unvault_psbt(self, deposit, derivation_index):
|
||||
"""Get the fully-signed Unvault transaction for this deposit.
|
||||
|
||||
This will raise if we don't have all the signatures.
|
||||
"""
|
||||
psbt_str = self.stks()[0].rpc.listpresignedtransactions([deposit])[
|
||||
"presigned_transactions"
|
||||
][0]["unvault"]
|
||||
psbt = serializations.PSBT()
|
||||
psbt.deserialize(psbt_str)
|
||||
|
||||
finalize_input(self.deposit_desc, psbt.inputs[0], derivation_index)
|
||||
psbt.tx.wit.vtxinwit.append(psbt.inputs[0].final_script_witness)
|
||||
return psbt.tx.serialize_with_witness().hex()
|
||||
|
||||
def signed_cancel_psbt(self, deposit, derivation_index):
|
||||
"""Get the fully-signed Cancel transaction for this deposit.
|
||||
|
||||
This picks the lowest feerate version.
|
||||
This will raise if we don't have all the signatures.
|
||||
"""
|
||||
psbt_str = self.stks()[0].rpc.listpresignedtransactions([deposit])[
|
||||
"presigned_transactions"
|
||||
][0]["cancel"][0]
|
||||
psbt = serializations.PSBT()
|
||||
psbt.deserialize(psbt_str)
|
||||
|
||||
finalize_input(self.unvault_desc, psbt.inputs[0], derivation_index)
|
||||
psbt.tx.wit.vtxinwit.append(psbt.inputs[0].final_script_witness)
|
||||
return psbt.tx.serialize_with_witness().hex()
|
||||
|
||||
def get_vault(self, address):
|
||||
"""Get a vault entry by outpoint or by address"""
|
||||
for v in self.man(0).rpc.listvaults()["vaults"]:
|
||||
if v["address"] == address:
|
||||
return v
|
||||
|
||||
def fund(self, amount=None):
|
||||
"""Deposit coins into the architectures, by paying to the deposit
|
||||
descriptor and getting the tx 6 blocks confirmations."""
|
||||
assert (
|
||||
len(self.man_wallets + self.stkman_wallets) > 0
|
||||
), "You must have deploy()ed first"
|
||||
|
||||
man = self.man(0)
|
||||
|
||||
if amount is None:
|
||||
amount = 49.9999
|
||||
|
||||
addr = man.rpc.getdepositaddress()["address"]
|
||||
txid = self.bitcoind.rpc.sendtoaddress(addr, amount)
|
||||
man.wait_for_log(f"Got a new unconfirmed deposit at {txid}")
|
||||
self.bitcoind.generate_block(6, wait_for_mempool=txid)
|
||||
man.wait_for_log(f"Vault at {txid}.* is now confirmed")
|
||||
|
||||
vaults = man.rpc.listvaults(["funded"])["vaults"]
|
||||
for v in vaults:
|
||||
if v["txid"] == txid:
|
||||
for w in self.man_wallets + self.stk_wallets:
|
||||
w.wait_for_deposits([f"{txid}:{v['vout']}"])
|
||||
return v
|
||||
|
||||
raise Exception(f"Vault created by '{txid}' got in logs but not in listvaults?")
|
||||
|
||||
def fundmany(self, amounts=[]):
|
||||
"""Deposit coins into the architectures in a single transaction"""
|
||||
assert (
|
||||
len(self.man_wallets + self.stkman_wallets) > 0
|
||||
), "You must have deploy()ed first"
|
||||
assert len(amounts) > 0, "You must provide at least an amount!"
|
||||
|
||||
man = self.man(0)
|
||||
|
||||
curr_index = 0
|
||||
vaults = man.rpc.listvaults()["vaults"]
|
||||
for v in vaults:
|
||||
if v["derivation_index"] > curr_index:
|
||||
curr_index = v["derivation_index"]
|
||||
|
||||
indexes = list(range(curr_index + 1, curr_index + 1 + len(amounts)))
|
||||
amounts_sendmany = {}
|
||||
for i, amount in enumerate(amounts):
|
||||
amounts_sendmany[man.rpc.getdepositaddress(indexes[i])["address"]] = amount
|
||||
|
||||
txid = self.bitcoind.rpc.sendmany("", amounts_sendmany)
|
||||
man.wait_for_logs(
|
||||
[f"Got a new unconfirmed deposit at {txid}" for _ in range(len(amounts))],
|
||||
timeout=TIMEOUT * max(1, len(amounts) / 10),
|
||||
)
|
||||
self.bitcoind.generate_block(6, wait_for_mempool=txid)
|
||||
man.wait_for_logs(
|
||||
[f"Vault at {txid}.* is now confirmed" for _ in range(len(amounts))],
|
||||
timeout=TIMEOUT * max(1, len(amounts) / 10),
|
||||
)
|
||||
|
||||
# Return the vaults we created
|
||||
all_vaults = man.rpc.listvaults(["funded"])["vaults"]
|
||||
created_vaults = []
|
||||
for v in all_vaults:
|
||||
if v["txid"] == txid:
|
||||
created_vaults.append(v)
|
||||
assert len(created_vaults) == len(amounts)
|
||||
|
||||
return created_vaults
|
||||
|
||||
def secure_vault(self, vault):
|
||||
"""Make all stakeholders share signatures for all revocation txs"""
|
||||
deposit = f"{vault['txid']}:{vault['vout']}"
|
||||
for stk in self.stks():
|
||||
stk.wait_for_deposits([deposit])
|
||||
psbts = stk.rpc.getrevocationtxs(deposit)
|
||||
cancel_psbts = [
|
||||
stk.stk_keychain.sign_revocation_psbt(c, vault["derivation_index"])
|
||||
for c in psbts["cancel_txs"]
|
||||
]
|
||||
emer_psbt = stk.stk_keychain.sign_revocation_psbt(
|
||||
psbts["emergency_tx"], vault["derivation_index"]
|
||||
)
|
||||
unemer_psbt = stk.stk_keychain.sign_revocation_psbt(
|
||||
psbts["emergency_unvault_tx"], vault["derivation_index"]
|
||||
)
|
||||
stk.rpc.revocationtxs(deposit, cancel_psbts, emer_psbt, unemer_psbt)
|
||||
for w in self.participants():
|
||||
w.wait_for_secured_vaults([deposit])
|
||||
|
||||
def secure_vaults(self, vaults):
|
||||
"""Secure all these vaults, concurrently."""
|
||||
sec_jobs = []
|
||||
for v in vaults:
|
||||
sec_jobs.append(self.executor.submit(self.secure_vault, v))
|
||||
for j in sec_jobs:
|
||||
j.result(TIMEOUT)
|
||||
|
||||
def activate_vault(self, vault):
|
||||
"""Make all stakeholders share signatures for the unvault tx"""
|
||||
deposit = f"{vault['txid']}:{vault['vout']}"
|
||||
for stk in self.stks():
|
||||
stk.wait_for_secured_vaults([deposit])
|
||||
unvault_psbt = stk.rpc.getunvaulttx(deposit)["unvault_tx"]
|
||||
unvault_psbt = stk.stk_keychain.sign_unvault_psbt(
|
||||
unvault_psbt, vault["derivation_index"]
|
||||
)
|
||||
stk.rpc.unvaulttx(deposit, unvault_psbt)
|
||||
for w in self.participants():
|
||||
w.wait_for_active_vaults([deposit])
|
||||
|
||||
def activate_fresh_vaults(self, vaults):
|
||||
"""Secure then activate all these vaults, concurrently."""
|
||||
# TODO: i'm sure we don't even need to wait for all sec jobs to be complete
|
||||
# before starting the activate_vault futures, given a high enough TIMEOUT.
|
||||
self.secure_vaults(vaults)
|
||||
|
||||
act_jobs = []
|
||||
for v in vaults:
|
||||
act_jobs.append(self.executor.submit(self.activate_vault, v))
|
||||
for j in act_jobs:
|
||||
j.result(TIMEOUT)
|
||||
|
||||
def broadcast_unvaults(self, vaults, destinations, feerate, priority=False):
|
||||
"""
|
||||
Broadcast the Unvault transactions for these {vaults}, advertizing a
|
||||
Spend tx spending to these {destinations} (mapping of addresses to
|
||||
amounts)
|
||||
"""
|
||||
man = self.man(0)
|
||||
deposits = []
|
||||
deriv_indexes = []
|
||||
for v in vaults:
|
||||
deposits.append(f"{v['txid']}:{v['vout']}")
|
||||
deriv_indexes.append(v["derivation_index"])
|
||||
man.wait_for_active_vaults(deposits)
|
||||
|
||||
spend_tx = man.rpc.getspendtx(deposits, destinations, feerate)["spend_tx"][
|
||||
"psbt"
|
||||
]
|
||||
for man in self.mans():
|
||||
spend_tx = man.man_keychain.sign_spend_psbt(spend_tx, deriv_indexes)
|
||||
man.rpc.updatespendtx(spend_tx)
|
||||
|
||||
spend_psbt = serializations.PSBT()
|
||||
spend_psbt.deserialize(spend_tx)
|
||||
spend_psbt.tx.calc_sha256()
|
||||
man.rpc.setspendtx(spend_psbt.tx.hash, priority)
|
||||
return spend_psbt
|
||||
|
||||
def unvault_vaults(self, vaults, destinations, feerate, priority=False):
|
||||
"""
|
||||
Unvault these {vaults}, advertizing a Spend tx spending to these {destinations}
|
||||
(mapping of addresses to amounts)
|
||||
"""
|
||||
spend_psbt = self.broadcast_unvaults(vaults, destinations, feerate, priority)
|
||||
deposits = [f"{v['txid']}:{v['vout']}" for v in vaults]
|
||||
self.bitcoind.generate_block(1, wait_for_mempool=len(deposits))
|
||||
for w in self.participants():
|
||||
wait_for(
|
||||
lambda: len(w.rpc.listvaults(["unvaulted"], deposits)["vaults"])
|
||||
== len(deposits)
|
||||
)
|
||||
return spend_psbt
|
||||
|
||||
def spend_vaults_unconfirmed(self, vaults, destinations, feerate, priority=False):
|
||||
"""
|
||||
Spend these {vaults} to these {destinations} (mapping of addresses to amounts), not
|
||||
confirming the Spend transaction.
|
||||
Make sure to call this only with revault deployment with a low (<500) CSV, or you'll encounter
|
||||
an ugly timeout from bitcoinlib.
|
||||
|
||||
:return: the list of spent deposits along with the Spend PSBT.
|
||||
"""
|
||||
assert len(vaults) > 0
|
||||
man = self.man(0)
|
||||
deposits = []
|
||||
deriv_indexes = []
|
||||
for v in vaults:
|
||||
deposits.append(f"{v['txid']}:{v['vout']}")
|
||||
deriv_indexes.append(v["derivation_index"])
|
||||
|
||||
for man in self.mans():
|
||||
man.wait_for_active_vaults(deposits)
|
||||
|
||||
spend_tx = man.rpc.getspendtx(deposits, destinations, feerate)["spend_tx"][
|
||||
"psbt"
|
||||
]
|
||||
for man in self.mans():
|
||||
spend_tx = man.man_keychain.sign_spend_psbt(spend_tx, deriv_indexes)
|
||||
man.rpc.updatespendtx(spend_tx)
|
||||
|
||||
spend_psbt = serializations.PSBT()
|
||||
spend_psbt.deserialize(spend_tx)
|
||||
spend_psbt.tx.calc_sha256()
|
||||
man.rpc.setspendtx(spend_psbt.tx.hash, priority)
|
||||
|
||||
self.bitcoind.generate_block(1, wait_for_mempool=len(deposits))
|
||||
self.bitcoind.generate_block(self.csv)
|
||||
man.wait_for_log(
|
||||
f"Succesfully broadcasted Spend tx '{spend_psbt.tx.hash}'",
|
||||
)
|
||||
for w in self.participants():
|
||||
wait_for(
|
||||
lambda: len(w.rpc.listvaults(["spending"], deposits)["vaults"])
|
||||
== len(deposits)
|
||||
)
|
||||
|
||||
return deposits, spend_psbt
|
||||
|
||||
def spend_vaults(self, vaults, destinations, feerate, priority=False):
|
||||
"""
|
||||
Spend these {vaults} to these {destinations} (mapping of addresses to amounts).
|
||||
Make sure to call this only with revault deployment with a low (<500) CSV, or you'll encounter
|
||||
an ugly timeout from bitcoinlib.
|
||||
|
||||
:return: the list of spent deposits along with the Spend PSBT.
|
||||
"""
|
||||
deposits, spend_psbt = self.spend_vaults_unconfirmed(
|
||||
vaults, destinations, feerate, priority
|
||||
)
|
||||
|
||||
self.bitcoind.generate_block(1, wait_for_mempool=[spend_psbt.tx.hash])
|
||||
for w in self.participants():
|
||||
wait_for(
|
||||
lambda: len(w.rpc.listvaults(["spent"], deposits)["vaults"])
|
||||
== len(deposits)
|
||||
)
|
||||
|
||||
return deposits, spend_psbt.tx.hash
|
||||
|
||||
def _any_spend_data(self, vaults):
|
||||
addr = self.bitcoind.rpc.getnewaddress()
|
||||
total_spent = sum(v["amount"] for v in vaults)
|
||||
feerate = 2
|
||||
fees = self.compute_spendtx_fees(feerate, len(vaults), 1)
|
||||
return {addr: total_spent - fees}, feerate
|
||||
|
||||
def unvault_vaults_anyhow(self, vaults, priority=False):
|
||||
"""
|
||||
Unvault these vaults with a random Spend transaction for a maximum amount and a
|
||||
fixed feerate.
|
||||
"""
|
||||
destinations, feerate = self._any_spend_data(vaults)
|
||||
return self.unvault_vaults(vaults, destinations, feerate, priority)
|
||||
|
||||
def broadcast_unvaults_anyhow(self, vaults, priority=False):
|
||||
"""
|
||||
Broadcast the Unvault transactions for these vaults with a random Spend
|
||||
transaction for a maximum amount and a fixed feerate.
|
||||
"""
|
||||
destinations, feerate = self._any_spend_data(vaults)
|
||||
return self.broadcast_unvaults(vaults, destinations, feerate, priority)
|
||||
|
||||
def spend_vaults_anyhow(self, vaults):
|
||||
"""Spend these vaults to a random address for a maximum amount for a fixed feerate"""
|
||||
destinations, feerate = self._any_spend_data(vaults)
|
||||
return self.spend_vaults(vaults, destinations, feerate)
|
||||
|
||||
def spend_vaults_anyhow_unconfirmed(self, vaults, priority=False):
|
||||
"""
|
||||
Spend these vaults to a random address for a maximum amount for a fixed feerate,
|
||||
not confirming the Spend transaction.
|
||||
"""
|
||||
destinations, feerate = self._any_spend_data(vaults)
|
||||
return self.spend_vaults_unconfirmed(vaults, destinations, feerate, priority)
|
||||
|
||||
def compute_spendtx_fees(
|
||||
self, spendtx_feerate, n_vaults_spent, n_destinations, with_change=False
|
||||
):
|
||||
"""Get the fees necessary to include in a Spend transaction.
|
||||
This assumes the destinations to be P2WPKH
|
||||
"""
|
||||
n_stk = len(self.stks())
|
||||
n_man = len(self.mans())
|
||||
|
||||
# witscript PUSH, keys , Unvault Script overhead, signatures
|
||||
spend_witness_vb = (
|
||||
1 + (n_man + n_stk * 2) * 34 + 15 + (n_man + n_stk) * 73
|
||||
) // 4
|
||||
# Overhead, P2WPKH, P2WSH, inputs, witnesses
|
||||
spend_witstrip_vb = (
|
||||
11
|
||||
+ 31 * n_destinations
|
||||
+ 43 * (1 + (1 if with_change else 0))
|
||||
+ (32 + 4 + 4 + 1) * n_vaults_spent
|
||||
)
|
||||
spendtx_vbytes = spend_witstrip_vb + spend_witness_vb * n_vaults_spent
|
||||
|
||||
# witscript PUSH, keys , Deposit Script overhead, signatures
|
||||
unvault_witness_vb = (1 + n_stk * (34 + 73) + 3) // 4
|
||||
# Overhead, P2WSH * 2, inputs + witness
|
||||
unvaulttxs_vbytes = (
|
||||
11 + 43 * 2 + (32 + 4 + 4 + 1) + unvault_witness_vb
|
||||
) * n_vaults_spent
|
||||
|
||||
return (
|
||||
spendtx_vbytes * spendtx_feerate # Spend fees
|
||||
+ 2 * 32 * spendtx_vbytes # Spend CPFP
|
||||
+ unvaulttxs_vbytes * 24 # Unvault fees (6sat/WU feerate)
|
||||
+ 30_000 * n_vaults_spent # Unvault CPFP
|
||||
)
|
||||
|
||||
def cancel_vault(self, vault):
|
||||
deposit = f"{vault['txid']}:{vault['vout']}"
|
||||
|
||||
for w in self.participants():
|
||||
wait_for(
|
||||
lambda: len(
|
||||
w.rpc.listvaults(
|
||||
["unvaulting", "unvaulted", "spending"], [deposit]
|
||||
)["vaults"]
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
self.stk(0).rpc.revault(deposit)
|
||||
self.bitcoind.generate_block(1, wait_for_mempool=1)
|
||||
for w in self.participants():
|
||||
wait_for(
|
||||
lambda: len(w.rpc.listvaults(["canceled"], [deposit])["vaults"]) == 1
|
||||
)
|
||||
|
||||
def stop_wallets(self):
|
||||
jobs = [self.executor.submit(w.stop) for w in self.participants()]
|
||||
for j in jobs:
|
||||
j.result(TIMEOUT)
|
||||
|
||||
def start_wallets(self):
|
||||
jobs = [self.executor.submit(w.start) for w in self.participants()]
|
||||
for j in jobs:
|
||||
j.result(TIMEOUT)
|
||||
|
||||
def cleanup(self):
|
||||
for n in self.daemons:
|
||||
n.cleanup()
|
||||
if self.bitcoind_proxy is not None:
|
||||
self.bitcoind_proxy.stop()
|
||||
@ -1,7 +1,9 @@
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@ -42,18 +44,160 @@ def wait_for(success, timeout=TIMEOUT, debug_fn=None):
|
||||
|
||||
|
||||
class RpcError(ValueError):
|
||||
def __init__(self, method: str, payload: dict, error: str):
|
||||
def __init__(self, method: str, params: dict, error: str):
|
||||
super(ValueError, self).__init__(
|
||||
"RPC call failed: method: {}, payload: {}, error: {}".format(
|
||||
method, payload, error
|
||||
"RPC call failed: method: {}, params: {}, error: {}".format(
|
||||
method, params, error
|
||||
)
|
||||
)
|
||||
|
||||
self.method = method
|
||||
self.payload = payload
|
||||
self.params = params
|
||||
self.error = error
|
||||
|
||||
|
||||
class UnixSocket(object):
|
||||
"""A wrapper for socket.socket that is specialized to unix sockets.
|
||||
|
||||
Some OS implementations impose restrictions on the Unix sockets.
|
||||
|
||||
- On linux OSs the socket path must be shorter than the in-kernel buffer
|
||||
size (somewhere around 100 bytes), thus long paths may end up failing
|
||||
the `socket.connect` call.
|
||||
|
||||
This is a small wrapper that tries to work around these limitations.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.sock = None
|
||||
self.connect()
|
||||
|
||||
def connect(self) -> None:
|
||||
try:
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.connect(self.path)
|
||||
self.sock.settimeout(TIMEOUT)
|
||||
except OSError as e:
|
||||
self.close()
|
||||
|
||||
if e.args[0] == "AF_UNIX path too long" and os.uname()[0] == "Linux":
|
||||
# If this is a Linux system we may be able to work around this
|
||||
# issue by opening our directory and using `/proc/self/fd/` to
|
||||
# get a short alias for the socket file.
|
||||
#
|
||||
# This was heavily inspired by the Open vSwitch code see here:
|
||||
# https://github.com/openvswitch/ovs/blob/master/python/ovs/socket_util.py
|
||||
|
||||
dirname = os.path.dirname(self.path)
|
||||
basename = os.path.basename(self.path)
|
||||
|
||||
# Open an fd to our home directory, that we can then find
|
||||
# through `/proc/self/fd` and access the contents.
|
||||
dirfd = os.open(dirname, os.O_DIRECTORY | os.O_RDONLY)
|
||||
short_path = "/proc/self/fd/%d/%s" % (dirfd, basename)
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.connect(short_path)
|
||||
else:
|
||||
# There is no good way to recover from this.
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
if self.sock is not None:
|
||||
self.sock.close()
|
||||
self.sock = None
|
||||
|
||||
def sendall(self, b: bytes) -> None:
|
||||
if self.sock is None:
|
||||
raise socket.error("not connected")
|
||||
|
||||
self.sock.sendall(b)
|
||||
|
||||
def recv(self, length: int) -> bytes:
|
||||
if self.sock is None:
|
||||
raise socket.error("not connected")
|
||||
|
||||
return self.sock.recv(length)
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class UnixDomainSocketRpc(object):
|
||||
def __init__(self, socket_path, logger=logging):
|
||||
self.socket_path = socket_path
|
||||
self.logger = logger
|
||||
self.next_id = 0
|
||||
|
||||
def _readobj(self, sock):
|
||||
"""Read a JSON object"""
|
||||
buff = b""
|
||||
while True:
|
||||
n_to_read = max(2048, len(buff))
|
||||
chunk = sock.recv(n_to_read)
|
||||
buff += chunk
|
||||
if len(chunk) != n_to_read:
|
||||
try:
|
||||
return json.loads(buff)
|
||||
except json.JSONDecodeError:
|
||||
# There is more to read, continue
|
||||
# FIXME: this is a workaround for large reads taken from revaultd.
|
||||
# We should use the '\n' marker instead since minisafed uses that.
|
||||
continue
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Intercept any call that is not explicitly defined and call @call.
|
||||
|
||||
We might still want to define the actual methods in the subclasses for
|
||||
documentation purposes.
|
||||
"""
|
||||
name = name.replace("_", "-")
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
if len(args) != 0 and len(kwargs) != 0:
|
||||
raise RpcError(
|
||||
name, {}, "Cannot mix positional and non-positional arguments"
|
||||
)
|
||||
return self.call(name, params=kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
def call(self, method, params={}):
|
||||
self.logger.debug(f"Calling {method} with params {params}")
|
||||
|
||||
# FIXME: we open a new socket for every readobj call...
|
||||
sock = UnixSocket(self.socket_path)
|
||||
msg = json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
sock.sendall(msg.encode() + b"\n")
|
||||
this_id = self.next_id
|
||||
resp = self._readobj(sock)
|
||||
|
||||
self.logger.debug(f"Received response for {method} call: {resp}")
|
||||
if "id" in resp and resp["id"] != this_id:
|
||||
raise ValueError(
|
||||
"Malformed response, id is not {}: {}.".format(this_id, resp)
|
||||
)
|
||||
sock.close()
|
||||
|
||||
if not isinstance(resp, dict):
|
||||
raise ValueError(
|
||||
f"Malformed response, response is not a dictionary: {resp}"
|
||||
)
|
||||
elif "error" in resp:
|
||||
raise RpcError(method, params, resp["error"])
|
||||
elif "result" not in resp:
|
||||
raise ValueError('Malformed response, "result" missing.')
|
||||
return resp["result"]
|
||||
|
||||
|
||||
class TailableProc(object):
|
||||
"""A monitorable process that we can start, stop and tail.
|
||||
|
||||
|
||||
17
tests/test_rpc.py
Normal file
17
tests/test_rpc.py
Normal file
@ -0,0 +1,17 @@
|
||||
from fixtures import *
|
||||
|
||||
|
||||
def test_getinfo(minisafed):
|
||||
res = minisafed.rpc.getinfo()
|
||||
assert res["version"] == "0.1"
|
||||
assert res["network"] == "regtest"
|
||||
assert res["blockheight"] == 101
|
||||
assert res["sync"] == 1.0
|
||||
assert "main" in res["descriptors"]
|
||||
|
||||
|
||||
def test_getaddress(minisafed):
|
||||
res = minisafed.rpc.getnewaddress()
|
||||
assert "address" in res
|
||||
# We'll get a new one at every call
|
||||
assert res["address"] != minisafed.rpc.getnewaddress()["address"]
|
||||
Loading…
x
Reference in New Issue
Block a user