diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 65fd7b2..62d1573 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -7,6 +7,13 @@ on: merge_group: push: branches: [main] + workflow_dispatch: + inputs: + RUST_LOG: + description: "Log level" + required: false + default: "info" + type: string env: CARGO_TERM_COLOR: always @@ -21,24 +28,45 @@ jobs: runs-on: self-hosted timeout-minutes: 60 # better fail-safe than the default 360 in github actions steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly + - name: Install bitcoind + env: + BITCOIND_VERSION: "28.0" + BITCOIND_ARCH: "x86_64-linux-gnu" + SHASUM: "7fe294b02b25b51acb8e8e0a0eb5af6bbafa7cd0c5b0e5fcbb61263104a82fbc" + run: | + curl -fsSLO --proto "=https" --tlsv1.2 "https://bitcoincore.org/bin/bitcoin-core-${{ env.BITCOIND_VERSION }}/bitcoin-${{ env.BITCOIND_VERSION }}-${{ env.BITCOIND_ARCH }}.tar.gz" + sha256sum -c <<< "$SHASUM bitcoin-${{ env.BITCOIND_VERSION }}-${{ env.BITCOIND_ARCH }}.tar.gz" + tar xzf "bitcoin-${{ env.BITCOIND_VERSION }}-${{ env.BITCOIND_ARCH }}.tar.gz" + sudo install -m 0755 -t /usr/local/bin bitcoin-${{ env.BITCOIND_VERSION }}/bin/* + bitcoind --version + rm -rf "bitcoin-${{ env.BITCOIND_VERSION }}" "bitcoin-${{ env.BITCOIND_VERSION }}-${{ env.BITCOIND_ARCH }}.tar.gz" + + - name: Install llvm-tools-preview + uses: dtolnay/rust-toolchain@nightly with: components: llvm-tools-preview toolchain: nightly-2024-07-27 + - name: Install latest nextest release uses: taiki-e/install-action@v2 with: tool: nextest + - name: Install cargo-llvm-cov uses: taiki-e/install-action@v2 with: tool: cargo-llvm-cov + - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + - name: Set RUST_LOG + run: echo "RUST_LOG=${{ github.event.inputs.RUST_LOG }}" >> $GITHUB_ENV + - name: Run tests with coverage run: | cargo llvm-cov --workspace --locked nextest --profile ci --lcov --output-path lcov.info diff --git a/Cargo.lock b/Cargo.lock index 951088f..9ef6e42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1043,6 +1043,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.17" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "0cb8f1d480b0ea3783ab015936d2a55c87e219676f0c0b7dec61494043f21857" +dependencies = [ + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "zstd", + "zstd-safe", +] + [[package]] name = "async-executor" version = "1.13.1" @@ -4035,7 +4049,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.0", "tower-service", - "webpki-roots", + "webpki-roots 0.26.6", ] [[package]] @@ -4585,8 +4599,12 @@ checksum = "763d142cdff44aaadd9268bebddb156ef6c65a0e13486bb81673cf2d8739f9b0" dependencies = [ "base64 0.12.3", "log", + "once_cell", + "rustls 0.21.12", + "rustls-webpki 0.101.7", "serde", "serde_json", + "webpki-roots 0.25.4", ] [[package]] @@ -6074,6 +6092,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.31", + "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -6083,6 +6102,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls 0.21.12", "rustls-pemfile 1.0.4", "serde", "serde_json", @@ -6091,12 +6111,14 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.24.1", "tokio-socks", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 0.25.4", "winreg", ] @@ -6106,6 +6128,7 @@ version = "0.12.9" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "encoding_rs", @@ -6146,7 +6169,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 0.26.6", "windows-registry", ] @@ -6782,7 +6805,7 @@ version = "0.29.1" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes 0.14.0", "rand", "secp256k1-sys 0.10.1", "serde", @@ -7756,6 +7779,32 @@ version = "0.3.3" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "4af28eeb7c18ac2dbdb255d40bee63f203120e1db6b0024b177746ebec7049c1" +[[package]] +name = "strata-bridge-btcio" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bitcoin 0.31.0", + "borsh", + "bytes", + "esplora-client", + "hex", + "musig2 0.0.11", + "rand", + "reqwest 0.12.9", + "serde", + "serde_json", + "sha2", + "strata-common", + "strata-test-utils", + "thiserror", + "threadpool", + "tokio", + "tracing", +] + [[package]] name = "strata-bridge-guest-builder" version = "0.1.0" @@ -7797,6 +7846,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "strata-common" +version = "0.1.0" +source = "git+/~https://github.com/alpenlabs/strata.git?branch=releases/0.1.0#b7078bd625a178b4ab93491e5c8cbfbaaa8a3ba8" +dependencies = [ + "tracing", + "tracing-subscriber 0.3.18", +] + [[package]] name = "strata-crypto" version = "0.1.0" @@ -7864,6 +7922,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "strata-test-utils" +version = "0.1.0" +dependencies = [ + "strata-common", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "strata-zkvm" version = "0.1.0" @@ -8311,7 +8379,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.0", "tungstenite", - "webpki-roots", + "webpki-roots 0.26.6", ] [[package]] @@ -8911,6 +8979,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.6" @@ -9296,6 +9370,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] + [[patch.unused]] name = "ark-r1cs-std" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 9a4d3cd..deac818 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,14 @@ [workspace] members = [ + "crates/btcio", "crates/proof-impl/bitvm-bridge", "crates/tx-graph", "bridge-proofs", # binaries listed separately + + # test utilities + "crates/test-utils", ] default-members = [] @@ -12,26 +16,13 @@ default-members = [] resolver = "2" [workspace.dependencies] -# deps in this workspace +# imported from strata +strata-bridge-btcio = { path = "crates/btcio" } + +# new crates strata-bridge-tx-graph = { path = "crates/tx-graph" } strata-proofimpl-bitvm-bridge = { path = "crates/proof-impl/bitvm-bridge" } - -# deps from original strata repo -shrex = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0", features = [ - "serde", -] } -strata-bridge-tx-builder = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } -strata-primitives = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } -strata-sp1-adapter = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } -strata-state = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } -strata-zkvm = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } - -# transitive deps with patches for `bitvm` -ark-bn254 = { git = "/~https://github.com/Antalpha-Labs/algebra/", features = [ - "curve", -], default-features = false } -ark-serialize = { version = "0.4.2", features = ["std"] } -ark-std = { version = "0.4.0", features = ["std"] } +strata-test-utils = { path = "crates/test-utils" } # external deps anyhow = "1.0.86" @@ -53,7 +44,10 @@ bytes = "1.6.0" chrono = "0.4.38" digest = "0.10" dotenvy = "0.15.7" -esplora-client = { git = "/~https://github.com/BitVM/rust-esplora-client" } +esplora-client = { git = "/~https://github.com/BitVM/rust-esplora-client", default-features = false, features = [ + "blocking-https-rustls", + "async-https-rustls", +] } ethnum = "1.5.0" eyre = "0.6" format_serde_error = { git = "/~https://github.com/AlexanderThaller/format_serde_error" } @@ -73,6 +67,9 @@ musig2 = { version = "0.0.11", features = ["serde", "rand"] } once_cell = "1.19.0" openssh = { version = "0.10.4", features = ["native-mux"] } openssh-sftp-client = { version = "0.14.6", features = ["openssh"] } +opentelemetry = "0.26" +opentelemetry-otlp = { version = "0.26", features = ["grpc-tonic"] } +opentelemetry_sdk = { version = "0.26", features = ["rt-tokio"] } paste = "1.0.15" rand = "0.8.5" rand_chacha = { version = "0.3.1", default-features = false } @@ -94,6 +91,15 @@ serde_json = { version = "1.0", default-features = false, features = [ ] } serde_with = "3.9.0" sha2 = "0.10" +shrex = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0", features = [ + "serde", +] } +strata-bridge-tx-builder = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } +strata-common = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } +strata-primitives = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } +strata-sp1-adapter = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } +strata-state = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } +strata-zkvm = { git = "/~https://github.com/alpenlabs/strata.git", branch = "releases/0.1.0" } suppaftp = { version = "6.0.1", features = ["async", "async-native-tls"] } tempfile = "3.10.1" terrors = "0.3.0" @@ -102,6 +108,7 @@ threadpool = "1.8" tokio = { version = "1.37", features = ["full"] } toml = "0.5" tracing = "0.1.40" +tracing-opentelemetry = "0.27" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } # This is needed for custom build of SP1 diff --git a/crates/btcio/Cargo.toml b/crates/btcio/Cargo.toml new file mode 100644 index 0000000..2b694af --- /dev/null +++ b/crates/btcio/Cargo.toml @@ -0,0 +1,30 @@ +[package] +edition = "2021" +name = "strata-bridge-btcio" +version = "0.1.0" + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +base64.workspace = true +bitcoin.workspace = true +borsh.workspace = true +bytes.workspace = true +esplora-client = { workspace = true, default-features = false, features = [ + "async-https-rustls", +] } +hex.workspace = true +musig2 = { workspace = true, features = ["serde"] } +rand.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +threadpool.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +strata-common.workspace = true +strata-test-utils.workspace = true diff --git a/crates/btcio/src/bitcoind.rs b/crates/btcio/src/bitcoind.rs new file mode 100644 index 0000000..f9cca4d --- /dev/null +++ b/crates/btcio/src/bitcoind.rs @@ -0,0 +1,664 @@ +use std::{ + env::var, + fmt, + sync::atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use base64::{engine::general_purpose, Engine}; +use bitcoin::{ + bip32::Xpriv, block::Header, consensus::encode::serialize_hex, Address, Block, BlockHash, + Network, Transaction, Txid, +}; +use reqwest::{ + header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}, + Client, +}; +use serde::{de, Deserialize, Serialize}; +use serde_json::{ + json, + value::{RawValue, Value}, +}; +use tokio::time::{sleep, Duration}; +use tracing::*; + +use crate::{ + error::{BitcoinRpcError, ClientError, ClientResult}, + traits::{BlockGenerator, Broadcaster, Reader, Signer, Wallet}, + types::{ + CreateWallet, GetBlockVerbosityZero, GetBlockchainInfo, GetNewAddress, GetTransaction, + ImportDescriptor, ImportDescriptorResult, ListDescriptors, ListTransactions, ListUnspent, + SignRawTransactionWithWallet, TestMempoolAccept, + }, + BLOCK_TIME, +}; + +/// The maximum number of retries for a request. +const MAX_RETRIES: u8 = 3; + +/// Custom implementation to convert a value to a `Value` type. +pub fn to_value(value: T) -> ClientResult +where + T: Serialize, +{ + serde_json::to_value(value) + .map_err(|e| ClientError::Param(format!("Error creating value: {}", e))) +} + +/// An `async` client for interacting with a `bitcoind` instance. +#[derive(Debug)] +pub struct BitcoinClient { + /// The URL of the `bitcoind` instance. + url: String, + /// The underlying `async` HTTP client. + client: Client, + /// The ID of the current request. + id: AtomicUsize, +} + +/// Response returned by the `bitcoind` RPC server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Response { + pub result: Option, + pub error: Option, + pub id: u64, +} + +impl BitcoinClient { + /// Creates a new [`BitcoinClient`] with the given URL, username, and password. + pub fn new(url: String, username: String, password: String) -> ClientResult { + if username.is_empty() || password.is_empty() { + return Err(ClientError::MissingUserPassword); + } + + let user_pw = general_purpose::STANDARD.encode(format!("{username}:{password}")); + let authorization = format!("Basic {user_pw}") + .parse() + .map_err(|_| ClientError::Other("Error parsing header".to_string()))?; + + let content_type = "application/json" + .parse() + .map_err(|_| ClientError::Other("Error parsing header".to_string()))?; + let headers = + HeaderMap::from_iter([(AUTHORIZATION, authorization), (CONTENT_TYPE, content_type)]); + + trace!(headers = ?headers); + + let client = Client::builder() + .default_headers(headers) + .build() + .map_err(|e| ClientError::Other(format!("Could not create client: {e}")))?; + + let id = AtomicUsize::new(0); + + trace!(url = %url, "Created bitcoin client"); + + Ok(Self { url, client, id }) + } + + fn next_id(&self) -> usize { + self.id.fetch_add(1, Ordering::AcqRel) + } + + async fn call( + &self, + method: &str, + params: &[Value], + ) -> ClientResult { + let mut retries = 0; + loop { + trace!(%method, ?params, %retries, "Calling bitcoin client"); + + let id = self.next_id(); + + let response = self + .client + .post(&self.url) + .json(&json!({ + "jsonrpc": "1.0", + "id": id, + "method": method, + "params": params + })) + .send() + .await; + trace!(?response, "Response received"); + match response { + Ok(resp) => { + let data = resp + .json::>() + .await + .map_err(|e| ClientError::Parse(e.to_string()))?; + trace!(?data, "Response data"); + if let Some(err) = data.error { + return Err(ClientError::Server(err.code, err.message)); + } + return data + .result + .ok_or_else(|| ClientError::Other("Empty data received".to_string())); + } + Err(err) => { + warn!(err = %err, "Error calling bitcoin client"); + + if err.is_body() { + // Body error is unrecoverable + return Err(ClientError::Body(err.to_string())); + } else if err.is_status() { + // Status error is unrecoverable + let e = match err.status() { + Some(code) => ClientError::Status(code.to_string(), err.to_string()), + _ => ClientError::Other(err.to_string()), + }; + return Err(e); + } else if err.is_decode() { + // Error decoding response, might be recoverable + let e = ClientError::MalformedResponse(err.to_string()); + warn!(%e, "decoding error, retrying..."); + } else if err.is_connect() { + // Connection error, might be recoverable + let e = ClientError::Connection(err.to_string()); + warn!(%e, "connection error, retrying..."); + } else if err.is_timeout() { + // Timeout error, might be recoverable + let e = ClientError::Timeout; + warn!(%e, "timeout error, retrying..."); + } else if err.is_request() { + // General request error, might be recoverable + let e = ClientError::Request(err.to_string()); + warn!(%e, "request error, retrying..."); + } else if err.is_builder() { + // Request builder error is unrecoverable + return Err(ClientError::ReqBuilder(err.to_string())); + } else if err.is_redirect() { + // Redirect error is unrecoverable + return Err(ClientError::HttpRedirect(err.to_string())); + } else { + // Unknown error is unrecoverable + return Err(ClientError::Other("Unknown error".to_string())); + } + } + } + retries += 1; + if retries >= MAX_RETRIES { + return Err(ClientError::MaxRetriesExceeded(MAX_RETRIES)); + } + sleep(Duration::from_millis(1_000)).await; + } + } +} + +#[async_trait] +impl Reader for BitcoinClient { + async fn estimate_smart_fee(&self, conf_target: u16) -> ClientResult { + let result = self + .call::>("estimatesmartfee", &[to_value(conf_target)?]) + .await? + .to_string(); + + let result_map: Value = result.parse::()?; + + let btc_vkb = result_map + .get("feerate") + .unwrap_or(&"0.00001".parse::().unwrap()) + .as_f64() + .unwrap(); + + // convert to sat/vB and round up + Ok((btc_vkb * 100_000_000.0 / 1000.0) as u64) + } + + async fn get_block(&self, hash: &BlockHash) -> ClientResult { + let get_block = self + .call::("getblock", &[to_value(hash.to_string())?, to_value(0)?]) + .await?; + let block = get_block + .block() + .map_err(|err| ClientError::Other(format!("block decode: {}", err)))?; + Ok(block) + } + + async fn get_block_at(&self, height: u32) -> ClientResult { + let hash = self.get_block_hash(height).await?; + self.get_block(&hash).await + } + + async fn get_block_count(&self) -> ClientResult { + self.call::("getblockcount", &[]).await + } + + async fn get_block_hash(&self, height: u32) -> ClientResult { + self.call::("getblockhash", &[to_value(height)?]) + .await + } + + async fn get_blockchain_info(&self) -> ClientResult { + self.call::("getblockchaininfo", &[]) + .await + } + + async fn get_superblock( + &self, + start_time: u32, + end_time: u32, + block_time: Option, + ) -> ClientResult
{ + if start_time >= end_time { + return Err(ClientError::Other("Invalid time range".to_string())); + } + + if end_time > self.get_current_timestamp().await? { + return Err(ClientError::Other("End time is in the future".to_string())); + } + + let block_time = block_time.unwrap_or(BLOCK_TIME); + // inclusive range that's why we add 1. + let n_blocks = ((end_time - start_time) / block_time) + 1; + + // iterate over the chaintip and get the blocks, while trying to be clever + // in order to minimize the number of requests. + let mut blocks_to_include = Vec::with_capacity(n_blocks as usize); + let chain_tip = self.get_block_count().await?; + let current_time = self.get_current_timestamp().await?; + + // Finding the last block with a timestamp less than the end_time + // using 2 * block_time as leeway + let delta_with_leeway = current_time + .checked_sub(end_time) + .and_then(|delta| delta.checked_add(2 * block_time)) + .ok_or(ClientError::Other( + "Overflow occurred in delta_with_leeway calculation".to_string(), + ))?; + + // Finding the potential last block + let potential_last_block_height = chain_tip - delta_with_leeway / block_time; + let mut last_block = { + let hash = self.get_block_hash(potential_last_block_height).await?; + self.get_block(&hash).await? + }; + while last_block.header.time > end_time { + let hash = last_block.header.prev_blockhash; + last_block = self.get_block(&hash).await?; + if last_block.header.time < start_time { + return Err(ClientError::Other("No block found".to_string())); + } + } + + // Found the last block + blocks_to_include.push(last_block.header); // Only include the header + + // Now, continue going backwards until we find the first block + let mut first_block = last_block.clone(); + while first_block.header.time > start_time { + let hash = first_block.header.prev_blockhash; + first_block = self.get_block(&hash).await?; + // Since we are iterating backwards, let's add'em to the blocks_to_include + blocks_to_include.push(first_block.header); // Only include the header + if first_block.header.time < start_time { + return Err(ClientError::Other("No block found".to_string())); + } + } + + // We have all the block headers, let's return the one with the lowest hash. + blocks_to_include + .iter() + .min_by(|a, b| a.block_hash().cmp(&b.block_hash())) + .copied() + .ok_or(ClientError::Other("No block found".to_string())) + } + + async fn get_current_timestamp(&self) -> ClientResult { + let best_block_hash = self.call::("getbestblockhash", &[]).await?; + let block = self.get_block(&best_block_hash).await?; + Ok(block.header.time) + } + + async fn get_raw_mempool(&self) -> ClientResult> { + self.call::>("getrawmempool", &[]).await + } + + async fn network(&self) -> ClientResult { + Ok(self + .call::("getblockchaininfo", &[]) + .await? + .chain + .parse::() + .map_err(|e| ClientError::Parse(e.to_string()))?) + } +} + +#[async_trait] +impl Broadcaster for BitcoinClient { + async fn send_raw_transaction(&self, tx: &Transaction) -> ClientResult { + let txstr = serialize_hex(tx); + trace!(txstr = %txstr, "Sending raw transaction"); + match self + .call::("sendrawtransaction", &[to_value(txstr)?]) + .await + { + Ok(txid) => { + trace!(?txid, "Transaction sent"); + Ok(txid) + } + Err(ClientError::Server(i, s)) => match i { + // Dealing with known and common errors + -27 => Ok(tx.compute_txid()), // Tx already in chain + _ => Err(ClientError::Server(i, s)), + }, + Err(e) => Err(ClientError::Other(e.to_string())), + } + } + + async fn test_mempool_accept(&self, tx: &Transaction) -> ClientResult> { + let txstr = serialize_hex(tx); + trace!(%txstr, "Testing mempool accept"); + self.call::>("testmempoolaccept", &[to_value([txstr])?]) + .await + } +} + +#[async_trait] +impl Wallet for BitcoinClient { + async fn get_new_address(&self) -> ClientResult
{ + let address_unchecked = self + .call::("getnewaddress", &[]) + .await? + .0 + .parse::>() + .map_err(|e| ClientError::Parse(e.to_string()))? + .assume_checked(); + Ok(address_unchecked) + } + + async fn get_transaction(&self, txid: &Txid) -> ClientResult { + Ok(self + .call::("gettransaction", &[to_value(txid.to_string())?]) + .await?) + } + + async fn get_utxos(&self) -> ClientResult> { + let resp = self.call::>("listunspent", &[]).await?; + trace!(?resp, "Got UTXOs"); + Ok(resp) + } + + async fn list_transactions(&self, count: Option) -> ClientResult> { + self.call::>("listtransactions", &[to_value(count)?]) + .await + } + + async fn list_wallets(&self) -> ClientResult> { + self.call::>("listwallets", &[]).await + } +} + +#[async_trait] +impl Signer for BitcoinClient { + async fn sign_raw_transaction_with_wallet( + &self, + tx: &Transaction, + ) -> ClientResult { + let tx_hex = serialize_hex(tx); + trace!(tx_hex = %tx_hex, "Signing transaction"); + self.call::( + "signrawtransactionwithwallet", + &[to_value(tx_hex)?], + ) + .await + } + + async fn get_xpriv(&self) -> ClientResult> { + // If the ENV variable `BITCOIN_XPRIV_RETRIEVABLE` is not set, we return `None` + if var("BITCOIN_XPRIV_RETRIEVABLE").is_err() { + return Ok(None); + } + + let descriptors = self + .call::("listdescriptors", &[to_value(true)?]) // true is the xpriv, false is the xpub + .await? + .descriptors; + if descriptors.is_empty() { + return Err(ClientError::Other("No descriptors found".to_string())); + } + + // We are only interested in the one that contains `tr(` + let descriptor = descriptors + .iter() + .find(|d| d.desc.contains("tr(")) + .map(|d| d.desc.clone()) + .ok_or(ClientError::Xpriv)?; + + // Now we extract the xpriv from the `tr()` up to the first `/` + let xpriv_str = descriptor + .split("tr(") + .nth(1) + .ok_or(ClientError::Xpriv)? + .split("/") + .next() + .ok_or(ClientError::Xpriv)?; + + let xpriv = xpriv_str.parse::().map_err(|_| ClientError::Xpriv)?; + Ok(Some(xpriv)) + } + + async fn import_descriptors( + &self, + descriptors: Vec, + wallet_name: String, + ) -> ClientResult> { + let wallet_args = CreateWallet { + wallet_name, + load_on_startup: Some(true), + }; + + // TODO: this should check for -35 error code which is good, + // means that is already created + let _wallet_create = self + .call::("createwallet", &[to_value(wallet_args.clone())?]) + .await; + // TODO: this should check for -35 error code which is good, -18 is bad. + let _wallet_load = self + .call::("loadwallet", &[to_value(wallet_args)?]) + .await; + + let result = self + .call::>("importdescriptors", &[to_value(descriptors)?]) + .await?; + Ok(result) + } +} + +#[async_trait] +impl BlockGenerator for BitcoinClient { + async fn generate_to_address( + &self, + count: u16, + address: &Address, + ) -> ClientResult> { + let hashes = self + .call::>( + "generatetoaddress", + &[to_value(count)?, to_value(address.to_string())?], + ) + .await?; + Ok(hashes) + } +} + +#[cfg(test)] +mod test { + use std::env::set_var; + + use bitcoin::{consensus, hashes::Hash, NetworkKind}; + use strata_common::logging; + use strata_test_utils::bitcoind::BitcoinD; + use tracing::trace; + + use super::*; + + /// Mine a number of blocks of a given size `count`, which may be specified to a given coinbase + /// `address`. + pub async fn mine_blocks( + client: &BitcoinClient, + count: u16, + address: Option
, + ) -> anyhow::Result> { + let coinbase_address = match address { + Some(address) => address, + None => client.get_new_address().await?, + }; + + trace!(%coinbase_address, "generatedtoaddress"); + let block_hashes = client.generate_to_address(count, &coinbase_address).await?; + trace!(?block_hashes, "generatedtoaddress"); + + Ok(block_hashes) + } + + // #[tokio::test()] + #[allow(unused)] // FIXME: remove when these tests work in CI + async fn client_works() { + logging::init(); + + let bitcoind = BitcoinD::default(); + let url = bitcoind.url.to_string(); + let user = bitcoind.user.to_string(); + let password = bitcoind.password.to_string(); + + // setting the ENV variable `BITCOIN_XPRIV_RETRIEVABLE` to retrieve the xpriv + set_var("BITCOIN_XPRIV_RETRIEVABLE", "true"); + let client = BitcoinClient::new(url, user, password).unwrap(); + // wait for the client to be ready + sleep(Duration::from_secs(1)).await; + + // network + let got = client.network().await.unwrap(); + let expected = Network::Regtest; + assert_eq!(expected, got); + + // get_blockchain_info + let get_blockchain_info = client.get_blockchain_info().await.unwrap(); + assert_eq!(get_blockchain_info.blocks, 0); + + // get_current_timestamp + let start_time = client.get_current_timestamp().await.unwrap(); + let blocks = mine_blocks(&client, 101, None).await.unwrap(); + + // get_block + let expected = blocks.last().unwrap(); + let got = client.get_block(expected).await.unwrap().block_hash(); + assert_eq!(*expected, got); + + // get_block_at + let target_height = blocks.len() as u32; + let expected = blocks.last().unwrap(); + let got = client + .get_block_at(target_height) + .await + .unwrap() + .block_hash(); + assert_eq!(*expected, got); + + // get_block_count + let expected = blocks.len() as u32; + let got = client.get_block_count().await.unwrap(); + assert_eq!(expected, got); + + // get_block_hash + let target_height = blocks.len() as u32; + let expected = blocks.last().unwrap(); + let got = client.get_block_hash(target_height).await.unwrap(); + assert_eq!(*expected, got); + + // get_new_address + let address = client.get_new_address().await.unwrap(); + let txid = client + .call::( + "sendtoaddress", + &[to_value(address.to_string()).unwrap(), to_value(1).unwrap()], + ) + .await + .unwrap() + .parse::() + .unwrap(); + + // get_transaction + let tx = client.get_transaction(&txid).await.unwrap().hex; + let got = client.send_raw_transaction(&tx).await.unwrap(); + let expected = txid; + assert_eq!(expected, got); + + // get_raw_mempool + let got = client.get_raw_mempool().await.unwrap(); + let expected = vec![txid]; + assert_eq!(expected, got); + + // estimate_smart_fee + let got = client.estimate_smart_fee(1).await.unwrap(); + let expected = 1; // 1 sat/vB + assert_eq!(expected, got); + + // sign_raw_transaction_with_wallet + let got = client.sign_raw_transaction_with_wallet(&tx).await.unwrap(); + assert!(got.complete); + assert!(consensus::encode::deserialize_hex::(&got.hex).is_ok()); + + // test_mempool_accept + let txids = client.test_mempool_accept(&tx).await.unwrap(); + let got = txids.first().unwrap(); + assert_eq!(got.txid, tx.compute_txid()); + + // send_raw_transaction + let got = client.send_raw_transaction(&tx).await.unwrap(); + assert!(got.as_byte_array().len() == 32); + + // list_transactions + let got = client.list_transactions(None).await.unwrap(); + assert_eq!(got.len(), 10); + + // get_utxos + // let's mine one more block + mine_blocks(&client, 1, None).await.unwrap(); + let got = client.get_utxos().await.unwrap(); + assert_eq!(got.len(), 3); + + // listdescriptors + let got = client.get_xpriv().await.unwrap().unwrap().network; + let expected = NetworkKind::Test; + assert_eq!(expected, got); + + // importdescriptors + // taken from /~https://github.com/rust-bitcoin/rust-bitcoin/blob/bb38aeb786f408247d5bbc88b9fa13616c74c009/bitcoin/examples/taproot-psbt.rs#L18C38-L18C149 + let descriptor_string = "tr([e61b318f/56'/20']tprv8ZgxMBicQKsPd4arFr7sKjSnKFDVMR2JHw9Y8L9nXN4kiok4u28LpHijEudH3mMYoL4pM5UL9Bgdz2M4Cy8EzfErmU9m86ZTw6hCzvFeTg7/101/*)#zz430whl".to_owned(); + let timestamp = "now".to_owned(); + let list_descriptors = vec![ImportDescriptor { + desc: descriptor_string, + active: Some(true), + timestamp, + }]; + let got = client + .import_descriptors(list_descriptors, "strata".to_owned()) + .await + .unwrap(); + let expected = vec![ImportDescriptorResult { success: true }]; + assert_eq!(expected, got); + + // superblock + let end_time = client.get_current_timestamp().await.unwrap(); + let got = client + .get_superblock(start_time, end_time, Some(1)) + .await + .unwrap() + .block_hash(); + let block_hash_first = client.get_block_hash(1).await.unwrap(); + let block_hash_mid = client.get_block_hash(50).await.unwrap(); + let block_hash_last = { + let height = client.get_block_count().await.unwrap(); + client.get_block_hash(height).await.unwrap() + }; + assert!(got <= block_hash_first); + assert!(got <= block_hash_mid); + assert!(got <= block_hash_last); + + drop(bitcoind); + } +} diff --git a/crates/btcio/src/constants.rs b/crates/btcio/src/constants.rs new file mode 100644 index 0000000..f1b2ebc --- /dev/null +++ b/crates/btcio/src/constants.rs @@ -0,0 +1,6 @@ +/// The block time in seconds for the target Bitcoin network. +/// +/// # Note +/// +/// This is configured for Strata's signet, which is 30 seconds. +pub const BLOCK_TIME: u32 = 30; diff --git a/crates/btcio/src/error.rs b/crates/btcio/src/error.rs new file mode 100644 index 0000000..d4e71b3 --- /dev/null +++ b/crates/btcio/src/error.rs @@ -0,0 +1,218 @@ +//! Error types for the RPC client. +use std::fmt; + +use bitcoin::Network; +use serde::{Deserialize, Serialize}; +use serde_json::Error as SerdeJsonError; +use thiserror::Error; + +/// This is an alias for the result type returned by any bitcoin client. +pub type ClientResult = Result; + +/// The error type for errors produced in this library. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ClientError { + /// Network error, retry might help + #[error("Network: {0}")] + Network(String), + + /// Missing username or password for the RPC server + #[error("Missing username or password")] + MissingUserPassword, + + /// RPC server returned an error + /// + /// # Note + /// + /// These errors are ABSOLUTELY UNDOCUMENTED. + /// Check + /// + /// and good luck! + #[error("RPC server returned error '{1}' (code {0})")] + Server(i32, String), + + #[error("Error parsing rpc response: {0}")] + Parse(String), + + /// Error creating the RPC request, retry might help + #[error("Could not create RPC Param")] + Param(String), + + /// Body error, unlikely to be recoverable by retrying + #[error("{0}")] + Body(String), + + /// HTTP status error, not retryable + #[error("Obtained failure status({0}): {1}")] + Status(String, String), + + /// Error decoding the response, retry might not help + #[error("Malformed Response: {0}")] + MalformedResponse(String), + + /// Connection error, retry might help + #[error("Could not connect: {0}")] + Connection(String), + + /// Timeout error, retry might help + #[error("Timeout")] + Timeout, + + /// Redirect error, not retryable + #[error("HttpRedirect: {0}")] + HttpRedirect(String), + + /// Error building the request, unlikely to be recoverable + #[error("Could not build request: {0}")] + ReqBuilder(String), + + /// Maximum retries exceeded, not retryable + #[error("Max retries {0} exceeded")] + MaxRetriesExceeded(u8), + + /// General request error, retry might help + #[error("Could not create request: {0}")] + Request(String), + + /// Wrong network address + #[error("Network address: {0}")] + WrongNetworkAddress(Network), + + /// Server version is unexpected or incompatible + #[error(transparent)] + UnexpectedServerVersion(#[from] UnexpectedServerVersionError), + + /// Could not sign raw transaction + #[error(transparent)] + Sign(#[from] SignRawTransactionWithWalletError), + + /// Could not get a [`Xpriv`](bitcoin::bip32::Xpriv) from the wallet + #[error("Could not get xpriv from wallet")] + Xpriv, + + /// Unknown error, unlikely to be recoverable + #[error("{0}")] + Other(String), +} + +impl ClientError { + pub fn is_tx_not_found(&self) -> bool { + matches!(self, Self::Server(-5, _)) + } + + pub fn is_block_not_found(&self) -> bool { + matches!(self, Self::Server(-5, _)) + } + + pub fn is_missing_or_invalid_input(&self) -> bool { + matches!(self, Self::Server(-26, _)) || matches!(self, Self::Server(-25, _)) + } +} + +impl From for ClientError { + fn from(value: SerdeJsonError) -> Self { + Self::Parse(format!("Could not parse {}", value)) + } +} + +/// `bitcoind` RPC server error. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BitcoinRpcError { + pub code: i32, + pub message: String, +} + +impl fmt::Display for BitcoinRpcError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "RPC error {}: {}", self.code, self.message) + } +} + +impl From for ClientError { + fn from(value: BitcoinRpcError) -> Self { + Self::Server(value.code, value.message) + } +} + +// FIXME: proper types and translations. +impl From for ClientError { + fn from(value: esplora_client::Error) -> Self { + match value { + esplora_client::Error::Minreq(e) => Self::Request(e.to_string()), + esplora_client::Error::Reqwest(e) => Self::Request(e.to_string()), + esplora_client::Error::HttpResponse { status, message } => { + Self::Status(status.to_string(), message) + } + esplora_client::Error::Parsing(e) => Self::MalformedResponse(e.to_string()), + esplora_client::Error::StatusCode(e) => Self::MalformedResponse(e.to_string()), + esplora_client::Error::BitcoinEncoding(e) => Self::MalformedResponse(e.to_string()), + esplora_client::Error::HexToArray(e) => Self::MalformedResponse(e.to_string()), + esplora_client::Error::HexToBytes(e) => Self::MalformedResponse(e.to_string()), + esplora_client::Error::TransactionNotFound(txid) => { + Self::Other(format!("Transaction not found: {txid}")) + } + esplora_client::Error::HeaderHeightNotFound(height) => { + Self::Other(format!("Header height not found: {height}")) + } + esplora_client::Error::HeaderHashNotFound(hash) => { + Self::Other(format!("Header hash not found: {hash}")) + } + esplora_client::Error::InvalidHttpHeaderName(header) => { + Self::Other(format!("Invalid HTTP header name: {header}")) + } + esplora_client::Error::InvalidHttpHeaderValue(header) => { + Self::Other(format!("Invalid HTTP header name: {header}")) + } + } + } +} + +/// Error returned when signing a raw transaction with a wallet fails. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SignRawTransactionWithWalletError { + /// The transaction ID. + txid: String, + /// The index of the input. + vout: u32, + /// The script signature. + #[serde(rename = "scriptSig")] + script_sig: String, + /// The sequence number. + sequence: u32, + /// The error message. + error: String, +} + +impl fmt::Display for SignRawTransactionWithWalletError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "error signing raw transaction with wallet: {}", + self.error + ) + } +} + +/// Error returned when RPC client expects a different version than bitcoind reports. +#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UnexpectedServerVersionError { + /// Version from server. + pub got: usize, + /// Expected server version. + pub expected: Vec, +} + +impl fmt::Display for UnexpectedServerVersionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut expected = String::new(); + for version in &self.expected { + let v = format!(" {} ", version); + expected.push_str(&v); + } + write!( + f, + "unexpected bitcoind version, got: {} expected one of: {}", + self.got, expected + ) + } +} diff --git a/crates/btcio/src/esplora.rs b/crates/btcio/src/esplora.rs new file mode 100644 index 0000000..6f6cfde --- /dev/null +++ b/crates/btcio/src/esplora.rs @@ -0,0 +1,313 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use bitcoin::{block::Header, Block, BlockHash, Network, Transaction, Txid}; +use esplora_client::{r#async::AsyncClient, Builder}; +use tracing::*; + +use crate::{ + error::{ClientError, ClientResult}, + traits::{Broadcaster, Reader}, + types::{GetBlockchainInfo, TestMempoolAccept}, + BLOCK_TIME, +}; + +pub struct EsploraClient { + pub(crate) client: AsyncClient, + #[allow(dead_code)] // We might need this later + pub(crate) base_url: String, + pub(crate) network: Network, +} + +impl EsploraClient { + pub fn new(base_url: String, network: Network) -> Self { + trace!(%base_url, "creating esplora client"); + let client = Builder::new(&base_url) + .build_async() + .expect("failed to build client"); + Self { + client, + base_url, + network, + } + } +} + +#[async_trait] +impl Reader for EsploraClient { + async fn estimate_smart_fee(&self, conf_target: u16) -> ClientResult { + let fee: HashMap = self.client.get_fee_estimates().await?; + + // If we have the exact fee rate, return it + if let Some(&fee_rate) = fee.get(&conf_target) { + return Ok(fee_rate.round() as u64); + } + + // Otherwise, search for the nearest both higher and lower + let mut lower: Option<(u16, f64)> = None; + let mut higher: Option<(u16, f64)> = None; + + for (&target, &rate) in &fee { + if target < conf_target { + if lower.is_none() || target > lower.unwrap().0 { + lower = Some((target, rate)); + } + } else if target > conf_target && (higher.is_none() || target < higher.unwrap().0) { + higher = Some((target, rate)); + } + } + + // FIXME: this is ugly AF. + match (lower, higher) { + (Some((_, lower_rate)), Some((_, higher_rate))) => { + // Average the lower and higher rates + Ok(((lower_rate + higher_rate) / 2.0).round() as u64) + } + (Some((_, lower_rate)), None) => Ok(lower_rate.round() as u64), + (None, Some((_, higher_rate))) => Ok(higher_rate.round() as u64), + (None, None) => Err(ClientError::Other("No fee estimates available".to_string())), + } + } + + async fn get_block(&self, hash: &BlockHash) -> ClientResult { + let result = self.client.get_block_by_hash(hash).await?; + if let Some(block) = result { + Ok(block) + } else { + Err(ClientError::Other(format!("Block not found: {hash}"))) + } + } + + async fn get_block_at(&self, height: u32) -> ClientResult { + let result = self.client.get_blocks(Some(height)).await?; + if let Some(summary) = result.first() { + // get the first one from the vec + let block_hash = summary.id; + let block = self.get_block(&block_hash).await?; + Ok(block) + } else { + Err(ClientError::Other(format!("Block not found: {height}"))) + } + } + + async fn get_block_count(&self) -> ClientResult { + Ok(self.client.get_height().await?) + } + + async fn get_block_hash(&self, height: u32) -> ClientResult { + Ok(self.client.get_block_hash(height).await?) + } + + // NOTE: I don't know if this is possible in esplora. + async fn get_blockchain_info(&self) -> ClientResult { + unimplemented!() + } + + async fn get_superblock( + &self, + start_time: u32, + end_time: u32, + block_time: Option, + ) -> ClientResult
{ + if start_time >= end_time { + return Err(ClientError::Other("Invalid time range".to_string())); + } + + if end_time > self.get_current_timestamp().await? { + return Err(ClientError::Other("End time is in the future".to_string())); + } + + let block_time = block_time.unwrap_or(BLOCK_TIME); + // inclusive range that's why we add 1. + let n_blocks = ((end_time - start_time) / block_time) + 1; + + // iterate over the chaintip and get the blocks, while trying to be clever + // in order to minimize the number of requests. + let mut blocks_to_include = Vec::with_capacity(n_blocks as usize); + let chain_tip = self.get_block_count().await?; + let current_time = self.get_current_timestamp().await?; + + // Finding the last block with a timestamp less than the end_time + // using 2 * block_time as leeway + let delta_with_leeway = current_time + .checked_sub(end_time) + .and_then(|delta| delta.checked_add(2 * block_time)) + .ok_or(ClientError::Other( + "Overflow occurred in delta_with_leeway calculation".to_string(), + ))?; + + // Finding the potential last block + let potential_last_block_height = chain_tip - delta_with_leeway / block_time; + let mut last_block = { + let hash = self.get_block_hash(potential_last_block_height).await?; + self.get_block(&hash).await? + }; + while last_block.header.time > end_time { + let hash = last_block.header.prev_blockhash; + last_block = self.get_block(&hash).await?; + if last_block.header.time < start_time { + return Err(ClientError::Other("No block found".to_string())); + } + } + + // Found the last block + blocks_to_include.push(last_block.header); // Only include the header + + // Now, continue going backwards until we find the first block + let mut first_block = last_block.clone(); + while first_block.header.time > start_time { + let hash = first_block.header.prev_blockhash; + first_block = self.get_block(&hash).await?; + // Since we are iterating backwards, let's add'em to the blocks_to_include + blocks_to_include.push(first_block.header); // Only include the header + if first_block.header.time < start_time { + return Err(ClientError::Other("No block found".to_string())); + } + } + + // We have all the block headers, let's return the one with the lowest hash. + blocks_to_include + .iter() + .min_by(|a, b| a.block_hash().cmp(&b.block_hash())) + .copied() + .ok_or(ClientError::Other("No block found".to_string())) + } + + async fn get_current_timestamp(&self) -> ClientResult { + let best_block_hash = self.client.get_tip_hash().await?; + let block = self.get_block(&best_block_hash).await?; + Ok(block.header.time) + } + + // NOTE: I don't know if this is possible in esplora. + async fn get_raw_mempool(&self) -> ClientResult> { + unimplemented!() + } + + async fn network(&self) -> ClientResult { + Ok(self.network) + } +} + +#[async_trait] +impl Broadcaster for EsploraClient { + async fn send_raw_transaction(&self, tx: &Transaction) -> ClientResult { + let txid = tx.compute_txid(); + trace!(?tx, "Sending raw transaction"); + debug!(%txid, "Broadcasting transaction"); + let _ = self.client.broadcast(tx).await?; + Ok(txid) + } + + // NOTE: I don't know if this is possible in esplora. + async fn test_mempool_accept(&self, _tx: &Transaction) -> ClientResult> { + unimplemented!() + } +} + +mod tests { + #[allow(unused_imports)] // Don't know why this is flagging unused + use std::str::FromStr; + + use tokio::{ + test, + time::{sleep, Duration}, + }; + + use super::*; + + #[allow(dead_code)] // Don't know why this is flagging unused + const BASE_URL: &str = "https://mempool.space/testnet4/api"; + #[allow(dead_code)] // Don't know why this is flagging unused + const NETWORK: Network = Network::Testnet; + + #[allow(dead_code)] // Don't know why this is flagging unused + async fn create_client() -> EsploraClient { + sleep(Duration::from_millis(500)).await; // To avoid spamming the esplora API + EsploraClient::new(BASE_URL.to_string(), NETWORK) + } + + #[test] + async fn estimate_smart_fee() { + let client = create_client().await; + + // estimate_smart_fee + let got = client.estimate_smart_fee(1).await.unwrap(); + assert!(got >= 1); + } + + #[test] + async fn get_block() { + let client = create_client().await; + + // block 1337 (EASTER EGG SPOTTED) + let hash = + BlockHash::from_str("0000000028617006aab774b7d36fbe28c960aade7db34294ac2f24c5f68eef0a") + .unwrap(); + let got = client + .get_block(&hash) + .await + .unwrap() + .bip34_block_height() + .unwrap(); + let expected = 1337; + assert_eq!(got, expected); + } + + #[test] + async fn get_block_at() { + let client = create_client().await; + + // block 1337 (EASTER EGG SPOTTED) + let got = client.get_block_at(1337).await.unwrap().header.block_hash(); + let expected = + BlockHash::from_str("0000000028617006aab774b7d36fbe28c960aade7db34294ac2f24c5f68eef0a") + .unwrap(); + assert_eq!(got, expected); + } + + #[test] + async fn get_block_count() { + let client = create_client().await; + + let got = client.get_block_count().await.unwrap(); + assert!(got >= 52742); // 2024-10-29 + } + + #[test] + async fn get_block_hash() { + let client = create_client().await; + + // block 1337 (EASTER EGG SPOTTED) + let got = client.get_block_hash(1337).await.unwrap(); + let expected = + BlockHash::from_str("0000000028617006aab774b7d36fbe28c960aade7db34294ac2f24c5f68eef0a") + .unwrap(); + assert_eq!(got, expected); + } + + // #[test] + #[allow(unused)] // FIXME: remove when this test is enabled and works in CI; fails due to rate limit + async fn get_superblock() { + let client = create_client().await; + + // This is pointing towards testnet4, hence block time is 10 minutes := 600 seconds. + let block_time = 600; + let current_time = client.get_current_timestamp().await.unwrap(); + sleep(Duration::from_millis(500)).await; // To avoid spamming the esplora API + + // To avoid spamming the network, let's get the superblock from 1 hour ago + let start_time = current_time - (3600 * 2); + let end_time = current_time - 3600; + + let got = client + .get_superblock(start_time, end_time, Some(block_time)) + .await + .unwrap(); + + sleep(Duration::from_millis(500)).await; // To avoid spamming the esplora API + let block_hash_previous = client.get_block(&got.prev_blockhash).await.unwrap(); + + assert!(got.block_hash() <= block_hash_previous.header.block_hash()); + } +} diff --git a/crates/btcio/src/lib.rs b/crates/btcio/src/lib.rs new file mode 100644 index 0000000..2805faa --- /dev/null +++ b/crates/btcio/src/lib.rs @@ -0,0 +1,10 @@ +pub mod bitcoind; +pub mod constants; +pub mod error; +pub mod esplora; +pub mod traits; +pub mod types; + +pub use bitcoind::*; +pub use constants::*; +pub use esplora::*; diff --git a/crates/btcio/src/traits.rs b/crates/btcio/src/traits.rs new file mode 100644 index 0000000..dd629a2 --- /dev/null +++ b/crates/btcio/src/traits.rs @@ -0,0 +1,206 @@ +use async_trait::async_trait; +use bitcoin::{bip32::Xpriv, block::Header, Address, Block, BlockHash, Network, Transaction, Txid}; + +use crate::{ + error::ClientResult, + types::{ + GetBlockchainInfo, GetTransaction, ImportDescriptor, ImportDescriptorResult, + ListTransactions, ListUnspent, SignRawTransactionWithWallet, TestMempoolAccept, + }, +}; + +/// Basic functionality that any Bitcoin client that interacts with the +/// Bitcoin network should provide. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](tokio)'s +/// [`spawn_blocking`](tokio::task::spawn_blocking) or any other method. +#[async_trait] +pub trait Reader { + /// Estimates the approximate fee per kilobyte needed for a transaction + /// to begin confirmation within conf_target blocks if possible and return + /// the number of blocks for which the estimate is valid. + /// + /// # Parameters + /// + /// - `conf_target`: Confirmation target in blocks. + /// + /// # Note + /// + /// Uses virtual transaction size as defined in + /// [BIP 141](/~https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki) + /// (witness data is discounted). + /// + /// By default uses the estimate mode of `CONSERVATIVE` which is the + /// default in Bitcoin Core v27. + async fn estimate_smart_fee(&self, conf_target: u16) -> ClientResult; + + /// Gets a [`Block`] with the given hash. + async fn get_block(&self, hash: &BlockHash) -> ClientResult; + + /// Gets a [`Block`] at given height. + async fn get_block_at(&self, height: u32) -> ClientResult; + + /// Gets the height of the most-work fully-validated chain. + /// + /// # Note + /// + /// The genesis block has a height of 0. + async fn get_block_count(&self) -> ClientResult; + + /// Gets the [`BlockHash`] at given height. + async fn get_block_hash(&self, height: u32) -> ClientResult; + + /// Gets various state info regarding blockchain processing. + async fn get_blockchain_info(&self) -> ClientResult; + + /// Gets the header of the heaviest block (lowest hash) within the time range + /// [`start_time`, `end_time`] (inclusive). + /// + /// # Note + /// + /// Time is Unix epoch time in seconds. + async fn get_superblock( + &self, + start_time: u32, + end_time: u32, + block_time: Option, + ) -> ClientResult
; + + /// Gets the timestamp in the block header of the current best block in bitcoin. + /// + /// # Note + /// + /// Time is Unix epoch time in seconds. + async fn get_current_timestamp(&self) -> ClientResult; + + /// Gets all transaction ids in mempool. + async fn get_raw_mempool(&self) -> ClientResult>; + + /// Gets the underlying [`Network`] information. + async fn network(&self) -> ClientResult; +} + +/// Broadcasting functionality that any Bitcoin client that interacts with the +/// Bitcoin network should provide. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](https://tokio.rs)'s +/// [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) +/// or any other method. +#[async_trait] +pub trait Broadcaster { + /// Sends a raw transaction to the network. + /// + /// # Parameters + /// + /// - `tx`: The raw transaction to send. This should be a byte array containing the serialized + /// raw transaction data. + async fn send_raw_transaction(&self, tx: &Transaction) -> ClientResult; + + /// Tests if a raw transaction is valid. + async fn test_mempool_accept(&self, tx: &Transaction) -> ClientResult>; +} + +/// Wallet functionality that any Bitcoin client **without private keys** that +/// interacts with the Bitcoin network should provide. +/// +/// For signing transactions, see [`Signer`]. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](https://tokio.rs)'s +/// [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) +/// or any other method. +#[async_trait] +pub trait Wallet { + /// Generates new address under own control for the underlying Bitcoin + /// client's wallet. + async fn get_new_address(&self) -> ClientResult
; + + /// Gets information related to a transaction. + /// + /// # Note + /// + /// This assumes that the transaction is present in the underlying Bitcoin + /// client's wallet. + async fn get_transaction(&self, txid: &Txid) -> ClientResult; + + /// Gets all Unspent Transaction Outputs (UTXOs) for the underlying Bitcoin + /// client's wallet. + async fn get_utxos(&self) -> ClientResult>; + + /// Lists transactions in the underlying Bitcoin client's wallet. + /// + /// # Parameters + /// + /// - `count`: The number of transactions to list. If `None`, assumes a maximum of 10 + /// transactions. + async fn list_transactions(&self, count: Option) -> ClientResult>; + + /// Lists all wallets in the underlying Bitcoin client. + async fn list_wallets(&self) -> ClientResult>; +} + +/// Signing functionality that any Bitcoin client **with private keys** that +/// interacts with the Bitcoin network should provide. +/// +/// # Note +/// +/// This is a fully `async` trait. The user should be responsible for +/// handling the `async` nature of the trait methods. And if implementing +/// this trait for a specific type that is not `async`, the user should +/// consider wrapping with [`tokio`](https://tokio.rs)'s +/// [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) +/// or any other method. +#[async_trait] +pub trait Signer { + /// Signs a transaction using the keys available in the underlying Bitcoin + /// client's wallet and returns a signed transaction. + /// + /// # Note + /// + /// The returned signed transaction might not be consensus-valid if it + /// requires additional signatures, such as in a multisignature context. + async fn sign_raw_transaction_with_wallet( + &self, + tx: &Transaction, + ) -> ClientResult; + + /// Gets the underlying [`Xpriv`] from the wallet. + async fn get_xpriv(&self) -> ClientResult>; + + /// Imports the descriptors into the wallet. + async fn import_descriptors( + &self, + descriptors: Vec, + wallet_name: String, + ) -> ClientResult>; +} + +/// Generating RPCs for the Bitcoin client. +/// +/// # Note +/// +/// This is only if your `bitcoind` is a miner. +/// Useful for testing. +#[async_trait] +pub trait BlockGenerator { + /// Mine blocks immediately to a specified address + async fn generate_to_address( + &self, + nblocks: u16, + address: &Address, + ) -> ClientResult>; +} diff --git a/crates/btcio/src/types.rs b/crates/btcio/src/types.rs new file mode 100644 index 0000000..22838ef --- /dev/null +++ b/crates/btcio/src/types.rs @@ -0,0 +1,597 @@ +use bitcoin::{ + absolute::Height, + address::{self, NetworkUnchecked}, + consensus::{self, encode}, + Address, Amount, Block, BlockHash, SignedAmount, Transaction, Txid, +}; +use serde::{ + de::{self, IntoDeserializer, Visitor}, + Deserialize, Deserializer, Serialize, +}; +use tracing::*; + +use crate::error::SignRawTransactionWithWalletError; + +/// The category of a transaction. +/// +/// This is one of the results of `listtransactions` RPC method. +/// +/// # Note +/// +/// This is a subset of the categories available in Bitcoin Core. +/// It also assumes that the transactions are present in the underlying Bitcoin +/// client's wallet. +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TransactionCategory { + /// Transactions sent. + Send, + /// Non-coinbase transactions received. + Receive, + /// Coinbase transactions received with more than 100 confirmations. + Generate, + /// Coinbase transactions received with 100 or less confirmations. + Immature, + /// Orphaned coinbase transactions received. + Orphan, +} + +/// Result of JSON-RPC method `getblockchaininfo`. +/// +/// Method call: `getblockchaininfo` +/// +/// > Returns an object containing various state info regarding blockchain processing. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct GetBlockchainInfo { + /// Current network name as defined in BIP70 (main, test, signet, regtest). + pub chain: String, + /// The current number of blocks processed in the server. + pub blocks: u64, + /// The current number of headers we have validated. + pub headers: u64, + /// The hash of the currently best block. + #[serde(rename = "bestblockhash")] + pub best_block_hash: String, + /// The current difficulty. + pub difficulty: f64, + /// Median time for the current best block. + #[serde(rename = "mediantime")] + pub median_time: u64, + /// Estimate of verification progress (between 0 and 1). + #[serde(rename = "verificationprogress")] + pub verification_progress: f64, + /// Estimate of whether this node is in Initial Block Download (IBD) mode. + #[serde(rename = "initialblockdownload")] + pub initial_block_download: bool, + /// Total amount of work in active chain, in hexadecimal. + #[serde(rename = "chainwork")] + pub chain_work: String, + /// The estimated size of the block and undo files on disk. + pub size_on_disk: u64, + /// If the blocks are subject to pruning. + pub pruned: bool, + /// Lowest-height complete block stored (only present if pruning is enabled). + #[serde(rename = "pruneheight")] + pub prune_height: Option, + /// Whether automatic pruning is enabled (only present if pruning is enabled). + pub automatic_pruning: Option, + /// The target size used by pruning (only present if automatic pruning is enabled). + pub prune_target_size: Option, +} + +/// Result of JSON-RPC method `getblock` with verbosity set to 0. +/// +/// A string that is serialized, hex-encoded data for block 'hash'. +/// +/// Method call: `getblock "blockhash" ( verbosity )` +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct GetBlockVerbosityZero(pub String); + +impl GetBlockVerbosityZero { + /// Converts json straight to a [`Block`]. + pub fn block(self) -> Result { + let block: Block = encode::deserialize_hex(&self.0)?; + Ok(block) + } +} + +/// Result of JSON-RPC method `getblock` with verbosity set to 1. +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct GetBlockVerbosityOne { + /// The block hash (same as provided) in RPC call. + pub hash: String, + /// The number of confirmations, or -1 if the block is not on the main chain. + pub confirmations: i32, + /// The block size. + pub size: usize, + /// The block size excluding witness data. + #[serde(rename = "strippedsize")] + pub stripped_size: Option, + /// The block weight as defined in BIP-141. + pub weight: u64, + /// The block height or index. + pub height: usize, + /// The block version. + pub version: i32, + /// The block version formatted in hexadecimal. + #[serde(rename = "versionHex")] + pub version_hex: String, + /// The merkle root + #[serde(rename = "merkleroot")] + pub merkle_root: String, + /// The transaction ids + pub tx: Vec, + /// The block time expressed in UNIX epoch time. + pub time: usize, + /// The median block time expressed in UNIX epoch time. + #[serde(rename = "mediantime")] + pub median_time: Option, + /// The nonce + pub nonce: u32, + /// The bits. + pub bits: String, + /// The difficulty. + pub difficulty: f64, + /// Expected number of hashes required to produce the chain up to this block (in hex). + #[serde(rename = "chainwork")] + pub chain_work: String, + /// The number of transactions in the block. + #[serde(rename = "nTx")] + pub n_tx: u32, + /// The hash of the previous block (if available). + #[serde(rename = "previousblockhash")] + pub previous_block_hash: Option, + /// The hash of the next block (if available). + #[serde(rename = "nextblockhash")] + pub next_block_hash: Option, +} + +/// Result of JSON-RPC method `gettxout`. +/// +/// # Note +/// +/// This assumes that the UTXOs are present in the underlying Bitcoin +/// client's wallet. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct GetTransactionDetail { + pub address: String, + pub category: GetTransactionDetailCategory, + pub amount: f64, + pub label: Option, + pub vout: u32, + pub fee: Option, + pub abandoned: Option, +} + +/// Enum to represent the category of a transaction. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GetTransactionDetailCategory { + Send, + Receive, + Generate, + Immature, + Orphan, +} + +/// Result of the JSON-RPC method `getnewaddress`. +/// +/// # Note +/// +/// This assumes that the UTXOs are present in the underlying Bitcoin +/// client's wallet. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct GetNewAddress(pub String); + +impl GetNewAddress { + /// Converts json straight to a [`Address`]. + pub fn address(self) -> Result, address::ParseError> { + let address = self.0.parse::>()?; + Ok(address) + } +} + +/// Models the result of JSON-RPC method `listunspent`. +/// +/// # Note +/// +/// This assumes that the UTXOs are present in the underlying Bitcoin +/// client's wallet. +/// +/// Careful with the amount field. It is a [`SignedAmount`], hence can be negative. +/// Negative amounts for the [`TransactionCategory::Send`], and is positive +/// for all other categories. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct GetTransaction { + /// The signed amount in BTC. + #[serde(deserialize_with = "deserialize_signed_bitcoin")] + pub amount: SignedAmount, + /// The signed fee in BTC. + pub confirmations: u64, + pub generated: Option, + pub trusted: Option, + pub blockhash: Option, + pub blockheight: Option, + pub blockindex: Option, + pub blocktime: Option, + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, + pub wtxid: String, + pub walletconflicts: Vec, + pub replaced_by_txid: Option, + pub replaces_txid: Option, + pub comment: Option, + pub to: Option, + pub time: u64, + pub timereceived: u64, + #[serde(rename = "bip125-replaceable")] + pub bip125_replaceable: String, + pub details: Vec, + /// The transaction itself. + #[serde(deserialize_with = "deserialize_tx")] + pub hex: Transaction, +} + +impl GetTransaction { + pub fn block_height(&self) -> u64 { + if self.confirmations == 0 { + return 0; + } + self.blockheight.unwrap_or_else(|| { + warn!("Txn confirmed but did not obtain blockheight. Setting height to zero"); + 0 + }) + } +} + +/// Models the result of JSON-RPC method `listunspent`. +/// +/// # Note +/// +/// This assumes that the UTXOs are present in the underlying Bitcoin +/// client's wallet. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ListUnspent { + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, + /// The vout value. + pub vout: u32, + /// The Bitcoin address. + #[serde(deserialize_with = "deserialize_address")] + pub address: Address, + // The associated label, if any. + pub label: Option, + /// The script pubkey. + #[serde(rename = "scriptPubKey")] + pub script_pubkey: String, + /// The transaction output amount in BTC. + #[serde(deserialize_with = "deserialize_bitcoin")] + pub amount: Amount, + /// The number of confirmations. + pub confirmations: u32, + /// Whether we have the private keys to spend this output. + pub spendable: bool, + /// Whether we know how to spend this output, ignoring the lack of keys. + pub solvable: bool, + /// Whether this output is considered safe to spend. + /// Unconfirmed transactions from outside keys and unconfirmed replacement + /// transactions are considered unsafe and are not eligible for spending by + /// `fundrawtransaction` and `sendtoaddress`. + pub safe: bool, +} + +/// Models the result of JSON-RPC method `listtransactions`. +/// +/// # Note +/// +/// This assumes that the transactions are present in the underlying Bitcoin +/// client's wallet. +/// +/// Careful with the amount field. It is a [`SignedAmount`], hence can be negative. +/// Negative amounts for the [`TransactionCategory::Send`], and is positive +/// for all other categories. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct ListTransactions { + /// The Bitcoin address. + #[serde(deserialize_with = "deserialize_address")] + pub address: Address, + /// Category of the transaction. + category: TransactionCategory, + /// The signed amount in BTC. + #[serde(deserialize_with = "deserialize_signed_bitcoin")] + pub amount: SignedAmount, + /// The label associated with the address, if any. + pub label: Option, + /// The number of confirmations. + pub confirmations: u32, + pub trusted: Option, + pub generated: Option, + pub blockhash: Option, + pub blockheight: Option, + pub blockindex: Option, + pub blocktime: Option, + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, +} + +/// Models the result of JSON-RPC method `testmempoolaccept`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct TestMempoolAccept { + /// The transaction id. + #[serde(deserialize_with = "deserialize_txid")] + pub txid: Txid, + /// Rejection reason, if any. + pub reject_reason: Option, +} + +/// Models the result of JSON-RPC method `signrawtransactionwithwallet`. +/// +/// # Note +/// +/// This assumes that the transactions are present in the underlying Bitcoin +/// client's wallet. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct SignRawTransactionWithWallet { + /// The Transaction ID. + pub hex: String, + /// If the transaction has a complete set of signatures. + pub complete: bool, + /// Errors, if any. + pub errors: Option>, +} + +/// Models the result of the JSON-RPC method `listdescriptors`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ListDescriptors { + /// The descriptors + pub descriptors: Vec, +} + +/// Models the Descriptor in the result of the JSON-RPC method `listdescriptors`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ListDescriptor { + /// The descriptor. + pub desc: String, +} + +/// Models the result of the JSON-RPC method `importdescriptors`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ImportDescriptors { + /// The descriptors + pub descriptors: Vec, +} + +/// Models the Descriptor in the result of the JSON-RPC method `importdescriptors`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ImportDescriptor { + /// The descriptor. + pub desc: String, + /// Set this descriptor to be the active descriptor + /// for the corresponding output type/externality. + pub active: Option, + /// Time from which to start rescanning the blockchain for this descriptor, + /// in UNIX epoch time. Can also be a string "now" + pub timestamp: String, +} +/// Models the Descriptor in the result of the JSON-RPC method `importdescriptors`. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ImportDescriptorResult { + /// Result. + pub success: bool, +} + +/// Models the `createwallet` JSON-RPC method. +/// +/// # Note +/// +/// This can also be used for the `loadwallet` JSON-RPC method. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct CreateWallet { + /// Wallet name + pub wallet_name: String, + /// Load on startup + pub load_on_startup: Option, +} + +/// Deserializes the amount in BTC into proper [`Amount`]s. +fn deserialize_bitcoin<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct SatVisitor; + + impl<'d> Visitor<'d> for SatVisitor { + type Value = Amount; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a float representation of btc values expected") + } + + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + let amount = Amount::from_btc(v).expect("Amount deserialization failed"); + Ok(amount) + } + } + deserializer.deserialize_any(SatVisitor) +} + +/// Deserializes the *signed* amount in BTC into proper [`SignedAmount`]s. +fn deserialize_signed_bitcoin<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct SatVisitor; + + impl<'d> Visitor<'d> for SatVisitor { + type Value = SignedAmount; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a float representation of btc values expected") + } + + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + let signed_amount = SignedAmount::from_btc(v).expect("Amount deserialization failed"); + Ok(signed_amount) + } + } + deserializer.deserialize_any(SatVisitor) +} + +#[allow(dead_code)] +fn deserialize_signed_bitcoin_option<'d, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'d>, +{ + let f: Option = Option::deserialize(deserializer)?; + match f { + Some(v) => deserialize_signed_bitcoin(v.into_deserializer()).map(Some), + None => Ok(None), + } +} + +/// Deserializes the transaction id string into proper [`Txid`]s. +fn deserialize_txid<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct TxidVisitor; + + impl<'d> Visitor<'d> for TxidVisitor { + type Value = Txid; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a transaction id string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + let txid = v.parse::().expect("invalid txid"); + + Ok(txid) + } + } + deserializer.deserialize_any(TxidVisitor) +} + +/// Deserializes the transaction hex string into proper [`Transaction`]s. +fn deserialize_tx<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct TxVisitor; + + impl<'d> Visitor<'d> for TxVisitor { + type Value = Transaction; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a transaction hex string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + let tx = consensus::encode::deserialize_hex::(v) + .expect("failed to deserialize tx hex"); + Ok(tx) + } + } + deserializer.deserialize_any(TxVisitor) +} + +/// Deserializes the address string into proper [`Address`]s. +/// +/// # Note +/// +/// The user is responsible for ensuring that the address is valid, +/// since this functions returns an [`Address`]. +fn deserialize_address<'d, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'d>, +{ + struct AddressVisitor; + impl<'d> Visitor<'d> for AddressVisitor { + type Value = Address; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a Bitcoin address string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + let address = v + .parse::>() + .expect("Address deserialization failed"); + Ok(address) + } + } + deserializer.deserialize_any(AddressVisitor) +} + +/// Deserializes the blockhash string into proper [`BlockHash`]s. +#[allow(dead_code)] +fn deserialize_blockhash<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct BlockHashVisitor; + + impl<'d> Visitor<'d> for BlockHashVisitor { + type Value = BlockHash; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a blockhash string expected") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + let blockhash = consensus::encode::deserialize_hex::(v) + .expect("BlockHash deserialization failed"); + Ok(blockhash) + } + } + deserializer.deserialize_any(BlockHashVisitor) +} + +/// Deserializes the height string into proper [`Height`]s. +#[allow(dead_code)] +fn deserialize_height<'d, D>(deserializer: D) -> Result +where + D: Deserializer<'d>, +{ + struct HeightVisitor; + + impl<'d> Visitor<'d> for HeightVisitor { + type Value = Height; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a height u32 string expected") + } + + fn visit_u32(self, v: u32) -> Result + where + E: de::Error, + { + let height = Height::from_consensus(v).expect("Height deserialization failed"); + Ok(height) + } + } + deserializer.deserialize_any(HeightVisitor) +} diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml new file mode 100644 index 0000000..0544f9e --- /dev/null +++ b/crates/test-utils/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "strata-test-utils" +version = "0.1.0" +edition = "2021" + +[dependencies] +strata-common.workspace = true +tempfile = "3.13.0" +tokio.workspace = true +tracing.workspace = true diff --git a/crates/test-utils/src/bitcoind.rs b/crates/test-utils/src/bitcoind.rs new file mode 100644 index 0000000..60be5a0 --- /dev/null +++ b/crates/test-utils/src/bitcoind.rs @@ -0,0 +1,155 @@ +use std::{error::Error, fs, path::PathBuf, process::Command, thread, time::Duration}; + +use tempfile::{tempdir, TempDir}; +use tracing::{info, trace}; + +#[derive(Debug)] +pub struct BitcoinD<'a> { + /// URL of the `bitcoind` RPC server. + pub url: &'a str, + /// Username for the `bitcoind` RPC server. + pub user: &'a str, + /// Password for the `bitcoind` RPC server. + pub password: &'a str, + /// Temporary superdirectory where the `bitcoind` data is stored. + /// + /// Needed for the drop check. + _temp_dir: TempDir, + /// Directory where the `bitcoind` data is stored. + pub data_dir: PathBuf, +} + +impl Default for BitcoinD<'_> { + fn default() -> Self { + let url = "http://127.0.0.1:18443"; + let user = "strata"; + let password = "strata"; + BitcoinD::new(url, user, password).expect("Failed to start bitcoind") + } +} + +impl<'a> BitcoinD<'a> { + /// Creates a new [`BitcoinD`] instance. + pub fn new(url: &'a str, user: &'a str, password: &'a str) -> Result> { + // Creates a temporary data dir + let temp_dir = tempdir()?; + let data_dir = temp_dir.path().join("strata-bitcoind"); + fs::create_dir(&data_dir)?; + info!(?data_dir, "created data directory"); + + let process = Command::new("bitcoind") + .arg("-regtest") + .arg("-daemon") + .arg("-rpcuser=strata") + .arg("-rpcpassword=strata") + .arg(format!("-datadir={}", data_dir.display())) + .arg("-fallbackfee=0.00001") + .spawn()? + .wait_with_output()?; + trace!(?process, "bitcoind started"); + + thread::sleep(Duration::from_millis(100)); + + // wait until the wallet is created + let process = Command::new("bitcoin-cli") + .arg("-regtest") + .arg("-rpcuser=strata") + .arg("-rpcpassword=strata") + .arg(format!("-datadir={}", data_dir.display())) + .arg("createwallet") + .arg("default") + .spawn()? + .wait()?; + info!("wallet created"); + trace!(?process, "wallet created"); + + thread::sleep(Duration::from_millis(100)); + + Ok(BitcoinD { + url, + user, + password, + _temp_dir: temp_dir, + data_dir, + }) + } + + /// Returns the `data_dir` [`PathBuf`]. + pub fn data_dir(&self) -> &PathBuf { + &self.data_dir + } +} + +impl Drop for BitcoinD<'_> { + fn drop(&mut self) { + // Call bitcoin-cli to stop the bitcoind. + let _ = Command::new("bitcoin-cli") + .arg("-regtest") + .arg("-rpcuser=strata") + .arg("-rpcpassword=strata") + .arg(format!("-datadir={}", self.data_dir.display())) + .arg("stop") + .spawn() + .expect("Failed to stop bitcoind") + .wait(); + // Delete the data_dir + fs::remove_dir_all(&self.data_dir).expect("Failed to remove data_dir"); + } +} + +#[cfg(test)] +mod tests { + use strata_common::logging; + use tracing::debug; + + use super::*; + + #[test] + fn test_bitcoind() { + logging::init(); + debug!("Starting bitcoind"); + let bitcoind = BitcoinD::default(); + + let data_dir = bitcoind.data_dir().clone(); + debug!(?data_dir, "Data directory"); + + // Assert that the bitcoind is running + debug!("Checking if bitcoind is running"); + assert!(Command::new("bitcoin-cli") + .arg("-regtest") + .arg("-rpcuser=strata") + .arg("-rpcpassword=strata") + .arg(format!("-datadir={}", data_dir.display())) + .arg("getblockchaininfo") + .output() + .is_ok()); + + // Assert that the data directory is created + debug!("Checking if data directory is created"); + assert!(data_dir.exists()); + } + + #[test] + fn bitcoind_drop_check() { + let bitcoind = BitcoinD::default(); + let data_dir = bitcoind.data_dir().clone(); + drop(bitcoind); + + // Assert that the bitcoind is stopped + let output = Command::new("bitcoin-cli") + .arg("-regtest") + .arg("-rpcuser=strata") + .arg("-rpcpassword=strata") + .arg(format!("-datadir={}", data_dir.display())) + .arg("getblockchaininfo") + .output() + .unwrap(); + + debug!(?output, "Output"); + + assert_eq!(output.status.code(), Some(1)); + + // Check if the data directory is deleted + assert!(!data_dir.exists()); + } +} diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs new file mode 100644 index 0000000..91babaa --- /dev/null +++ b/crates/test-utils/src/lib.rs @@ -0,0 +1,3 @@ +pub mod bitcoind; + +pub use bitcoind::*; diff --git a/crates/tx-graph/Cargo.toml b/crates/tx-graph/Cargo.toml index 929ad98..6641c7c 100644 --- a/crates/tx-graph/Cargo.toml +++ b/crates/tx-graph/Cargo.toml @@ -16,7 +16,9 @@ bitcoin = { workspace = true, features = ["rand-std"] } bitcoin-script = { workspace = true } bitcoin-scriptexec = { workspace = true } bitvm = { workspace = true } -esplora-client.workspace = true +esplora-client = { workspace = true, default-features = false, features = [ + "async-https-rustls", +] } hex = { version = "0.4.3" } lazy_static.workspace = true musig2 = { workspace = true, features = ["serde"] }