diff --git a/Cargo.lock b/Cargo.lock index 7a5fa6a4c..e485aa475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,6 +294,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aws-lc-rs" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae74d9bd0a7530e8afd1770739ad34b36838829d6ad61818f9230f683f5ad77" +dependencies = [ + "aws-lc-sys", + "mirai-annotations", + "paste", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e89b6941c2d1a7045538884d6e760ccfffdf8e1ffc2613d8efa74305e1f3752" +dependencies = [ + "bindgen 0.69.4", + "cc", + "cmake", + "dunce", + "fs_extra", + "libc", + "paste", +] + [[package]] name = "aws-runtime" version = "1.3.0" @@ -502,7 +529,7 @@ dependencies = [ "once_cell", "pin-project-lite", "pin-utils", - "rustls", + "rustls 0.21.12", "tokio", "tracing", ] @@ -758,6 +785,29 @@ dependencies = [ "which", ] +[[package]] +name = "bindgen" +version = "0.69.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +dependencies = [ + "bitflags 2.5.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.65", + "which", +] + [[package]] name = "bitcoin" version = "0.31.2" @@ -1003,6 +1053,11 @@ name = "cc" version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f" +dependencies = [ + "jobserver", + "libc", + "once_cell", +] [[package]] name = "cexpr" @@ -1124,6 +1179,15 @@ dependencies = [ "time 0.2.27", ] +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.1" @@ -1512,6 +1576,12 @@ dependencies = [ "syn 2.0.65", ] +[[package]] +name = "dunce" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" + [[package]] name = "ed25519" version = "2.2.3" @@ -1557,7 +1627,24 @@ dependencies = [ "byteorder", "libc", "log", - "rustls", + "rustls 0.21.12", + "serde 1.0.203", + "serde_json", + "webpki-roots", + "winapi 0.3.9", +] + +[[package]] +name = "electrum-client" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b1f8783238bb18e6e137875b0a66f3dffe6c7ea84066e05d033cf180b150f" +dependencies = [ + "bitcoin 0.32.0", + "byteorder", + "libc", + "log", + "rustls 0.23.12", "serde 1.0.203", "serde_json", "webpki-roots", @@ -1780,6 +1867,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -2282,7 +2375,7 @@ dependencies = [ "http 0.2.12", "hyper 0.14.28", "log", - "rustls", + "rustls 0.21.12", "rustls-native-certs", "tokio", "tokio-rustls", @@ -2538,6 +2631,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.69" @@ -2875,6 +2977,12 @@ dependencies = [ "ws2_32-sys", ] +[[package]] +name = "mirai-annotations" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" + [[package]] name = "mockall" version = "0.10.2" @@ -3223,7 +3331,7 @@ version = "7.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a40a031a559eb38c35a14096f21c366254501a06d41c4b327d2a7515d713a5b7" dependencies = [ - "bindgen", + "bindgen 0.64.0", "bitvec", "bs58 0.4.0", "cc", @@ -4134,10 +4242,25 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.102.6", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.6.3" @@ -4185,6 +4308,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.17" @@ -4211,9 +4346,13 @@ name = "sbtc" version = "0.1.0" dependencies = [ "bitcoin 0.32.0", + "bitcoincore-rpc", "clarity", + "electrum-client 0.20.0", "rand 0.8.5", "secp256k1 0.29.0", + "serde 1.0.203", + "serde_json", "stacks-common", "test-case", "thiserror", @@ -4616,7 +4755,7 @@ dependencies = [ "blocklist-api", "clap", "config 0.14.0", - "electrum-client", + "electrum-client 0.19.0", "fake", "futures", "hashbrown 0.14.5", @@ -4780,7 +4919,7 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", + "rustls 0.21.12", "rustls-pemfile 1.0.4", "serde 1.0.203", "serde_json", @@ -5451,7 +5590,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", "tokio", ] @@ -6398,3 +6537,17 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.65", +] diff --git a/Cargo.toml b/Cargo.toml index 790025cd0..834f7646f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,9 +30,11 @@ backoff = { version = "0.4.0", features = ["tokio"] } base64 = "0.22.1" bincode = "1.3.3" bitcoin = { version = "0.32", features = ["serde"] } +bitcoincore-rpc = { version = "0.19" } bitvec = { version = "1.0", default-features = false } config = "0.11.0" clap = { version = "4.5.4", features = ["derive", "env"] } +electrum-client = { version = "0.20" } futures = "0.3.24" hashbrown = "0.14.5" http = "1.1.0" diff --git a/sbtc/Cargo.toml b/sbtc/Cargo.toml index 0dfa57d09..18c102d56 100644 --- a/sbtc/Cargo.toml +++ b/sbtc/Cargo.toml @@ -5,10 +5,19 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = [] +integration-tests = ["testing"] +testing = [] + [dependencies] bitcoin = { workspace = true, features = ["rand-std"] } +bitcoincore-rpc.workspace = true clarity = { git = "https://github.com/Trust-Machines/stacks-blockchain/", branch = "develop-upstream" } +electrum-client.workspace = true rand.workspace = true +serde.workspace = true +serde_json.workspace = true stacks-common = { git = "https://github.com/Trust-Machines/stacks-blockchain/", branch = "develop-upstream" } thiserror.workspace = true tracing.workspace = true diff --git a/sbtc/src/deposits.rs b/sbtc/src/deposits.rs index de4a3fb4d..1e6b08706 100644 --- a/sbtc/src/deposits.rs +++ b/sbtc/src/deposits.rs @@ -11,13 +11,15 @@ use bitcoin::Network; use bitcoin::OutPoint; use bitcoin::ScriptBuf; use bitcoin::Transaction; -use bitcoin::Txid; use bitcoin::XOnlyPublicKey; use clarity::codec::StacksMessageCodec; use clarity::vm::types::PrincipalData; use secp256k1::SECP256K1; use stacks_common::types::chainstate::STACKS_ADDRESS_ENCODED_SIZE; +use crate::error::Error; +use crate::rpc::BitcoinRpcClient; + /// This is the length of the fixed portion of the deposit script, which /// is: /// ```text @@ -56,50 +58,6 @@ const OP_PUSHNUM_16: u8 = opcodes::OP_PUSHNUM_16.to_u8(); /// Represents the number -1. const OP_PUSHNUM_NEG1: u8 = opcodes::OP_PUSHNUM_NEG1.to_u8(); -/// Errors -#[derive(Debug, thiserror::Error)] -pub enum Error { - /// The deposit script was invalid - #[error("invalid deposit script")] - InvalidDepositScript, - /// The lock time included in the reclaim script was invalid. - #[error("the lock time included in the reclaim script was invalid: {0}")] - InvalidReclaimScriptLockTime(i64), - /// The reclaim script was invalid. - #[error("the reclaim script format was invalid")] - InvalidReclaimScript, - /// The reclaim script lock time was invalid - #[error("reclaim script lock time was either too large or non-minimal: {0}")] - ScriptNum(#[source] bitcoin::script::Error), - /// The X-only public key was invalid - #[error("the x-only public key in the script was invalid: {0}")] - InvalidXOnlyPublicKey(#[source] secp256k1::Error), - /// Could not parse the Stacks principal address. - #[error("could not parse the stacks principal address: {0}")] - ParseStacksAddress(#[source] stacks_common::codec::Error), - /// Failed to parse the hex as a bitcoin::Transaction. - #[error("could not parse the BTC transaction hex: {0}")] - DecodeFromHex(#[source] bitcoin::consensus::encode::FromHexError), - /// Failed to extract the outpoint from the bitcoin::Transaction. - #[error("could not get outpoint {1} from BTC transaction: {0}")] - OutpointIndex( - #[source] bitcoin::blockdata::transaction::OutputsIndexError, - OutPoint, - ), - /// The ScriptPubKey of the UTXO did not match what was expected from - /// the given deposit script and reclaim script. - #[error("mismatch in expected and actual ScriptPubKeys. outpoint: {0}")] - UtxoScriptPubKeyMismatch(OutPoint), - /// Failed to parse the hex as a bitcoin::Transaction. - #[error("could not parse the bitcoin transaction hex")] - TxidMismatch { - /// This is the transaction ID of the actual transaction - from_tx: Txid, - /// This is the transaction ID of from the request - from_request: Txid, - }, -} - /// All the info required to verify the validity of a deposit /// transaction. This info is sent by the user to the Emily API pub struct CreateDepositRequest { @@ -136,10 +94,22 @@ pub struct ParsedDepositRequest { } impl CreateDepositRequest { - /// Validate the deposit request. + /// Validate this deposit request from the transaction. + /// + /// This function fetches the transaction using the given client and + /// checks that the transaction has been confirmed. The transaction + /// need not be confirmed. + pub fn validate(&self, client: &C) -> Result + where + C: BitcoinRpcClient, + { + // Fetch the transaction from either a block or from the mempool + let response = client.get_tx(&self.outpoint.txid)?; + self.validate_tx(&response.tx) + } + /// Validate this deposit request. /// /// This function checks the following - /// * That the provided tx hex is a valid transaction. /// * That the transaction's txid matches the expected txid from the /// request. /// * That the expected UTXO is in the transaction. @@ -147,10 +117,7 @@ impl CreateDepositRequest { /// the expected formats for deposit transactions. /// * That deposit script and the reclaim script are part of the UTXO /// ScriptPubKey. - pub fn validate_tx(&self, tx_hex: &str) -> Result { - let tx: Transaction = - bitcoin::consensus::encode::deserialize_hex(tx_hex).map_err(Error::DecodeFromHex)?; - + pub fn validate_tx(&self, tx: &Transaction) -> Result { if tx.compute_txid() != self.outpoint.txid { // The expectation is that the transaction hex was fetched from // the blockchain using the txid, so in practice this should @@ -509,11 +476,14 @@ fn scriptint_parse(v: &[u8]) -> i64 { #[cfg(test)] mod tests { + use std::collections::HashMap; + use bitcoin::absolute::LockTime; use bitcoin::hashes::Hash as _; use bitcoin::transaction::Version; use bitcoin::AddressType; use bitcoin::Amount; + use bitcoin::Txid; use bitcoin::TxOut; use rand::rngs::OsRng; use secp256k1::SecretKey; @@ -521,11 +491,43 @@ mod tests { use stacks_common::types::chainstate::StacksAddress; use super::*; + use crate::rpc::GetTxResponse; use test_case::test_case; const CONTRACT_ADDRESS: &str = "ST1RQHF4VE5CZ6EK3MZPZVQBA0JVSMM9H5PMHMS1Y.contract-name"; + struct DummyClient(HashMap); + + impl DummyClient { + fn new_from_tx(tx: &Transaction) -> Self { + let mut map = HashMap::new(); + map.insert(tx.compute_txid(), tx.clone()); + Self(map) + } + } + + impl BitcoinRpcClient for DummyClient { + fn get_tx(&self, txid: &Txid) -> Result { + let tx = self + .0 + .get(txid) + .cloned() + .ok_or(Error::GetTransactionElectrum( + electrum_client::Error::CouldntLockReader, + *txid, + ))?; + Ok(GetTxResponse { + tx, + txid: *txid, + block_hash: None, + confirmations: None, + block_time: None, + in_active_chain: None, + }) + } + } + /// A full reclaim script with a p2pk script at the end. fn reclaim_p2pk(lock_time: i64) -> ScriptBuf { ScriptBuf::builder() @@ -742,22 +744,27 @@ mod tests { assert!(ReclaimScriptInputs::parse(&reclaim_script).is_err()); } - #[test] - fn happy_path_tx_validation() { + #[test_case(true ; "use client")] + #[test_case(false ; "no client")] + fn happy_path_tx_validation(use_client: bool) { let max_fee: u64 = 15000; let amount_sats = 500_000; let lock_time = 150; let setup: TxSetup = tx_setup(lock_time, max_fee, amount_sats); - let tx_hex = bitcoin::consensus::encode::serialize_hex(&setup.tx); let request = CreateDepositRequest { outpoint: OutPoint::new(setup.tx.compute_txid(), 0), reclaim_script: setup.reclaim.reclaim_script(), deposit_script: setup.deposit.deposit_script(), }; - let parsed = request.validate_tx(&tx_hex).unwrap(); + let parsed = if use_client { + let client = DummyClient::new_from_tx(&setup.tx); + request.validate(&client).unwrap() + } else { + request.validate_tx(&setup.tx).unwrap() + }; assert_eq!(parsed.outpoint, request.outpoint); assert_eq!(parsed.deposit_script, request.deposit_script); @@ -776,8 +783,6 @@ mod tests { let mut setup: TxSetup = tx_setup(lock_time, max_fee, amount_sats); - let tx_hex = bitcoin::consensus::encode::serialize_hex(&setup.tx); - // Let's modify the max_fee of the deposit script and send that in // the request. setup.deposit.max_fee = 3000; @@ -788,7 +793,7 @@ mod tests { reclaim_script: setup.reclaim.reclaim_script(), }; - let error = request.validate_tx(&tx_hex).unwrap_err(); + let error = request.validate_tx(&setup.tx).unwrap_err(); assert!(matches!(error, Error::UtxoScriptPubKeyMismatch(_))); } @@ -800,8 +805,6 @@ mod tests { let mut setup: TxSetup = tx_setup(lock_time, max_fee, amount_sats); - let tx_hex = bitcoin::consensus::encode::serialize_hex(&setup.tx); - // Let's modify the lock time of the reclaim script to look more // reasonable in the request. setup.reclaim.lock_time = 150; @@ -812,7 +815,7 @@ mod tests { reclaim_script: setup.reclaim.reclaim_script(), }; - let error = request.validate_tx(&tx_hex).unwrap_err(); + let error = request.validate_tx(&setup.tx).unwrap_err(); assert!(matches!(error, Error::UtxoScriptPubKeyMismatch(_))); } @@ -824,8 +827,6 @@ mod tests { let setup: TxSetup = tx_setup(lock_time, max_fee, amount_sats); - let tx_hex = bitcoin::consensus::encode::serialize_hex(&setup.tx); - let request = CreateDepositRequest { // This output index is guaranteed to always be incorrect. outpoint: OutPoint::new(setup.tx.compute_txid(), setup.tx.output.len() as u32), @@ -833,7 +834,7 @@ mod tests { reclaim_script: setup.reclaim.reclaim_script(), }; - let error = request.validate_tx(&tx_hex).unwrap_err(); + let error = request.validate_tx(&setup.tx).unwrap_err(); assert!(matches!(error, Error::OutpointIndex(_, _))); let request = CreateDepositRequest { @@ -843,29 +844,10 @@ mod tests { reclaim_script: setup.reclaim.reclaim_script(), }; - let error = request.validate_tx(&tx_hex).unwrap_err(); + let error = request.validate_tx(&setup.tx).unwrap_err(); assert!(matches!(error, Error::TxidMismatch { .. })); } - #[test] - fn incorrect_tx_hex_rejected() { - let max_fee: u64 = 15000; - let amount_sats = 500_000; - let lock_time = 150; - - let setup: TxSetup = tx_setup(lock_time, max_fee, amount_sats); - - let request = CreateDepositRequest { - // This output index is guaranteed to be incorrect. - outpoint: OutPoint::new(setup.tx.compute_txid(), 0), - deposit_script: setup.deposit.deposit_script(), - reclaim_script: setup.reclaim.reclaim_script(), - }; - - let error = request.validate_tx("abc123").unwrap_err(); - assert!(matches!(error, Error::DecodeFromHex(_))); - } - #[test] fn correct_tx_request_has_invalid_deposit_or_reclaim_script() { let max_fee: u64 = 15000; @@ -874,8 +856,6 @@ mod tests { let setup: TxSetup = tx_setup(lock_time, max_fee, amount_sats); - let tx_hex = bitcoin::consensus::encode::serialize_hex(&setup.tx); - let request = CreateDepositRequest { outpoint: OutPoint::new(setup.tx.compute_txid(), 0), // The actual deposit script in the transaction is fine, but @@ -885,7 +865,7 @@ mod tests { reclaim_script: setup.reclaim.reclaim_script(), }; - let error = request.validate_tx(&tx_hex).unwrap_err(); + let error = request.validate_tx(&setup.tx).unwrap_err(); assert!(matches!(error, Error::InvalidDepositScript)); let request = CreateDepositRequest { @@ -897,7 +877,7 @@ mod tests { reclaim_script: ScriptBuf::new(), }; - let error = request.validate_tx(&tx_hex).unwrap_err(); + let error = request.validate_tx(&setup.tx).unwrap_err(); assert!(matches!(error, Error::InvalidReclaimScript)); } } diff --git a/sbtc/src/error.rs b/sbtc/src/error.rs new file mode 100644 index 000000000..9f8df1f3e --- /dev/null +++ b/sbtc/src/error.rs @@ -0,0 +1,64 @@ +//! Top-level error type for the sbtc library +//! + +use bitcoin::OutPoint; +use bitcoin::Txid; + +/// Errors +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Bitcoin-core RPC error + #[error("{0}")] + BitcoinCoreRpcClient(#[source] bitcoincore_rpc::Error, String), + /// Bitcoin-core RPC error + #[error("{0}")] + DecodeTx(#[source] bitcoin::consensus::encode::Error, Txid), + /// Bitcoin-core RPC error + #[error("{0}")] + DeserializeGetTransaction(#[source] serde_json::Error, Txid), + /// Could not build the electrum client + #[error("{0}")] + ElectrumClientBuild(#[source] electrum_client::Error, String), + /// Bitcoin-core RPC error + #[error("{0}")] + GetTransactionBitcoinCore(#[source] bitcoincore_rpc::Error, Txid), + /// Bitcoin-core RPC error + #[error("{0}")] + GetTransactionElectrum(#[source] electrum_client::Error, Txid), + /// The deposit script was invalid + #[error("invalid deposit script")] + InvalidDepositScript, + /// The lock time included in the reclaim script was invalid. + #[error("the lock time included in the reclaim script was invalid: {0}")] + InvalidReclaimScriptLockTime(i64), + /// The reclaim script was invalid. + #[error("the reclaim script format was invalid")] + InvalidReclaimScript, + /// The reclaim script lock time was invalid + #[error("reclaim script lock time was either too large or non-minimal: {0}")] + ScriptNum(#[source] bitcoin::script::Error), + /// The X-only public key was invalid + #[error("the x-only public key in the script was invalid: {0}")] + InvalidXOnlyPublicKey(#[source] secp256k1::Error), + /// Could not parse the Stacks principal address. + #[error("could not parse the stacks principal address: {0}")] + ParseStacksAddress(#[source] stacks_common::codec::Error), + /// Failed to extract the outpoint from the bitcoin::Transaction. + #[error("could not get outpoint {1} from BTC transaction: {0}")] + OutpointIndex( + #[source] bitcoin::blockdata::transaction::OutputsIndexError, + OutPoint, + ), + /// The ScriptPubKey of the UTXO did not match what was expected from + /// the given deposit script and reclaim script. + #[error("mismatch in expected and actual ScriptPubKeys. outpoint: {0}")] + UtxoScriptPubKeyMismatch(OutPoint), + /// Failed to parse the hex as a bitcoin::Transaction. + #[error("The txid of the transaction did not match the given txid")] + TxidMismatch { + /// This is the transaction ID of the actual transaction + from_tx: Txid, + /// This is the transaction ID of from the request + from_request: Txid, + }, +} diff --git a/sbtc/src/lib.rs b/sbtc/src/lib.rs index f3926b09b..719720698 100644 --- a/sbtc/src/lib.rs +++ b/sbtc/src/lib.rs @@ -8,7 +8,9 @@ use std::sync::OnceLock; use bitcoin::XOnlyPublicKey; pub mod deposits; +pub mod error; pub mod logging; +pub mod rpc; /// The x-coordinate public key with no known discrete logarithm. /// diff --git a/sbtc/src/rpc.rs b/sbtc/src/rpc.rs new file mode 100644 index 000000000..3e1efd95c --- /dev/null +++ b/sbtc/src/rpc.rs @@ -0,0 +1,169 @@ +//! Contains client wrappers for bitcoin core and electrum. + +use std::num::NonZeroU8; + +use bitcoin::consensus; +use bitcoin::consensus::Decodable as _; +use bitcoin::BlockHash; +use bitcoin::Transaction; +use bitcoin::Txid; +use bitcoincore_rpc::Auth; +use bitcoincore_rpc::RpcApi; +use electrum_client::ElectrumApi as _; +use electrum_client::Param; +use serde::Deserialize; + +use crate::error::Error; + +/// A slimmed down type representing a response from bitcoin-core's +/// getrawtransaction RPC. +/// +/// The docs for the getrawtransaction RPC call can be found here: +/// https://bitcoincore.org/en/doc/27.0.0/rpc/rawtransactions/getrawtransaction/. +#[derive(Debug, Clone, Deserialize)] +pub struct GetTxResponse { + /// The raw bitcoin transaction. + #[serde(with = "consensus::serde::With::")] + #[serde(rename = "hex")] + pub tx: Transaction, + /// The transaction ID. + pub txid: Txid, + /// The block hash of the Bitcoin block that includes this transaction. + #[serde(rename = "blockhash")] + pub block_hash: Option, + /// The number of confirmations deep from that chain tip of the bitcoin + /// block that includes this transaction. + pub confirmations: Option, + /// The Unix epoch time when the block was mined. It reflects the + /// timestamp as recorded by the miner of the block. + #[serde(rename = "blocktime")] + pub block_time: Option, + /// Whether the specified block (in the getrawtransaction RPC) is in + /// the active chain or not. It is only present when the "blockhash" + /// argument is present in the RPC. + pub in_active_chain: Option, +} + +/// Trait for interacting with bitcoin-core +pub trait BitcoinRpcClient { + /// Return the transaction if the transaction is in the mempool or in + /// any block. + fn get_tx(&self, txid: &Txid) -> Result; +} + +/// A client for interacting with bitcoin-core +pub struct BtcClient { + /// The underlying bitcoin-core client + inner: bitcoincore_rpc::Client, +} + +impl BtcClient { + /// Return a bitcoin-core RPC client. Will error if the URL is an invalid URL. + /// + /// # Notes + /// + /// This function does not attempt to establish a connection to bitcoin-core. + pub fn new(url: &str, username: String, password: String) -> Result { + let auth = Auth::UserPass(username, password); + let client = bitcoincore_rpc::Client::new(url, auth) + .map_err(|err| Error::BitcoinCoreRpcClient(err, url.to_string()))?; + + Ok(Self { inner: client }) + } + /// Fetch and decode raw transaction from bitcoin-core using the + /// getrawtransaction RPC. + /// + /// # Notes + /// + /// By default, this call only returns a transaction if it is in the + /// mempool. If -txindex is enabled on bitcoin-core and no blockhash + /// argument is passed, it will return the transaction if it is in the + /// mempool or any block. + pub fn get_tx(&self, txid: &Txid) -> Result { + let response = self + .inner + .get_raw_transaction_info(txid, None) + .map_err(|err| Error::GetTransactionBitcoinCore(err, *txid))?; + let tx = Transaction::consensus_decode(&mut response.hex.as_slice()) + .map_err(|err| Error::DecodeTx(err, *txid))?; + + debug_assert_eq!(txid, &response.txid); + + Ok(GetTxResponse { + tx, + txid: response.txid, + block_hash: response.blockhash, + confirmations: response.confirmations, + block_time: response.blocktime.map(|time| time as u64), + in_active_chain: response.in_active_chain, + }) + } +} + +impl BitcoinRpcClient for BtcClient { + fn get_tx(&self, txid: &Txid) -> Result { + self.get_tx(txid) + } +} + +/// A client for interacting with Electrum server +pub struct ElectrumClient { + /// The underlying electrum client + inner: electrum_client::Client, +} + +impl ElectrumClient { + /// Establish a connection to the electrum server and return a client. + /// + /// # Notes + /// + /// * Attempts to establish a connection with the server using the + /// given URL. + /// * The URL must be prefixed with either tcp:// or ssl://. + /// * The electrum-client authors use an u8 for the timeout instead + /// Duration, so we mirror that here. A timeout of zero will error so + /// disallow it. A timeout of None means no timeout. + pub fn new(url: &str, timeout: Option) -> Result { + // The config builder will panic if the timeout is set to zero, so + // we set it to None, which means no timeout. Kind of surprising + // but this is what is usually meant by a timeout of zero anyway. + let config = electrum_client::Config::builder() + .timeout(timeout.map(NonZeroU8::get)) + .retry(2) + .validate_domain(true) + .build(); + // This actually attempts to establish a connection with the server + // and returns and Error otherwise. + let client = electrum_client::Client::from_config(url, config) + .map_err(|err| Error::ElectrumClientBuild(err, url.to_string()))?; + + Ok(Self { inner: client }) + } + /// Fetch and decode raw transaction from the electrum server. + /// + /// # Notes + /// + /// This function uses the `blockchain.transaction.get` Electrum + /// protocol method for the response. That method uses bitcoin-core's + /// getrawtransaction RPC under the hood, but supplies the correct + /// block hash fetched from Electrum server's index. The benefit of + /// using electrum for this is that you do not need to set -txindex = 1 + /// in bitcoin-core, and electrum is (presumably) much more efficient. + pub fn get_tx(&self, txid: &Txid) -> Result { + let params = [Param::String(txid.to_string()), Param::Bool(true)]; + let value = self + .inner + .raw_call("blockchain.transaction.get", params) + .map_err(|err| Error::GetTransactionElectrum(err, *txid))?; + + serde_json::from_value::(value) + .inspect(|response| debug_assert_eq!(txid, &response.txid)) + .map_err(|err| Error::DeserializeGetTransaction(err, *txid)) + } +} + +impl BitcoinRpcClient for ElectrumClient { + fn get_tx(&self, txid: &Txid) -> Result { + self.get_tx(txid) + } +}