From 3e60a514c404044ee945c445d63ef1f02f235c4e Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Wed, 2 Dec 2020 19:11:12 +1100 Subject: [PATCH] Pull in bitcoin harness to be able to use @teizinger JSON RPC client @d4nte adapted the harness, but I was not able to use that commit straight away because of bitcoin version conflicts. Pull the harness in for now until the mainnet swap is done, then potentially upgrade bitcoin. --- Cargo.lock | 89 ++++++- Cargo.toml | 2 +- bitcoin-harness/.gitignore | 10 + bitcoin-harness/Cargo.toml | 26 ++ bitcoin-harness/src/bitcoind_rpc.rs | 255 ++++++++++++++++++ bitcoin-harness/src/bitcoind_rpc_api.rs | 126 +++++++++ bitcoin-harness/src/lib.rs | 183 +++++++++++++ bitcoin-harness/src/wallet.rs | 335 ++++++++++++++++++++++++ swap/Cargo.toml | 2 +- swap/src/bitcoin.rs | 17 +- xmr-btc/Cargo.toml | 2 +- 11 files changed, 1017 insertions(+), 30 deletions(-) create mode 100644 bitcoin-harness/.gitignore create mode 100644 bitcoin-harness/Cargo.toml create mode 100644 bitcoin-harness/src/bitcoind_rpc.rs create mode 100644 bitcoin-harness/src/bitcoind_rpc_api.rs create mode 100644 bitcoin-harness/src/lib.rs create mode 100644 bitcoin-harness/src/wallet.rs diff --git a/Cargo.lock b/Cargo.lock index 4b2ec7ce..5877d529 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,22 +246,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a32c9d2fa897cfbb0db45d71e3d2838666194abc4828c0f994e4b5c3bf85ba4" dependencies = [ "bech32", - "bitcoin_hashes", + "bitcoin_hashes 0.7.6", "hex 0.3.2", - "secp256k1", + "secp256k1 0.17.2", + "serde", +] + +[[package]] +name = "bitcoin" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aefc9be9f17185f4ebccae6575d342063f775924d57df0000edb1880c0fb7095" +dependencies = [ + "bech32", + "bitcoin_hashes 0.9.4", + "secp256k1 0.19.0", "serde", ] [[package]] name = "bitcoin-harness" version = "0.1.0" -source = "git+https://github.com/coblox/bitcoin-harness-rs?rev=3be644cd9512c157d3337a189298b8257ed54d04#3be644cd9512c157d3337a189298b8257ed54d04" dependencies = [ + "async-trait", "base64 0.12.3", - "bitcoin", + "bitcoin 0.23.0", "bitcoincore-rpc-json", "futures", "hex 0.4.2", + "jsonrpc_client", "reqwest", "serde", "serde_json", @@ -282,12 +295,21 @@ dependencies = [ ] [[package]] -name = "bitcoincore-rpc-json" -version = "0.11.0" +name = "bitcoin_hashes" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6d55f23cd516d515ae10911164c603ea1040024670ec109715a20d7f6c9d0c" +checksum = "0aaf87b776808e26ae93289bc7d025092b6d909c193f0cdee0b3a86e7bd3c776" dependencies = [ - "bitcoin", + "serde", +] + +[[package]] +name = "bitcoincore-rpc-json" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d488ec31e9cb6726c361be5160f7d2aaace89a0681acf1f476b8fada770b6e" +dependencies = [ + "bitcoin 0.25.2", "hex 0.3.2", "serde", "serde_json", @@ -1551,6 +1573,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpc_client" +version = "0.3.0" +source = "git+https://github.com/thomaseizinger/rust-jsonrpc-client?rev=c7010817e0f86ab24b3dc10d6bb0463faa0aace4#c7010817e0f86ab24b3dc10d6bb0463faa0aace4" +dependencies = [ + "async-trait", + "jsonrpc_client_macro", + "reqwest", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "jsonrpc_client_macro" +version = "0.1.0" +source = "git+https://github.com/thomaseizinger/rust-jsonrpc-client?rev=c7010817e0f86ab24b3dc10d6bb0463faa0aace4#c7010817e0f86ab24b3dc10d6bb0463faa0aace4" +dependencies = [ + "quote 1.0.7", + "syn 1.0.48", +] + [[package]] name = "keccak" version = "0.1.0" @@ -1944,7 +1988,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f196216a07ade53d91a263b3721057f1f0cfd6f95f3d324626f3f9cf7cdfce" dependencies = [ - "bitcoin", + "bitcoin 0.23.0", "serde", ] @@ -2932,7 +2976,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2932dc07acd2066ff2e3921a4419606b220ba6cd03a9935123856cc534877056" dependencies = [ "rand 0.6.5", - "secp256k1-sys", + "secp256k1-sys 0.1.2", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6179428c22c73ac0fbb7b5579a56353ce78ba29759b3b8575183336ea74cdfb" +dependencies = [ + "secp256k1-sys 0.3.0", "serde", ] @@ -2945,6 +2999,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11553d210db090930f4432bea123b31f70bbf693ace14504ea2a35e796c28dd2" +dependencies = [ + "cc", +] + [[package]] name = "secp256kfun" version = "0.3.2-alpha.0" @@ -2952,7 +3015,7 @@ source = "git+https://github.com/LLFourn/secp256kfun?rev=510d48ef6a2b19805f7f5c7 dependencies = [ "digest 0.9.0", "rand_core 0.5.1", - "secp256k1", + "secp256k1 0.17.2", "secp256kfun_parity_backend", "serde", "subtle 2.3.0", @@ -3368,7 +3431,7 @@ dependencies = [ "atty", "backoff", "base64 0.12.3", - "bitcoin", + "bitcoin 0.23.0", "bitcoin-harness", "conquer-once", "derivative", @@ -4146,7 +4209,7 @@ dependencies = [ "async-trait", "backoff", "base64 0.12.3", - "bitcoin", + "bitcoin 0.23.0", "bitcoin-harness", "cross-curve-dleq", "curve25519-dalek 2.1.0", diff --git a/Cargo.toml b/Cargo.toml index 9d880136..2c77ed13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["monero-harness", "xmr-btc", "swap"] +members = ["monero-harness", "bitcoin-harness", "xmr-btc", "swap"] diff --git a/bitcoin-harness/.gitignore b/bitcoin-harness/.gitignore new file mode 100644 index 00000000..088ba6ba --- /dev/null +++ b/bitcoin-harness/.gitignore @@ -0,0 +1,10 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk diff --git a/bitcoin-harness/Cargo.toml b/bitcoin-harness/Cargo.toml new file mode 100644 index 00000000..bba4caa0 --- /dev/null +++ b/bitcoin-harness/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "bitcoin-harness" +version = "0.1.0" +authors = ["CoBloX Team "] +edition = "2018" + +[dependencies] +async-trait = "0.1" +base64 = "0.12.3" +bitcoin = { version = "0.23", features = ["use-serde"] } +bitcoincore-rpc-json = "0.12" +futures = "0.3.5" +hex = "0.4.2" +jsonrpc_client = { git = "https://github.com/thomaseizinger/rust-jsonrpc-client", rev = "c7010817e0f86ab24b3dc10d6bb0463faa0aace4", features = ["reqwest"] } +reqwest = { version = "0.10", default-features = false, features = ["json", "native-tls"] } +serde = "1.0" +serde_json = "1.0" +testcontainers = "0.11" +thiserror = "1.0" +tokio = { version = "0.2", default-features = false, features = ["blocking", "macros", "rt-core", "time"] } +tracing = "0.1" +url = "2" + +[features] +default = [] +test-docker = [] diff --git a/bitcoin-harness/src/bitcoind_rpc.rs b/bitcoin-harness/src/bitcoind_rpc.rs new file mode 100644 index 00000000..a7bea10f --- /dev/null +++ b/bitcoin-harness/src/bitcoind_rpc.rs @@ -0,0 +1,255 @@ +//! An incomplete async bitcoind rpc client that supports multi-wallet features + +use crate::bitcoind_rpc_api::{BitcoindRpcApi, PsbtBase64, WalletProcessPsbtResponse}; +use ::bitcoin::{hashes::hex::FromHex, Address, Amount, Network, Transaction, Txid}; +use bitcoincore_rpc_json::{FinalizePsbtResult, GetAddressInfoResult}; +use jsonrpc_client::{JsonRpcError, ResponsePayload, SendRequest}; +use reqwest::Url; +use serde::{de::DeserializeOwned, Deserialize}; +use std::collections::HashMap; + +pub type Result = std::result::Result; + +pub const JSONRPC_VERSION: &str = "1.0"; + +#[jsonrpc_client::implement(BitcoindRpcApi)] +#[derive(Debug, Clone)] +pub struct Client { + inner: reqwest::Client, + base_url: reqwest::Url, +} + +impl Client { + pub fn new(url: Url) -> Self { + Client { + inner: reqwest::Client::new(), + base_url: url, + } + } + + pub fn with_wallet(&self, wallet_name: &str) -> Result { + Ok(Self { + base_url: self + .base_url + .join(format!("/wallet/{}", wallet_name).as_str())?, + ..self.clone() + }) + } + + pub async fn network(&self) -> Result { + let blockchain_info = self.getblockchaininfo().await?; + + let network = match blockchain_info.chain.as_str() { + "main" => Network::Bitcoin, + "test" => Network::Testnet, + "regtest" => Network::Regtest, + _ => return Err(Error::UnexpectedResponse), + }; + + Ok(network) + } + + pub async fn median_time(&self) -> Result { + let blockchain_info = self.getblockchaininfo().await?; + + Ok(blockchain_info.median_time) + } + + pub async fn set_hd_seed( + &self, + wallet_name: &str, + new_key_pool: Option, + wif_private_key: Option, + ) -> Result<()> { + self.with_wallet(wallet_name)? + .sethdseed(new_key_pool, wif_private_key) + .await?; + + Ok(()) + } + + pub async fn send_to_address( + &self, + wallet_name: &str, + address: Address, + amount: Amount, + ) -> Result { + let txid = self + .with_wallet(wallet_name)? + .sendtoaddress(address, amount.as_btc()) + .await?; + let txid = Txid::from_hex(&txid)?; + + Ok(txid) + } + + pub async fn get_raw_transaction(&self, txid: Txid) -> Result { + let hex: String = self.get_raw_transaction_rpc(txid, false).await?; + let bytes: Vec = FromHex::from_hex(&hex)?; + let transaction = bitcoin::consensus::encode::deserialize(&bytes)?; + + Ok(transaction) + } + + pub async fn get_raw_transaction_verbose( + &self, + txid: Txid, + ) -> Result { + let res = self.get_raw_transaction_rpc(txid, true).await?; + + Ok(res) + } + + async fn get_raw_transaction_rpc(&self, txid: Txid, is_verbose: bool) -> Result + where + R: std::fmt::Debug + DeserializeOwned, + { + let body = jsonrpc_client::Request::new_v2("getrawtransaction") + .with_argument(txid)? + .with_argument(is_verbose)? + .serialize()?; + + let payload: ResponsePayload = self + .inner + .send_request::(self.base_url.clone(), body) + .await + .map_err(::jsonrpc_client::Error::Client)? + .payload; + let response: std::result::Result = payload.into(); + + Ok(response.map_err(::jsonrpc_client::Error::JsonRpc)?) + } + + pub async fn fund_psbt( + &self, + wallet_name: &str, + inputs: &[bitcoincore_rpc_json::CreateRawTransactionInput], + address: Address, + amount: Amount, + ) -> Result { + let mut outputs_converted = HashMap::new(); + outputs_converted.insert(address.to_string(), amount.as_btc()); + let psbt = self + .with_wallet(wallet_name)? + .walletcreatefundedpsbt(inputs, outputs_converted) + .await?; + Ok(psbt.psbt) + } + + pub async fn join_psbts(&self, wallet_name: &str, psbts: &[String]) -> Result { + let psbt = self.with_wallet(wallet_name)?.joinpsbts(psbts).await?; + Ok(psbt) + } + pub async fn wallet_process_psbt( + &self, + wallet_name: &str, + psbt: PsbtBase64, + ) -> Result { + let psbt = self + .with_wallet(wallet_name)? + .walletprocesspsbt(psbt) + .await?; + Ok(psbt) + } + + pub async fn finalize_psbt( + &self, + wallet_name: &str, + psbt: PsbtBase64, + ) -> Result { + let psbt = self.with_wallet(wallet_name)?.finalizepsbt(psbt).await?; + Ok(psbt) + } + + pub async fn address_info( + &self, + wallet_name: &str, + address: &Address, + ) -> Result { + let address_info = self + .with_wallet(wallet_name)? + .getaddressinfo(address) + .await?; + Ok(address_info) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("JSON Rpc Client: ")] + JsonRpcClient(#[from] jsonrpc_client::Error), + #[error("Serde JSON: ")] + SerdeJson(#[from] serde_json::Error), + #[error("Parse amount: ")] + ParseAmount(#[from] bitcoin::util::amount::ParseAmountError), + #[error("Hex decode: ")] + Hex(#[from] bitcoin::hashes::hex::Error), + #[error("Bitcoin decode: ")] + BitcoinDecode(#[from] bitcoin::consensus::encode::Error), + // TODO: add more info to error + #[error("Unexpected response: ")] + UnexpectedResponse, + #[error("Parse url: ")] + ParseUrl(#[from] url::ParseError), +} + +#[derive(Debug, Deserialize)] +struct BlockchainInfo { + chain: Network, + mediantime: u32, +} + +/// Response to the RPC command `getrawtransaction`, when the second +/// argument is set to `true`. +/// +/// It only defines one field, but can be expanded to include all the +/// fields returned by `bitcoind` (see: +/// https://bitcoincore.org/en/doc/0.19.0/rpc/rawtransactions/getrawtransaction/) +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct GetRawTransactionVerboseResponse { + #[serde(rename = "blockhash")] + pub block_hash: Option, +} + +/// Response to the RPC command `getblock`. +/// +/// It only defines one field, but can be expanded to include all the +/// fields returned by `bitcoind` (see: +/// https://bitcoincore.org/en/doc/0.19.0/rpc/blockchain/getblock/) +#[derive(Copy, Clone, Debug, Deserialize)] +pub struct GetBlockResponse { + pub height: u32, +} + +#[cfg(all(test, feature = "test-docker"))] +mod test { + use super::*; + use crate::Bitcoind; + use testcontainers::clients; + + #[tokio::test] + async fn get_network_info() { + let tc_client = clients::Cli::default(); + let (client, _container) = { + let blockchain = Bitcoind::new(&tc_client, "0.19.1").unwrap(); + + (Client::new(blockchain.node_url.clone()), blockchain) + }; + + let network = client.network().await.unwrap(); + + assert_eq!(network, Network::Regtest) + } + + #[tokio::test] + async fn get_median_time() { + let tc_client = clients::Cli::default(); + let (client, _container) = { + let blockchain = Bitcoind::new(&tc_client, "0.19.1").unwrap(); + + (Client::new(blockchain.node_url.clone()), blockchain) + }; + + let _mediant_time = client.median_time().await.unwrap(); + } +} diff --git a/bitcoin-harness/src/bitcoind_rpc_api.rs b/bitcoin-harness/src/bitcoind_rpc_api.rs new file mode 100644 index 00000000..282d4a2f --- /dev/null +++ b/bitcoin-harness/src/bitcoind_rpc_api.rs @@ -0,0 +1,126 @@ +use bitcoin::{Address, BlockHash, Transaction, Txid}; +use bitcoincore_rpc_json::{ + FinalizePsbtResult, GetAddressInfoResult, GetBlockResult, GetBlockchainInfoResult, + GetDescriptorInfoResult, GetTransactionResult, GetWalletInfoResult, ListUnspentResultEntry, + LoadWalletResult, WalletCreateFundedPsbtResult, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[jsonrpc_client::api] +#[async_trait::async_trait] +pub trait BitcoindRpcApi { + async fn createwallet( + &self, + wallet_name: &str, + disable_private_keys: Option, + blank: Option, + passphrase: Option, + avoid_reuse: Option, + ) -> LoadWalletResult; + + async fn deriveaddresses(&self, descriptor: &str, range: Option<[u64; 2]>) -> Vec
; + + async fn dumpwallet(&self, filename: &std::path::Path) -> DumpWalletResponse; + + async fn finalizepsbt(&self, psbt: PsbtBase64) -> FinalizePsbtResult; + + async fn generatetoaddress( + &self, + nblocks: u32, + address: Address, + max_tries: Option, + ) -> Vec; + + async fn getaddressinfo(&self, address: &Address) -> GetAddressInfoResult; + + // TODO: Manual implementation to avoid odd "account" parameter + async fn getbalance( + &self, + account: Account, + minimum_confirmation: Option, + include_watch_only: Option, + avoid_reuse: Option, + ) -> f64; + + async fn getblock(&self, block_hash: &bitcoin::BlockHash) -> GetBlockResult; + + async fn getblockchaininfo(&self) -> GetBlockchainInfoResult; + + async fn getblockcount(&self) -> u32; + + async fn getdescriptorinfo(&self, descriptor: &str) -> GetDescriptorInfoResult; + + async fn getnewaddress(&self, label: Option, address_type: Option) -> Address; + + async fn gettransaction(&self, txid: Txid) -> GetTransactionResult; + + async fn getwalletinfo(&self) -> GetWalletInfoResult; + + async fn joinpsbts(&self, psbts: &[String]) -> PsbtBase64; + + async fn listunspent( + &self, + min_conf: Option, + max_conf: Option, + addresses: Option>, + include_unsafe: Option, + ) -> Vec; + + async fn listwallets(&self) -> Vec; + + async fn sendrawtransaction(&self, transaction: TransactionHex) -> String; + + /// amount is btc + async fn sendtoaddress(&self, address: Address, amount: f64) -> String; + + async fn sethdseed(&self, new_key_pool: Option, wif_private_key: Option) -> (); + + /// Outputs are {address, btc amount} + async fn walletcreatefundedpsbt( + &self, + inputs: &[bitcoincore_rpc_json::CreateRawTransactionInput], + outputs: HashMap, + ) -> WalletCreateFundedPsbtResult; + + async fn walletprocesspsbt(&self, psbt: PsbtBase64) -> WalletProcessPsbtResponse; +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct DumpWalletResponse { + pub filename: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PsbtBase64(pub String); + +#[derive(Debug, Deserialize, Serialize)] +pub struct WalletProcessPsbtResponse { + psbt: String, + complete: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename = "*")] +pub struct Account; + +impl From for PsbtBase64 { + fn from(processed_psbt: WalletProcessPsbtResponse) -> Self { + Self(processed_psbt.psbt) + } +} + +impl From for PsbtBase64 { + fn from(base64_string: String) -> Self { + Self(base64_string) + } +} + +#[derive(Debug, Serialize)] +pub struct TransactionHex(String); + +impl From for TransactionHex { + fn from(tx: Transaction) -> Self { + Self(bitcoin::consensus::encode::serialize_hex(&tx)) + } +} diff --git a/bitcoin-harness/src/lib.rs b/bitcoin-harness/src/lib.rs new file mode 100644 index 00000000..ffc0dcec --- /dev/null +++ b/bitcoin-harness/src/lib.rs @@ -0,0 +1,183 @@ +#![warn( + unused_extern_crates, + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::fallible_impl_from, + clippy::cast_precision_loss, + clippy::cast_possible_wrap, + clippy::dbg_macro +)] +#![forbid(unsafe_code)] + +//! # bitcoin-harness +//! A simple lib to start a bitcoind container, generate blocks and funds +//! addresses. Note: It uses tokio. +//! +//! # Examples +//! +//! ## Just connect to bitcoind and get the network +//! +//! ```rust +//! use bitcoin_harness::{bitcoind_rpc, Bitcoind, Client}; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let tc_client = testcontainers::clients::Cli::default(); +//! let bitcoind = Bitcoind::new(&tc_client, "0.20.0").unwrap(); +//! let client = Client::new(bitcoind.node_url); +//! let network = client.network().await.unwrap(); +//! +//! assert_eq!(network, bitcoin::Network::Regtest) +//! # } +//! ``` +//! +//! ## Create a wallet, fund it and get a UTXO +//! +//! ```rust +//! use bitcoin_harness::{bitcoind_rpc, Bitcoind, Client, Wallet}; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let tc_client = testcontainers::clients::Cli::default(); +//! let bitcoind = Bitcoind::new(&tc_client, "0.19.1").unwrap(); +//! let client = Client::new(bitcoind.node_url.clone()); +//! +//! bitcoind.init(5).await.unwrap(); +//! +//! let wallet = Wallet::new("my_wallet", bitcoind.node_url.clone()) +//! .await +//! .unwrap(); +//! let address = wallet.new_address().await.unwrap(); +//! let amount = bitcoin::Amount::from_btc(3.0).unwrap(); +//! +//! bitcoind.mint(address, amount).await.unwrap(); +//! +//! let balance = wallet.balance().await.unwrap(); +//! +//! assert_eq!(balance, amount); +//! +//! let utxos = wallet.list_unspent().await.unwrap(); +//! +//! assert_eq!(utxos.get(0).unwrap().amount, amount); +//! # } +//! ``` + +pub mod bitcoind_rpc; +pub mod bitcoind_rpc_api; +pub mod wallet; + +use crate::bitcoind_rpc_api::BitcoindRpcApi; +use reqwest::Url; +use std::time::Duration; +use testcontainers::{clients, images::coblox_bitcoincore::BitcoinCore, Container, Docker}; + +pub use crate::{bitcoind_rpc::Client, wallet::Wallet}; + +pub type Result = std::result::Result; + +const BITCOIND_RPC_PORT: u16 = 18443; + +#[derive(Debug)] +pub struct Bitcoind<'c> { + pub container: Container<'c, clients::Cli, BitcoinCore>, + pub node_url: Url, + pub wallet_name: String, +} + +impl<'c> Bitcoind<'c> { + /// Starts a new regtest bitcoind container + pub fn new(client: &'c clients::Cli, tag: &str) -> Result { + let container = client.run(BitcoinCore::default().with_tag(tag)); + let port = container + .get_host_port(BITCOIND_RPC_PORT) + .ok_or(Error::PortNotExposed(BITCOIND_RPC_PORT))?; + + let auth = container.image().auth(); + let url = format!( + "http://{}:{}@localhost:{}", + &auth.username, &auth.password, port + ); + let url = Url::parse(&url)?; + + let wallet_name = String::from("testwallet"); + + Ok(Self { + container, + node_url: url, + wallet_name, + }) + } + + /// Create a test wallet, generate enough block to fund it and activate + /// segwit. Generate enough blocks to make the passed + /// `spendable_quantity` spendable. Spawn a tokio thread to mine a new + /// block every second. + pub async fn init(&self, spendable_quantity: u32) -> Result<()> { + let bitcoind_client = Client::new(self.node_url.clone()); + + bitcoind_client + .createwallet(&self.wallet_name, None, None, None, None) + .await?; + + let reward_address = bitcoind_client + .with_wallet(&self.wallet_name)? + .getnewaddress(None, None) + .await?; + + bitcoind_client + .generatetoaddress(101 + spendable_quantity, reward_address.clone(), None) + .await?; + let _ = tokio::spawn(mine(bitcoind_client, reward_address)); + + Ok(()) + } + + /// Send Bitcoin to the specified address, limited to the spendable bitcoin + /// quantity. + pub async fn mint(&self, address: bitcoin::Address, amount: bitcoin::Amount) -> Result<()> { + let bitcoind_client = Client::new(self.node_url.clone()); + + bitcoind_client + .send_to_address(&self.wallet_name, address.clone(), amount) + .await?; + + // Confirm the transaction + let reward_address = bitcoind_client + .with_wallet(&self.wallet_name)? + .getnewaddress(None, None) + .await?; + bitcoind_client + .generatetoaddress(1, reward_address, None) + .await?; + + Ok(()) + } + + pub fn container_id(&self) -> &str { + self.container.id() + } +} + +async fn mine(bitcoind_client: Client, reward_address: bitcoin::Address) -> Result<()> { + loop { + tokio::time::delay_for(Duration::from_secs(1)).await; + bitcoind_client + .generatetoaddress(1, reward_address.clone(), None) + .await?; + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Bitcoin Rpc: ")] + BitcoindRpc(#[from] bitcoind_rpc::Error), + #[error("Json Rpc: ")] + JsonRpc(#[from] jsonrpc_client::Error), + #[error("Url Parsing: ")] + UrlParseError(#[from] url::ParseError), + #[error("Docker port not exposed: ")] + PortNotExposed(u16), +} diff --git a/bitcoin-harness/src/wallet.rs b/bitcoin-harness/src/wallet.rs new file mode 100644 index 00000000..647a6005 --- /dev/null +++ b/bitcoin-harness/src/wallet.rs @@ -0,0 +1,335 @@ +use crate::{ + bitcoind_rpc::{Client, Result}, + bitcoind_rpc_api::{Account, BitcoindRpcApi, PsbtBase64, WalletProcessPsbtResponse}, +}; +use bitcoin::{hashes::hex::FromHex, Address, Amount, Transaction, Txid}; +use bitcoincore_rpc_json::{ + FinalizePsbtResult, GetAddressInfoResult, GetTransactionResult, GetWalletInfoResult, + ListUnspentResultEntry, +}; +use std::convert::TryFrom; +use url::Url; + +/// A wrapper to bitcoind wallet +#[derive(Debug)] +pub struct Wallet { + name: String, + bitcoind_client: Client, +} + +impl Wallet { + /// Create a wallet on the bitcoind instance or use the wallet with the same + /// name if it exists. + pub async fn new(name: &str, url: Url) -> Result { + let bitcoind_client = Client::new(url); + + let wallet = Self { + name: name.to_string(), + bitcoind_client, + }; + + wallet.init().await?; + + Ok(wallet) + } + + async fn init(&self) -> Result<()> { + match self.info().await { + Err(_) => { + self.bitcoind_client + .createwallet(&self.name, None, None, None, None) + .await?; + Ok(()) + } + Ok(_) => Ok(()), + } + } + + pub async fn info(&self) -> Result { + Ok(self + .bitcoind_client + .with_wallet(&self.name)? + .getwalletinfo() + .await?) + } + + pub async fn median_time(&self) -> Result { + Ok(self.bitcoind_client.median_time().await?) + } + + pub async fn block_height(&self) -> Result { + Ok(self.bitcoind_client.getblockcount().await?) + } + + pub async fn new_address(&self) -> Result
{ + Ok(self + .bitcoind_client + .with_wallet(&self.name)? + .getnewaddress(None, Some("bech32".into())) + .await?) + } + + pub async fn balance(&self) -> Result { + let response = self + .bitcoind_client + .with_wallet(&self.name)? + .getbalance(Account, None, None, None) + .await?; + let amount = Amount::from_btc(response)?; + Ok(amount) + } + + pub async fn send_to_address(&self, address: Address, amount: Amount) -> Result { + let txid = self + .bitcoind_client + .with_wallet(&self.name)? + .sendtoaddress(address, amount.as_btc()) + .await?; + let txid = Txid::from_hex(&txid)?; + + Ok(txid) + } + + pub async fn send_raw_transaction(&self, transaction: Transaction) -> Result { + let txid = self + .bitcoind_client + .with_wallet(&self.name)? + .sendrawtransaction(transaction.into()) + .await?; + let txid = Txid::from_hex(&txid)?; + Ok(txid) + } + + pub async fn get_raw_transaction(&self, txid: Txid) -> Result { + self.bitcoind_client.get_raw_transaction(txid).await + } + + pub async fn get_wallet_transaction(&self, txid: Txid) -> Result { + let res = self + .bitcoind_client + .with_wallet(&self.name)? + .gettransaction(txid) + .await?; + + Ok(res) + } + + pub async fn address_info(&self, address: &Address) -> Result { + self.bitcoind_client.address_info(&self.name, address).await + } + + pub async fn list_unspent(&self) -> Result> { + let unspents = self + .bitcoind_client + .with_wallet(&self.name)? + .listunspent(None, None, None, None) + .await?; + Ok(unspents) + } + + pub async fn fund_psbt(&self, address: Address, amount: Amount) -> Result { + self.bitcoind_client + .fund_psbt(&self.name, &[], address, amount) + .await + } + + pub async fn join_psbts(&self, psbts: &[String]) -> Result { + self.bitcoind_client.join_psbts(&self.name, psbts).await + } + + pub async fn wallet_process_psbt(&self, psbt: PsbtBase64) -> Result { + self.bitcoind_client + .wallet_process_psbt(&self.name, psbt) + .await + } + + pub async fn finalize_psbt(&self, psbt: PsbtBase64) -> Result { + self.bitcoind_client.finalize_psbt(&self.name, psbt).await + } + + pub async fn transaction_block_height(&self, txid: Txid) -> Result> { + let res = self + .bitcoind_client + .get_raw_transaction_verbose(txid) + .await?; + + let block_hash = match res.block_hash { + Some(block_hash) => block_hash, + None => return Ok(None), + }; + + let res = self.bitcoind_client.getblock(&block_hash).await?; + + // TODO: This was changed to u32 because down the road we needed it as u32 (and + // the height should be sufficient as u32) + Ok(Some( + u32::try_from(res.height).expect("can cast block-height to u32"), + )) + } +} + +#[cfg(test)] +mod test { + use std::time::Duration; + + use crate::{Bitcoind, Wallet}; + use bitcoin::{util::psbt::PartiallySignedTransaction, Amount, Transaction, TxOut}; + use tokio::time::delay_for; + + #[tokio::test] + async fn get_wallet_transaction() { + let tc_client = testcontainers::clients::Cli::default(); + let bitcoind = Bitcoind::new(&tc_client, "0.19.1").unwrap(); + bitcoind.init(5).await.unwrap(); + + let wallet = Wallet::new("wallet", bitcoind.node_url.clone()) + .await + .unwrap(); + let mint_address = wallet.new_address().await.unwrap(); + let mint_amount = bitcoin::Amount::from_btc(3.0).unwrap(); + bitcoind.mint(mint_address, mint_amount).await.unwrap(); + + let pay_address = wallet.new_address().await.unwrap(); + let pay_amount = bitcoin::Amount::from_btc(1.0).unwrap(); + let txid = wallet + .send_to_address(pay_address, pay_amount) + .await + .unwrap(); + + let _res = wallet.get_wallet_transaction(txid).await.unwrap(); + } + + #[tokio::test] + async fn two_party_psbt_test() { + let tc_client = testcontainers::clients::Cli::default(); + let bitcoind = Bitcoind::new(&tc_client, "0.19.1").unwrap(); + bitcoind.init(5).await.unwrap(); + + let alice = Wallet::new("alice", bitcoind.node_url.clone()) + .await + .unwrap(); + let address = alice.new_address().await.unwrap(); + let amount = bitcoin::Amount::from_btc(3.0).unwrap(); + bitcoind.mint(address, amount).await.unwrap(); + let joined_address = alice.new_address().await.unwrap(); + let alice_result = alice + .fund_psbt(joined_address.clone(), Amount::from_btc(1.0).unwrap()) + .await + .unwrap(); + + let bob = Wallet::new("bob", bitcoind.node_url.clone()).await.unwrap(); + let address = bob.new_address().await.unwrap(); + let amount = bitcoin::Amount::from_btc(3.0).unwrap(); + bitcoind.mint(address, amount).await.unwrap(); + let bob_psbt = bob + .fund_psbt(joined_address.clone(), Amount::from_btc(1.0).unwrap()) + .await + .unwrap(); + + let joined_psbts = alice + .join_psbts(&[alice_result.clone(), bob_psbt.clone()]) + .await + .unwrap(); + + let partial_signed_bitcoin_transaction: PartiallySignedTransaction = { + let as_hex = base64::decode(joined_psbts.0).unwrap(); + bitcoin::consensus::deserialize(&as_hex).unwrap() + }; + + let transaction = partial_signed_bitcoin_transaction.extract_tx(); + let mut outputs = vec![]; + + transaction.output.iter().for_each(|output| { + // filter out shared output + if output.script_pubkey != joined_address.clone().script_pubkey() { + outputs.push(output.clone()); + } + }); + // add shared output with twice the btc to fit change addresses + outputs.push(TxOut { + value: Amount::from_btc(2.0).unwrap().as_sat(), + script_pubkey: joined_address.clone().script_pubkey(), + }); + + let transaction = Transaction { + output: outputs, + ..transaction + }; + + assert_eq!( + transaction.input.len(), + 2, + "We expect 2 inputs, one from alice, one from bob" + ); + assert_eq!( + transaction.output.len(), + 3, + "We expect 3 outputs, change for alice, change for bob and shared address" + ); + + let psbt = { + let partial_signed_bitcoin_transaction = + PartiallySignedTransaction::from_unsigned_tx(transaction).unwrap(); + let hex_vec = bitcoin::consensus::serialize(&partial_signed_bitcoin_transaction); + base64::encode(hex_vec).into() + }; + + let alice_signed_psbt = alice.wallet_process_psbt(psbt).await.unwrap(); + let bob_signed_psbt = bob + .wallet_process_psbt(alice_signed_psbt.into()) + .await + .unwrap(); + + let alice_finalized_psbt = alice.finalize_psbt(bob_signed_psbt.into()).await.unwrap(); + + let transaction = alice_finalized_psbt.transaction().unwrap().unwrap(); + let txid = alice.send_raw_transaction(transaction).await.unwrap(); + println!("Final tx_id: {:?}", txid); + } + + #[tokio::test] + async fn block_height() { + let tc_client = testcontainers::clients::Cli::default(); + let bitcoind = Bitcoind::new(&tc_client, "0.19.1").unwrap(); + bitcoind.init(5).await.unwrap(); + + let wallet = Wallet::new("wallet", bitcoind.node_url.clone()) + .await + .unwrap(); + + let height_0 = wallet.block_height().await.unwrap(); + delay_for(Duration::from_secs(2)).await; + + let height_1 = wallet.block_height().await.unwrap(); + + assert!(height_1 > height_0) + } + + #[tokio::test] + async fn transaction_block_height() { + let tc_client = testcontainers::clients::Cli::default(); + let bitcoind = Bitcoind::new(&tc_client, "0.19.1").unwrap(); + bitcoind.init(5).await.unwrap(); + + let wallet = Wallet::new("wallet", bitcoind.node_url.clone()) + .await + .unwrap(); + let mint_address = wallet.new_address().await.unwrap(); + let mint_amount = bitcoin::Amount::from_btc(3.0).unwrap(); + bitcoind.mint(mint_address, mint_amount).await.unwrap(); + + let pay_address = wallet.new_address().await.unwrap(); + let pay_amount = bitcoin::Amount::from_btc(1.0).unwrap(); + let txid = wallet + .send_to_address(pay_address, pay_amount) + .await + .unwrap(); + + // wait for the transaction to be included in a block, so that + // it has a block height field assigned to it when calling + // `getrawtransaction` + delay_for(Duration::from_secs(2)).await; + + let _res = wallet.transaction_block_height(txid).await.unwrap(); + } +} diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 5d606654..695293d8 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -13,7 +13,7 @@ atty = "0.2" backoff = { version = "0.2", features = ["tokio"] } base64 = "0.12" bitcoin = { version = "0.23", features = ["rand", "use-serde"] } # TODO: Upgrade other crates in this repo to use this version. -bitcoin-harness = { git = "https://github.com/coblox/bitcoin-harness-rs", rev = "3be644cd9512c157d3337a189298b8257ed54d04" } +bitcoin-harness = { path = "../bitcoin-harness" } conquer-once = "0.3" derivative = "2" ecdsa_fun = { git = "https://github.com/LLFourn/secp256kfun", rev = "510d48ef6a2b19805f7f5c70c598e5b03f668e7a", features = ["libsecp_compat", "serde", "serialization"] } diff --git a/swap/src/bitcoin.rs b/swap/src/bitcoin.rs index 8425fb85..2622da54 100644 --- a/swap/src/bitcoin.rs +++ b/swap/src/bitcoin.rs @@ -1,11 +1,10 @@ -use std::time::Duration; - use anyhow::Result; use async_trait::async_trait; -use backoff::{backoff::Constant as ConstantBackoff, future::FutureOperation as _}; +use backoff::{backoff::Constant as ConstantBackoff, future::FutureOperation}; use bitcoin::util::psbt::PartiallySignedTransaction; -use bitcoin_harness::bitcoind_rpc::PsbtBase64; +use bitcoin_harness::bitcoind_rpc_api::PsbtBase64; use reqwest::Url; +use std::time::Duration; use xmr_btc::bitcoin::{ BlockHeight, BroadcastSignedTransaction, BuildTxLockPsbt, SignTxLock, TransactionBlockHeight, WatchForRawTransaction, @@ -34,16 +33,6 @@ impl Wallet { pub async fn new_address(&self) -> Result
{ self.0.new_address().await.map_err(Into::into) } - - pub async fn transaction_fee(&self, txid: Txid) -> Result { - let fee = self - .0 - .get_wallet_transaction(txid) - .await - .map(|res| bitcoin::Amount::from_btc(-res.fee))??; - - Ok(fee) - } } #[async_trait] diff --git a/xmr-btc/Cargo.toml b/xmr-btc/Cargo.toml index e95bc5a7..e91dd580 100644 --- a/xmr-btc/Cargo.toml +++ b/xmr-btc/Cargo.toml @@ -28,7 +28,7 @@ tracing = "0.1" [dev-dependencies] backoff = { version = "0.2", features = ["tokio"] } base64 = "0.12" -bitcoin-harness = { git = "https://github.com/coblox/bitcoin-harness-rs", rev = "3be644cd9512c157d3337a189298b8257ed54d04" } +bitcoin-harness = { path = "../bitcoin-harness" } futures = "0.3" monero-harness = { path = "../monero-harness" } reqwest = { version = "0.10", default-features = false }