diff --git a/stacks-common/src/deps_common/bitcoin/network/serialize.rs b/stacks-common/src/deps_common/bitcoin/network/serialize.rs index 265cad36e8..0e8b22f6e2 100644 --- a/stacks-common/src/deps_common/bitcoin/network/serialize.rs +++ b/stacks-common/src/deps_common/bitcoin/network/serialize.rs @@ -25,7 +25,8 @@ use std::{error, fmt, io}; use crate::address; use crate::deps_common::bitcoin::network::encodable::{ConsensusDecodable, ConsensusEncodable}; use crate::deps_common::bitcoin::util::hash::Sha256dHash; -use crate::util::hash::to_hex as hex_encode; +use crate::util::hash::{hex_bytes, to_hex as hex_encode}; +use crate::util::HexError; /// Serialization error #[derive(Debug)] @@ -67,6 +68,8 @@ pub enum Error { UnrecognizedNetworkCommand(String), /// Unexpected hex digit UnexpectedHexDigit(char), + /// Invalid hex input + InvalidHex(HexError), } impl fmt::Display for Error { @@ -106,6 +109,7 @@ impl fmt::Display for Error { write!(f, "unrecognized network command: {nwcmd}") } Error::UnexpectedHexDigit(ref d) => write!(f, "unexpected hex digit: {d}"), + Error::InvalidHex(ref e) => fmt::Display::fmt(e, f), } } } @@ -123,7 +127,8 @@ impl error::Error for Error { | Error::UnsupportedWitnessVersion(..) | Error::UnsupportedSegwitFlag(..) | Error::UnrecognizedNetworkCommand(..) - | Error::UnexpectedHexDigit(..) => None, + | Error::UnexpectedHexDigit(..) + | Error::InvalidHex(..) => None, } } } @@ -142,6 +147,13 @@ impl From for Error { } } +#[doc(hidden)] +impl From for Error { + fn from(error: HexError) -> Self { + Error::InvalidHex(error) + } +} + /// Objects which are referred to by hash pub trait BitcoinHash { /// Produces a Sha256dHash which can be used to refer to the object @@ -193,6 +205,15 @@ where } } +/// Deserialize an object from a hex-encoded string +pub fn deserialize_hex(data: &str) -> Result +where + for<'a> T: ConsensusDecodable>>, +{ + let bytes = hex_bytes(data)?; + deserialize(&bytes) +} + /// An encoder for raw binary data pub struct RawEncoder { writer: W, diff --git a/stacks-node/src/burnchains/mod.rs b/stacks-node/src/burnchains/mod.rs index 70a335348d..8bca0d1b85 100644 --- a/stacks-node/src/burnchains/mod.rs +++ b/stacks-node/src/burnchains/mod.rs @@ -1,5 +1,6 @@ pub mod bitcoin_regtest_controller; pub mod mocknet_controller; +pub mod rpc; use std::time::Instant; diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs new file mode 100644 index 0000000000..e4c9e644b1 --- /dev/null +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -0,0 +1,684 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Bitcoin RPC client module. +//! +//! This module provides a typed interface for interacting with a Bitcoin Core node via RPC. +//! It includes structures representing RPC request parameters and responses, +//! as well as a client implementation ([`BitcoinRpcClient`]) for common node operations +//! such as creating wallets, listing UTXOs, importing descriptors, generating blocks, and sending transactions. +//! +//! Designed for use with Bitcoin Core versions v0.25.0 and newer + +use std::time::Duration; + +use serde::{Deserialize, Deserializer}; +use serde_json::value::RawValue; +use serde_json::{json, Value}; +use stacks::burnchains::bitcoin::address::BitcoinAddress; +use stacks::burnchains::Txid; +use stacks::config::Config; +use stacks::types::chainstate::BurnchainHeaderHash; +use stacks::types::Address; +use stacks::util::hash::hex_bytes; +use stacks_common::deps_common::bitcoin::blockdata::script::Script; +use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; +use stacks_common::deps_common::bitcoin::network::serialize::{ + serialize_hex, Error as bitcoin_serialize_error, +}; + +use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; + +#[cfg(test)] +pub mod test_utils; + +#[cfg(test)] +mod tests; + +/// Response structure for the `gettransaction` RPC call. +/// +/// Contains metadata about a wallet transaction, currently limited to the confirmation count. +/// +/// # Notes +/// This struct supports a subset of available fields to match current usage. +/// Additional fields can be added in the future as needed. +#[derive(Debug, Clone, Deserialize)] +pub struct GetTransactionResponse { + pub confirmations: u32, +} + +/// Response returned by the `getdescriptorinfo` RPC call. +/// +/// Contains information about a parsed descriptor, including its checksum. +/// +/// # Notes +/// This struct supports a subset of available fields to match current usage. +/// Additional fields can be added in the future as needed. +#[derive(Debug, Clone, Deserialize)] +pub struct DescriptorInfoResponse { + pub checksum: String, +} + +/// Represents the `timestamp` parameter accepted by the `importdescriptors` RPC method. +/// +/// This indicates when the imported descriptor starts being relevant for address tracking. +/// It affects wallet rescanning behavior. +#[derive(Debug, Clone)] +pub enum Timestamp { + /// Tells the wallet to start tracking from the current blockchain time + Now, + /// A Unix timestamp (in seconds) specifying when the wallet should begin scanning. + Time(u64), +} + +/// Serializes [`Timestamp`] to either the string `"now"` or a numeric timestamp, +/// matching the format expected by Bitcoin Core. +impl serde::Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match *self { + Timestamp::Now => serializer.serialize_str("now"), + Timestamp::Time(timestamp) => serializer.serialize_u64(timestamp), + } + } +} + +/// Represents a single descriptor import request for use with the `importdescriptors` RPC method. +/// +/// This struct defines a descriptor to import into the loaded wallet, +/// along with metadata that influences how the wallet handles it (e.g., scan time, internal/external). +/// +/// # Notes +/// This struct supports a subset of available fields to match current usage. +/// Additional fields can be added in the future as needed. +#[derive(Debug, Clone, Serialize)] +pub struct ImportDescriptorsRequest { + /// A descriptor string (e.g., `addr(...)#checksum`) with a valid checksum suffix. + #[serde(rename = "desc")] + pub descriptor: String, + /// Specifies when the wallet should begin tracking addresses from this descriptor. + pub timestamp: Timestamp, + /// Optional flag indicating whether the descriptor is used for change addresses. + #[serde(skip_serializing_if = "Option::is_none")] + pub internal: Option, +} + +/// Response returned by the `importdescriptors` RPC method for each imported descriptor. +/// +/// # Notes +/// This struct supports a subset of available fields to match current usage. +/// Additional fields can be added in the future as needed. +#[derive(Debug, Clone, Deserialize)] +pub struct ImportDescriptorsResponse { + /// whether the descriptor was imported successfully + pub success: bool, + /// Optional list of warnings encountered during the import process + #[serde(default)] + pub warnings: Vec, + /// Optional detailed error information if the import failed for this descriptor + pub error: Option, +} + +/// Represents a single UTXO (unspent transaction output) returned by the `listunspent` RPC method. +/// +/// # Notes +/// This struct supports a subset of available fields to match current usage. +/// Additional fields can be added in the future as needed. +#[derive(Debug, Clone, Deserialize)] +pub struct ListUnspentResponse { + /// The transaction ID of the UTXO. + #[serde(deserialize_with = "deserialize_string_to_txid")] + pub txid: Txid, + /// The index of the output in the transaction. + pub vout: u32, + /// The Bitcoin destination address + #[serde(deserialize_with = "deserialize_string_to_bitcoin_address")] + pub address: BitcoinAddress, + /// The script associated with the output. + #[serde( + rename = "scriptPubKey", + deserialize_with = "deserialize_string_to_script" + )] + pub script_pub_key: Script, + /// The amount in BTC, deserialized as a string to preserve full precision. + #[serde(deserialize_with = "deserialize_btc_string_to_sat")] + pub amount: u64, + /// The number of confirmations for the transaction. + pub confirmations: u32, +} + +/// Deserializes a JSON string (hex-encoded in big-endian order) into [`Txid`], +/// storing bytes in little-endian order +fn deserialize_string_to_txid<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let hex_str: String = Deserialize::deserialize(deserializer)?; + let txid = Txid::from_bitcoin_hex(&hex_str).map_err(serde::de::Error::custom)?; + Ok(txid) +} + +/// Deserializes a JSON string into [`BitcoinAddress`] +fn deserialize_string_to_bitcoin_address<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let addr_str: String = Deserialize::deserialize(deserializer)?; + BitcoinAddress::from_string(&addr_str).ok_or(serde::de::Error::custom( + "BitcoinAddress failed to create from string", + )) +} + +/// Deserializes a JSON string into [`Script`] +fn deserialize_string_to_script<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let string: String = Deserialize::deserialize(deserializer)?; + let bytes = hex_bytes(&string) + .map_err(|e| serde::de::Error::custom(format!("invalid hex string for script: {e}")))?; + Ok(bytes.into()) +} + +/// Deserializes a raw JSON value containing a BTC amount string into satoshis (`u64`). +/// +/// First captures the value as unprocessed JSON to preserve exact formatting (e.g., float precision), +/// then convert the BTC string to its integer value in satoshis using [`convert_btc_string_to_sat`]. +fn deserialize_btc_string_to_sat<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let raw: Box = Deserialize::deserialize(deserializer)?; + let raw_str = raw.get(); + let sat_amount = convert_btc_string_to_sat(raw_str).map_err(serde::de::Error::custom)?; + Ok(sat_amount) +} + +/// Converts a BTC amount string (e.g. "1.12345678") into satoshis (u64). +/// +/// # Arguments +/// * `amount` - A string slice containing the BTC amount in decimal notation. +/// Expected format: `.` with up to 8 decimal places. +/// Examples: "1.00000000", "0.00012345", "0.5", "1". +/// +/// # Returns +/// On success return the equivalent amount in satoshis (as u64). +fn convert_btc_string_to_sat(amount: &str) -> Result { + const BTC_TO_SAT: u64 = 100_000_000; + const MAX_DECIMAL_COUNT: usize = 8; + let comps: Vec<&str> = amount.split('.').collect(); + match comps[..] { + [lhs, rhs] => { + let rhs_len = rhs.len(); + if rhs_len > MAX_DECIMAL_COUNT { + return Err(format!("Unexpected amount of decimals ({rhs_len}) in '{amount}'")); + } + + match (lhs.parse::(), rhs.parse::()) { + (Ok(integer), Ok(decimal)) => { + let mut sat_amount = integer * BTC_TO_SAT; + let base: u64 = 10; + let sat = decimal * base.pow((MAX_DECIMAL_COUNT - rhs.len()) as u32); + sat_amount += sat; + Ok(sat_amount) + } + (lhs, rhs) => { + return Err(format!("Cannot convert BTC '{amount}' to sat integer: {lhs:?} - fractional: {rhs:?}")); + } + } + }, + [lhs] => match lhs.parse::() { + Ok(btc) => Ok(btc * BTC_TO_SAT), + Err(_) => Err(format!("Cannot convert BTC '{amount}' integer part to sat: '{lhs}'")), + }, + + _ => Err(format!("Invalid BTC amount format: '{amount}'. Expected '.' with up to 8 decimals.")), + } +} + +/// Converts a satoshi amount (u64) into a BTC string with exactly 8 decimal places. +/// +/// # Arguments +/// * `amount` - The amount in satoshis. +/// +/// # Returns +/// * A `String` representing the BTC value in the format `.`, +/// always padded to 8 decimal places (e.g. "1.00000000", "0.50000000"). +fn convert_sat_to_btc_string(amount: u64) -> String { + let base: u64 = 10; + let int_part = amount / base.pow(8); + let frac_part = amount % base.pow(8); + let amount = format!("{int_part}.{frac_part:08}"); + amount +} + +/// Represents an error message returned when importing descriptors fails. +#[derive(Debug, Clone, Deserialize)] +pub struct ImportDescriptorsErrorMessage { + /// Numeric error code identifying the type of error. + pub code: i64, + /// Human-readable description of the error. + pub message: String, +} + +/// Response for `generatetoaddress` rpc, mainly used as deserialization wrapper for `BurnchainHeaderHash` +struct GenerateToAddressResponse(pub Vec); + +/// Deserializes a JSON string array into a vec of [`BurnchainHeaderHash`] and wrap it into [`GenerateToAddressResponse`] +impl<'de> Deserialize<'de> for GenerateToAddressResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let hash_strs: Vec = Deserialize::deserialize(deserializer)?; + let mut hashes = Vec::with_capacity(hash_strs.len()); + for (i, s) in hash_strs.into_iter().enumerate() { + let hash = BurnchainHeaderHash::from_hex(&s).map_err(|e| { + serde::de::Error::custom(format!( + "Invalid BurnchainHeaderHash at index {}: {}", + i, e + )) + })?; + hashes.push(hash); + } + + Ok(GenerateToAddressResponse(hashes)) + } +} + +/// Response mainly used as deserialization wrapper for [`Txid`] +struct TxidWrapperResponse(pub Txid); + +/// Deserializes a JSON string (hex-encoded, big-endian) into [`Txid`] and wrap it into [`TxidWrapperResponse`] +impl<'de> Deserialize<'de> for TxidWrapperResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let hex_str: String = Deserialize::deserialize(deserializer)?; + let txid = Txid::from_bitcoin_hex(&hex_str).map_err(serde::de::Error::custom)?; + Ok(TxidWrapperResponse(txid)) + } +} + +/// Response mainly used as deserialization wrapper for [`BurnchainHeaderHash`] +struct BurnchainHeaderHashWrapperResponse(pub BurnchainHeaderHash); + +/// Deserializes a JSON string (hex-encoded, big-endian) into [`BurnchainHeaderHash`], +/// and wrap it into [`BurnchainHeaderHashWrapperResponse`] +impl<'de> Deserialize<'de> for BurnchainHeaderHashWrapperResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let hex_str: String = Deserialize::deserialize(deserializer)?; + let bhh = BurnchainHeaderHash::from_hex(&hex_str).map_err(serde::de::Error::custom)?; + Ok(BurnchainHeaderHashWrapperResponse(bhh)) + } +} + +/// Client for interacting with a Bitcoin RPC service. +#[derive(Debug)] +pub struct BitcoinRpcClient { + /// The client ID to identify the source of the requests. + client_id: String, + /// RPC endpoint used for global calls + global_ep: RpcTransport, + /// RPC endpoint used for wallet-specific calls + wallet_ep: RpcTransport, +} + +/// Represents errors that can occur when using [`BitcoinRpcClient`]. +#[derive(Debug, thiserror::Error)] +pub enum BitcoinRpcClientError { + // Missing credential error + #[error("Missing credential error")] + MissingCredentials, + // RPC Transport errors + #[error("Rcp error: {0}")] + Rpc(#[from] RpcError), + // JSON serialization errors + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + // Bitcoin serialization errors + #[error("Bitcoin Serialization error: {0}")] + BitcoinSerialization(#[from] bitcoin_serialize_error), +} + +/// Alias for results returned from client operations. +pub type BitcoinRpcClientResult = Result; + +impl BitcoinRpcClient { + /// Create a [`BitcoinRpcClient`] from Stacks Configuration, mainly using [`stacks::config::BurnchainConfig`] + pub fn from_stx_config(config: &Config) -> BitcoinRpcClientResult { + let host = config.burnchain.peer_host.clone(); + let port = config.burnchain.rpc_port; + let username_opt = &config.burnchain.username; + let password_opt = &config.burnchain.password; + let wallet_name = config.burnchain.wallet_name.clone(); + let timeout = config.burnchain.timeout; + let client_id = "stacks".to_string(); + + let rpc_auth = match (username_opt, password_opt) { + (Some(username), Some(password)) => RpcAuth::Basic { + username: username.clone(), + password: password.clone(), + }, + _ => return Err(BitcoinRpcClientError::MissingCredentials), + }; + + Self::new(host, port, rpc_auth, wallet_name, timeout, client_id) + } + + /// Creates a new instance of the Bitcoin RPC client with both global and wallet-specific endpoints. + /// + /// # Arguments + /// + /// * `host` - Hostname or IP address of the Bitcoin RPC server (e.g., `localhost`). + /// * `port` - Port number the RPC server is listening on. + /// * `auth` - RPC authentication credentials (`RpcAuth::None` or `RpcAuth::Basic`). + /// * `wallet_name` - Name of the wallet to target for wallet-specific RPC calls. + /// * `timeout` - Timeout for RPC requests, in seconds. + /// * `client_id` - Identifier used in the `id` field of JSON-RPC requests for traceability. + /// + /// # Returns + /// + /// A [`BitcoinRpcClient`] on success, or a [`BitcoinRpcClientError`] otherwise. + pub fn new( + host: String, + port: u16, + auth: RpcAuth, + wallet_name: String, + timeout: u32, + client_id: String, + ) -> BitcoinRpcClientResult { + let rpc_global_path = format!("http://{host}:{port}"); + let rpc_wallet_path = format!("{rpc_global_path}/wallet/{wallet_name}"); + let rpc_auth = auth; + + let rpc_timeout = Duration::from_secs(u64::from(timeout)); + + let global_ep = + RpcTransport::new(rpc_global_path, rpc_auth.clone(), Some(rpc_timeout.clone()))?; + let wallet_ep = RpcTransport::new(rpc_wallet_path, rpc_auth, Some(rpc_timeout))?; + + Ok(Self { + client_id, + global_ep, + wallet_ep, + }) + } + + /// Creates and loads a new wallet into the Bitcoin Core node. + /// + /// Wallet is stored in the `-walletdir` specified in the Bitcoin Core configuration (or the default data directory if not set). + /// + /// # Arguments + /// * `wallet_name` - Name of the wallet to create. + /// * `disable_private_keys` - If `Some(true)`, the wallet will not be able to hold private keys. + /// If `None`, this defaults to `false`, allowing private key import/use. + /// + /// # Returns + /// Returns `Ok(())` if the wallet is created successfully. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.17.0**. + /// + /// # Notes + /// This method supports a subset of available RPC arguments to match current usage. + /// Additional parameters can be added in the future as needed. + pub fn create_wallet( + &self, + wallet_name: &str, + disable_private_keys: Option, + ) -> BitcoinRpcClientResult<()> { + let disable_private_keys = disable_private_keys.unwrap_or(false); + + self.global_ep.send::( + &self.client_id, + "createwallet", + vec![wallet_name.into(), disable_private_keys.into()], + )?; + Ok(()) + } + + /// Returns a list of currently loaded wallets by the Bitcoin Core node. + /// + /// # Returns + /// A vector of wallet names as strings. + /// + /// # Availability + /// Available since Bitcoin Core **v0.15.0**. + pub fn list_wallets(&self) -> BitcoinRpcClientResult> { + Ok(self + .global_ep + .send(&self.client_id, "listwallets", vec![])?) + } + + /// Retrieve a list of unspent transaction outputs (UTXOs) that meet the specified criteria. + /// + /// # Arguments + /// * `min_confirmations` - Minimum number of confirmations required for a UTXO to be included (Default: 0). + /// * `max_confirmations` - Maximum number of confirmations allowed (Default: 9.999.999). + /// * `addresses` - Optional list of addresses to filter UTXOs by (Default: no filtering). + /// * `include_unsafe` - Whether to include UTXOs from unconfirmed unsafe transactions (Default: `true`). + /// * `minimum_amount` - Minimum amount in satoshis (internally converted to BTC string to preserve full precision) a UTXO must have to be included (Default: 0). + /// * `maximum_count` - Maximum number of UTXOs to return. Use `None` for effectively 'unlimited' (Default: 0). + /// + /// # Returns + /// A Vec<[`ListUnspentResponse`]> containing the matching UTXOs. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.7.0**. + /// + /// # Notes + /// This method supports a subset of available RPC arguments to match current usage. + /// Additional parameters can be added in the future as needed. + pub fn list_unspent( + &self, + min_confirmations: Option, + max_confirmations: Option, + addresses: Option<&[&BitcoinAddress]>, + include_unsafe: Option, + minimum_amount: Option, + maximum_count: Option, + ) -> BitcoinRpcClientResult> { + let min_confirmations = min_confirmations.unwrap_or(0); + let max_confirmations = max_confirmations.unwrap_or(9_999_999); + let addresses = addresses.unwrap_or(&[]); + let include_unsafe = include_unsafe.unwrap_or(true); + let minimum_amount = minimum_amount.unwrap_or(0); + let maximum_count = maximum_count.unwrap_or(0); + + let addr_as_strings: Vec = addresses.iter().map(|addr| addr.to_string()).collect(); + let min_amount_btc_str = convert_sat_to_btc_string(minimum_amount); + + Ok(self.wallet_ep.send( + &self.client_id, + "listunspent", + vec![ + min_confirmations.into(), + max_confirmations.into(), + addr_as_strings.into(), + include_unsafe.into(), + json!({ + "minimumAmount": min_amount_btc_str, + "maximumCount": maximum_count + }), + ], + )?) + } + + /// Mines a specified number of blocks and sends the block rewards to a given address. + /// + /// # Arguments + /// * `num_block` - The number of blocks to mine. + /// * `address` - The [`BitcoinAddress`] to receive the block rewards. + /// + /// # Returns + /// A vector of [`BurnchainHeaderHash`] corresponding to the newly generated blocks. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.17.0**. + /// + /// # Notes + /// Typically used on `regtest` or test networks. + pub fn generate_to_address( + &self, + num_block: u64, + address: &BitcoinAddress, + ) -> BitcoinRpcClientResult> { + let response = self.global_ep.send::( + &self.client_id, + "generatetoaddress", + vec![num_block.into(), address.to_string().into()], + )?; + Ok(response.0) + } + + /// Retrieves detailed information about an in-wallet transaction. + /// + /// This method returns information such as amount, fee, confirmations, block hash, + /// hex-encoded transaction, and other metadata for a transaction tracked by the wallet. + /// + /// # Arguments + /// * `txid` - The transaction ID (as [`Txid`]) to query, + /// which is intended to be created with [`Txid::from_bitcoin_hex`], + /// or an analogous process. + /// + /// # Returns + /// A [`GetTransactionResponse`] containing detailed metadata for the specified transaction. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.10.0**. + pub fn get_transaction(&self, txid: &Txid) -> BitcoinRpcClientResult { + let btc_txid = txid.to_bitcoin_hex(); + + Ok(self + .wallet_ep + .send(&self.client_id, "gettransaction", vec![btc_txid.into()])?) + } + + /// Broadcasts a raw transaction to the Bitcoin network. + /// + /// This method sends a hex-encoded raw Bitcoin transaction. It supports optional limits for the + /// maximum fee rate and maximum burn amount to prevent accidental overspending. + /// + /// # Arguments + /// + /// * `tx` - A [`Transaction`], that will be hex-encoded, representing the raw transaction. + /// * `max_fee_rate` - Optional maximum fee rate (in BTC/kvB). If `None`, defaults to `0.10` BTC/kvB. + /// - Bitcoin Core will reject transactions exceeding this rate unless explicitly overridden. + /// - Set to `0.0` to disable fee rate limiting entirely. + /// * `max_burn_amount` - Optional maximum amount (in satoshis) that can be "burned" in the transaction. + /// - Introduced in Bitcoin Core v25 (https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-25.0.md#rpc-and-other-apis) + /// - If `None`, defaults to `0`, meaning burning is not allowed. + /// + /// # Returns + /// A [`Txid`] as a transaction ID (storing internally bytes in **little-endian** order) + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.7.0**. + /// - `maxburnamount` parameter is available starting from **v25.0**. + pub fn send_raw_transaction( + &self, + tx: &Transaction, + max_fee_rate: Option, + max_burn_amount: Option, + ) -> BitcoinRpcClientResult { + const DEFAULT_FEE_RATE_BTC_KVB: f64 = 0.10; + let tx_hex = serialize_hex(tx)?; + let max_fee_rate = max_fee_rate.unwrap_or(DEFAULT_FEE_RATE_BTC_KVB); + let max_burn_amount = max_burn_amount.unwrap_or(0); + + let response = self.global_ep.send::( + &self.client_id, + "sendrawtransaction", + vec![tx_hex.into(), max_fee_rate.into(), max_burn_amount.into()], + )?; + Ok(response.0) + } + + /// Returns information about a descriptor, including its checksum. + /// + /// # Arguments + /// * `descriptor` - The descriptor string to analyze. + /// + /// # Returns + /// A [`DescriptorInfoResponse`] containing parsed descriptor information such as the checksum. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.18.0**. + pub fn get_descriptor_info( + &self, + descriptor: &str, + ) -> BitcoinRpcClientResult { + Ok(self.global_ep.send( + &self.client_id, + "getdescriptorinfo", + vec![descriptor.into()], + )?) + } + + /// Imports one or more descriptors into the currently loaded wallet. + /// + /// # Arguments + /// * `descriptors` – A slice of [`ImportDescriptorsRequest`] items. Each item defines a single + /// descriptor and optional metadata for how it should be imported. + /// + /// # Returns + /// A vector of [`ImportDescriptorsResponse`] results, one for each descriptor import attempt. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.21.0**. + pub fn import_descriptors( + &self, + descriptors: &[&ImportDescriptorsRequest], + ) -> BitcoinRpcClientResult> { + let descriptor_values = descriptors + .iter() + .map(serde_json::to_value) + .collect::, _>>()?; + + Ok(self.global_ep.send( + &self.client_id, + "importdescriptors", + vec![descriptor_values.into()], + )?) + } + + /// Returns the hash of the block at the given height. + /// + /// # Arguments + /// * `height` - The height (block number) of the block whose hash is requested. + /// + /// # Returns + /// A [`BurnchainHeaderHash`] representing the block hash. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.9.0**. + pub fn get_block_hash(&self, height: u64) -> BitcoinRpcClientResult { + let response = self.global_ep.send::( + &self.client_id, + "getblockhash", + vec![height.into()], + )?; + Ok(response.0) + } +} diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs new file mode 100644 index 0000000000..af9cca9c66 --- /dev/null +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -0,0 +1,287 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Test-only utilities for [`BitcoinRpcClient`] + +use serde::{Deserialize, Deserializer}; +use serde_json::Value; +use stacks::burnchains::bitcoin::address::BitcoinAddress; +use stacks::burnchains::bitcoin::BitcoinNetworkType; +use stacks::burnchains::Txid; +use stacks::types::chainstate::BurnchainHeaderHash; +use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; +use stacks_common::deps_common::bitcoin::network::serialize::deserialize_hex; + +use crate::burnchains::rpc::bitcoin_rpc_client::{ + deserialize_string_to_bitcoin_address, BitcoinRpcClient, BitcoinRpcClientResult, + TxidWrapperResponse, +}; + +/// Represents the response returned by the `getblockchaininfo` RPC call. +/// +/// # Notes +/// This struct supports a subset of available fields to match current usage. +/// Additional fields can be added in the future as needed. +#[derive(Debug, Clone, Deserialize)] +pub struct GetBlockChainInfoResponse { + /// the network name + #[serde(deserialize_with = "deserialize_string_to_network_type")] + pub chain: BitcoinNetworkType, + /// the height of the most-work fully-validated chain. The genesis block has height 0 + pub blocks: u64, + /// the current number of headers that have been validated + pub headers: u64, + /// the hash of the currently best block + #[serde( + rename = "bestblockhash", + deserialize_with = "deserialize_string_to_burn_header_hash" + )] + pub best_block_hash: BurnchainHeaderHash, +} + +/// Deserializes a JSON string into [`BitcoinNetworkType`] +fn deserialize_string_to_network_type<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let string: String = Deserialize::deserialize(deserializer)?; + match string.as_str() { + "main" => Ok(BitcoinNetworkType::Mainnet), + "test" => Ok(BitcoinNetworkType::Testnet), + "regtest" => Ok(BitcoinNetworkType::Regtest), + other => Err(serde::de::Error::custom(format!( + "invalid network type: {other}" + ))), + } +} + +/// Represents the response returned by the `generateblock` RPC call. +#[derive(Debug, Clone, Deserialize)] +struct GenerateBlockResponse { + /// The hash of the generated block + #[serde(deserialize_with = "deserialize_string_to_burn_header_hash")] + hash: BurnchainHeaderHash, +} + +/// Deserializes a JSON string into [`BurnchainHeaderHash`] +fn deserialize_string_to_burn_header_hash<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let string: String = Deserialize::deserialize(deserializer)?; + BurnchainHeaderHash::from_hex(&string).map_err(serde::de::Error::custom) +} + +/// Represents supported Bitcoin address types. +#[derive(Debug, Clone)] +pub enum AddressType { + /// Legacy P2PKH address + Legacy, + /// P2SH-wrapped SegWit address + P2shSegwit, + /// Native SegWit address + Bech32, + /// Native SegWit v1+ address + Bech32m, +} + +impl ToString for AddressType { + fn to_string(&self) -> String { + match self { + AddressType::Legacy => "legacy", + AddressType::P2shSegwit => "p2sh-segwit", + AddressType::Bech32 => "bech32", + AddressType::Bech32m => "bech32m", + } + .to_string() + } +} + +/// Response for `getnewaddress` rpc, mainly used as deserialization wrapper for `BitcoinAddress` +struct GetNewAddressResponse(pub BitcoinAddress); + +/// Deserializes a JSON string into [`BitcoinAddress`] and wrap it into [`GetNewAddressResponse`] +impl<'de> Deserialize<'de> for GetNewAddressResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_string_to_bitcoin_address(deserializer).map(GetNewAddressResponse) + } +} + +impl BitcoinRpcClient { + /// Retrieve general information about the current state of the blockchain. + /// + /// # Arguments + /// None. + /// + /// # Returns + /// A [`GetBlockChainInfoResponse`] struct containing blockchain metadata. + pub fn get_blockchain_info(&self) -> BitcoinRpcClientResult { + Ok(self + .global_ep + .send(&self.client_id, "getblockchaininfo", vec![])?) + } + + /// Retrieves and deserializes a raw Bitcoin transaction by its ID. + /// + /// # Arguments + /// * `txid` - Transaction ID to fetch, which is intended to be created with [`Txid::from_bitcoin_hex`], + /// or an analogous process. + /// + /// # Returns + /// A [`Transaction`] struct representing the decoded transaction. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.7.0**. + pub fn get_raw_transaction(&self, txid: &Txid) -> BitcoinRpcClientResult { + let btc_txid = txid.to_bitcoin_hex(); + + let raw_hex = self.global_ep.send::( + &self.client_id, + "getrawtransaction", + vec![btc_txid.to_string().into()], + )?; + Ok(deserialize_hex(&raw_hex)?) + } + + /// Mines a new block including the given transactions to a specified address. + /// + /// # Arguments + /// * `address` - A [`BitcoinAddress`] to which the block subsidy will be paid. + /// * `txs` - List of transactions to include in the block. Each entry can be: + /// - A raw hex-encoded transaction + /// - A transaction ID (must be present in the mempool) + /// If the list is empty, an empty block (with only the coinbase transaction) will be generated. + /// + /// # Returns + /// A [`BurnchainHeaderHash`] struct containing the block hash of the newly generated block. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v22.0**. + /// - Requires `regtest` or similar testing networks. + pub fn generate_block( + &self, + address: &BitcoinAddress, + txs: &[&str], + ) -> BitcoinRpcClientResult { + let response = self.global_ep.send::( + &self.client_id, + "generateblock", + vec![address.to_string().into(), txs.into()], + )?; + Ok(response.hash) + } + + /// Gracefully shuts down the Bitcoin Core node. + /// + /// Sends the shutdown command to safely terminate `bitcoind`. This ensures all state is written + /// to disk and the node exits cleanly. + /// + /// # Returns + /// On success, returns the string: `"Bitcoin Core stopping"` + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. + pub fn stop(&self) -> BitcoinRpcClientResult { + Ok(self.global_ep.send(&self.client_id, "stop", vec![])?) + } + + /// Retrieves a new Bitcoin address from the wallet. + /// + /// # Arguments + /// * `label` - Optional label to associate with the address. + /// * `address_type` - Optional [`AddressType`] variant to specify the type of address. + /// If `None`, the address type defaults to the node’s `-addresstype` setting. + /// If `-addresstype` is also unset, the default is `"bech32"` (since v0.20.0). + /// + /// # Returns + /// A [`BitcoinAddress`] variant representing the newly generated Bitcoin address. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. + /// - `address_type` parameter supported since **v0.17.0**. + /// - Defaulting to `bech32` (when unset) introduced in **v0.20.0**. + pub fn get_new_address( + &self, + label: Option<&str>, + address_type: Option, + ) -> BitcoinRpcClientResult { + let mut params = vec![]; + + let label = label.unwrap_or(""); + params.push(label.into()); + + if let Some(at) = address_type { + params.push(at.to_string().into()); + } + + let response = self.global_ep.send::( + &self.client_id, + "getnewaddress", + params, + )?; + + Ok(response.0) + } + + /// Sends a specified amount of BTC to a given address. + /// + /// # Arguments + /// * `address` - The destination Bitcoin address as a [`BitcoinAddress`]. + /// * `amount` - Amount to send in BTC (not in satoshis). + /// + /// # Returns + /// A [`Txid`] struct representing the transaction ID (storing internally bytes in **little-endian** order) + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. + pub fn send_to_address( + &self, + address: &BitcoinAddress, + amount: f64, + ) -> BitcoinRpcClientResult { + let response = self.wallet_ep.send::( + &self.client_id, + "sendtoaddress", + vec![address.to_string().into(), amount.into()], + )?; + Ok(response.0) + } + + /// Invalidate a block by its block hash, forcing the node to reconsider its chain state. + /// + /// # Arguments + /// * `hash` - The block hash (as [`BurnchainHeaderHash`]) of the block to invalidate. + /// + /// # Returns + /// An empty `()` on success. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. + pub fn invalidate_block(&self, hash: &BurnchainHeaderHash) -> BitcoinRpcClientResult<()> { + self.global_ep.send::( + &self.client_id, + "invalidateblock", + vec![hash.to_hex().into()], + )?; + Ok(()) + } +} diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs new file mode 100644 index 0000000000..4c3cc691b7 --- /dev/null +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -0,0 +1,1022 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Unit Tests for [`BitcoinRpcClient`] + +use serde_json::json; +use stacks::burnchains::bitcoin::address::BitcoinAddress; +use stacks::burnchains::bitcoin::BitcoinNetworkType; +use stacks::burnchains::Txid; +use stacks::types::chainstate::BurnchainHeaderHash; +use stacks::types::Address; +use stacks_common::deps_common::bitcoin::network::serialize::{deserialize_hex, serialize_hex}; + +use super::*; + +mod utils { + + use super::*; + + pub const BITCOIN_ADDRESS_LEGACY_STR: &str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; + pub const BITCOIN_TX1_TXID_HEX: &str = + "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + pub const BITCOIN_TX1_RAW_HEX: &str = "0100000001b1f2f67426d26301f0b20467e9fdd93557cb3cbbcb8d79f3a9c7b6c8ec7f69e8000000006a47304402206369d5eb2b7c99f540f4cf3ff2fd6f4b90f89c4328bfa0b6db0c30bb7f2c3d4c022015a1c0e5f6a0b08c271b2d218e6a7a29f5441dbe39d9a5cbcc223221ad5dbb59012103a34e84c8c7ebc8ecb7c2e59ff6672f392c792fc1c4f3c6fa2e7d3d314f1f38c9ffffffff0200e1f505000000001976a9144621d7f4ce0c956c80e6f0c1b9f78fe0c49cb82088ac80fae9c7000000001976a91488ac1f0f01c2a5c2e8f4b4f1a3b1a04d2f35b4c488ac00000000"; + pub const BITCOIN_BLOCK_HASH: &str = + "0000000000000000011f5b3c4e7e9f4dc2c88f0b6c3a3b17e5a7d0dfeb3bb3cd"; + pub const BITCOIN_UTXO_SCRIPT_HEX: &str = "76a914e450fe826cb8f7a2efed518c7b22c47515abdd5388ac"; + + pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { + let url = server.url(); + let parsed = url::Url::parse(&url).unwrap(); + + BitcoinRpcClient::new( + parsed.host_str().unwrap().to_string(), + parsed.port_or_known_default().unwrap(), + RpcAuth::None, + "mywallet".into(), + 30, + "stacks".to_string(), + ) + .expect("Rpc Client creation should be ok!") + } +} + +#[test] +fn test_get_blockchain_info_ok_for_regtest() { + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getblockchaininfo", + "params": [] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "chain": "regtest", + "blocks": 1, + "headers": 2, + "bestblockhash": expected_block_hash + }, + "error": null + }); + + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let info = client + .get_blockchain_info() + .expect("get info should be ok!"); + + assert_eq!(BitcoinNetworkType::Regtest, info.chain); + assert_eq!(1, info.blocks); + assert_eq!(2, info.headers); + assert_eq!(expected_block_hash, info.best_block_hash.to_hex()); +} + +#[test] +fn test_get_blockchain_info_ok_for_testnet() { + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getblockchaininfo", + "params": [] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "chain": "test", + "blocks": 1, + "headers": 2, + "bestblockhash": expected_block_hash + }, + "error": null + }); + + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let info = client + .get_blockchain_info() + .expect("get info should be ok!"); + + assert_eq!(BitcoinNetworkType::Testnet, info.chain); + assert_eq!(1, info.blocks); + assert_eq!(2, info.headers); + assert_eq!(expected_block_hash, info.best_block_hash.to_hex()); +} + +#[test] +fn test_get_blockchain_info_fails_for_unknown_network() { + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getblockchaininfo", + "params": [] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "chain": "unknown", + "blocks": 1, + "headers": 2, + "bestblockhash": expected_block_hash + }, + "error": null + }); + + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let error = client + .get_blockchain_info() + .expect_err("get info should fail!"); + + assert!(matches!( + error, + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) + )); +} + +#[test] +fn test_get_blockchain_info_ok_for_mainnet_network() { + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getblockchaininfo", + "params": [] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "chain": "main", + "blocks": 1, + "headers": 2, + "bestblockhash": expected_block_hash + }, + "error": null + }); + + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let info = client + .get_blockchain_info() + .expect("get info should be ok!"); + + assert_eq!(BitcoinNetworkType::Mainnet, info.chain); + assert_eq!(1, info.blocks); + assert_eq!(2, info.headers); + assert_eq!(expected_block_hash, info.best_block_hash.to_hex()); +} + +#[test] +fn test_create_wallet_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "createwallet", + "params": ["testwallet", true] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "name": "testwallet", + "warning": null + }, + "error": null + }); + + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + client + .create_wallet("testwallet", Some(true)) + .expect("create wallet should be ok!"); +} + +#[test] +fn test_list_wallets_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listwallets", + "params": [] + }); + + let mock_response = json!({ + "id": "stacks", + "result": ["wallet1", "wallet2"], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let result = client.list_wallets().expect("Should list wallets"); + + assert_eq!(2, result.len()); + assert_eq!("wallet1", result[0]); + assert_eq!("wallet2", result[1]); +} + +#[test] +fn test_list_unspent_ok() { + let expected_txid_str = utils::BITCOIN_TX1_TXID_HEX; + let expected_script_hex = utils::BITCOIN_UTXO_SCRIPT_HEX; + let expected_address = utils::BITCOIN_ADDRESS_LEGACY_STR; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listunspent", + "params": [ + 1, + 10, + [utils::BITCOIN_ADDRESS_LEGACY_STR], + true, + { + "minimumAmount": "0.00001000", + "maximumCount": 5 + } + ] + }); + + let mock_response = json!({ + "id": "stacks", + "result": [{ + "txid": expected_txid_str, + "vout": 0, + "address": expected_address, + "scriptPubKey": expected_script_hex, + "amount": 0.00001, + "confirmations": 6 + }], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let addr = BitcoinAddress::from_string(utils::BITCOIN_ADDRESS_LEGACY_STR).unwrap(); + + let result = client + .list_unspent( + Some(1), + Some(10), + Some(&[&addr]), + Some(true), + Some(1_000), // 1000 sats = 0.00001000 BTC + Some(5), + ) + .expect("Should parse unspent outputs"); + + assert_eq!(1, result.len()); + let utxo = &result[0]; + assert_eq!(1_000, utxo.amount); + assert_eq!(0, utxo.vout); + assert_eq!(expected_address, utxo.address.to_string()); + assert_eq!(6, utxo.confirmations); + assert_eq!(expected_txid_str, utxo.txid.to_bitcoin_hex(),); + assert_eq!(expected_script_hex, format!("{:x}", utxo.script_pub_key),); +} + +#[test] +fn test_generate_to_address_ok() { + let num_blocks = 1; + let addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generatetoaddress", + "params": [num_blocks, addr_str], + }); + + let mock_response = json!({ + "id": "stacks", + "result": [ + expected_block_hash, + ], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let address = BitcoinAddress::from_string(addr_str).unwrap(); + let result = client + .generate_to_address(num_blocks, &address) + .expect("Should work!"); + assert_eq!(1, result.len()); + assert_eq!(expected_block_hash, result[0].to_hex()); +} + +#[test] +fn test_generate_to_address_fails_for_invalid_block_hash() { + let num_blocks = 2; + let addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + let expected_block_hash_invalid = "invalid_hash"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generatetoaddress", + "params": [num_blocks, addr_str], + }); + + let mock_response = json!({ + "id": "stacks", + "result": [ + expected_block_hash, + expected_block_hash_invalid, + ], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let address = BitcoinAddress::from_string(addr_str).unwrap(); + let error = client + .generate_to_address(num_blocks, &address) + .expect_err("Should fail!"); + assert!(matches!( + error, + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) + )); +} + +#[test] +fn test_get_transaction_ok() { + let txid_hex = utils::BITCOIN_TX1_TXID_HEX; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "gettransaction", + "params": [txid_hex] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "confirmations": 6, + }, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let txid = Txid::from_bitcoin_hex(&txid_hex).unwrap(); + let info = client.get_transaction(&txid).expect("Should be ok!"); + assert_eq!(6, info.confirmations); +} + +#[test] +fn test_get_raw_transaction_ok() { + let txid_hex = utils::BITCOIN_TX1_TXID_HEX; + let expected_tx_hex = utils::BITCOIN_TX1_RAW_HEX; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getrawtransaction", + "params": [txid_hex] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_tx_hex, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let txid = Txid::from_bitcoin_hex(txid_hex).unwrap(); + let raw_tx = client.get_raw_transaction(&txid).expect("Should be ok!"); + assert_eq!(txid_hex, raw_tx.txid().to_string()); + assert_eq!(expected_tx_hex, serialize_hex(&raw_tx).unwrap()); +} + +#[test] +fn test_generate_block_ok() { + let legacy_addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; + let txid1 = "txid1"; + let txid2 = "txid2"; + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generateblock", + "params": [legacy_addr_str, [txid1, txid2]] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "hash" : expected_block_hash + }, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let addr = BitcoinAddress::from_string(legacy_addr_str).expect("valid address!"); + let result = client + .generate_block(&addr, &[txid1, txid2]) + .expect("Should be ok!"); + assert_eq!(expected_block_hash, result.to_hex()); +} + +#[test] +fn test_generate_block_fails_for_invalid_block_hash() { + let legacy_addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; + let txid1 = "txid1"; + let txid2 = "txid2"; + let expected_block_hash = "invalid_block_hash"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generateblock", + "params": [legacy_addr_str, [txid1, txid2]] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "hash" : expected_block_hash + }, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let addr = BitcoinAddress::from_string(legacy_addr_str).expect("valid address!"); + let error = client + .generate_block(&addr, &[txid1, txid2]) + .expect_err("Should fail!"); + assert!(matches!( + error, + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) + )); +} + +#[test] +fn test_send_raw_transaction_ok_with_defaults() { + let raw_tx_hex = utils::BITCOIN_TX1_RAW_HEX; + let expected_txid = utils::BITCOIN_TX1_TXID_HEX; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx_hex, 0.10, 0] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_txid, + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let raw_tx = deserialize_hex(&raw_tx_hex).unwrap(); + let txid = client + .send_raw_transaction(&raw_tx, None, None) + .expect("Should work!"); + assert_eq!(expected_txid, txid.to_bitcoin_hex()); +} + +#[test] +fn test_send_raw_transaction_ok_with_custom_params() { + let raw_tx_hex = utils::BITCOIN_TX1_RAW_HEX; + let expected_txid = utils::BITCOIN_TX1_TXID_HEX; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx_hex, 0.0, 5_000] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_txid, + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let raw_tx = deserialize_hex(raw_tx_hex).unwrap(); + let txid = client + .send_raw_transaction(&raw_tx, Some(0.0), Some(5_000)) + .expect("Should work!"); + assert_eq!(expected_txid, txid.to_bitcoin_hex()); +} + +#[test] +fn test_get_descriptor_info_ok() { + let descriptor = format!("addr(bc1_address)"); + let expected_checksum = "mychecksum"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getdescriptorinfo", + "params": [descriptor] + }); + + let mock_response = json!({ + "id": "stacks", + "result": { + "checksum": expected_checksum + }, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let info = client + .get_descriptor_info(&descriptor) + .expect("Should work!"); + assert_eq!(expected_checksum, info.checksum); +} + +#[test] +fn test_import_descriptors_ok() { + let descriptor = "addr(1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa)#checksum"; + let timestamp = 0; + let internal = true; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "importdescriptors", + "params": [ + [ + { + "desc": descriptor, + "timestamp": 0, + "internal": true + } + ] + ] + }); + + let mock_response = json!({ + "id": "stacks", + "result": [{ + "success": true, + "warnings": [] + }], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let desc_req = ImportDescriptorsRequest { + descriptor: descriptor.to_string(), + timestamp: Timestamp::Time(timestamp), + internal: Some(internal), + }; + let result = client.import_descriptors(&[&desc_req]); + assert!(result.is_ok()); +} + +#[test] +fn test_stop_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "stop", + "params": [] + }); + + let mock_response = json!({ + "id": "stacks", + "result": "Bitcoin Core stopping", + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + let result = client.stop().expect("Should work!"); + assert_eq!("Bitcoin Core stopping", result); +} + +#[test] +fn test_get_new_address_ok() { + let expected_address = utils::BITCOIN_ADDRESS_LEGACY_STR; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getnewaddress", + "params": [""] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_address, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let address = client.get_new_address(None, None).expect("Should be ok!"); + assert_eq!(expected_address, address.to_string()); +} + +#[test] +fn test_get_new_address_fails_for_invalid_address() { + let expected_address = "invalid_address"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getnewaddress", + "params": [""] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_address, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let error = client + .get_new_address(None, None) + .expect_err("Should fail!"); + assert!(matches!( + error, + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) + )) +} + +#[test] +fn test_send_to_address_ok() { + let address_str = utils::BITCOIN_ADDRESS_LEGACY_STR; + let amount = 0.5; + let expected_txid_str = utils::BITCOIN_TX1_TXID_HEX; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendtoaddress", + "params": [address_str, amount] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_txid_str, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let address = BitcoinAddress::from_string(&address_str).unwrap(); + let txid = client + .send_to_address(&address, amount) + .expect("Should be ok!"); + assert_eq!(expected_txid_str, txid.to_bitcoin_hex()); +} + +#[test] +fn test_send_to_address_fails_for_invalid_tx_id() { + let address_str = utils::BITCOIN_ADDRESS_LEGACY_STR; + let amount = 0.5; + let expected_txid_str = "invalid_tx_id"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendtoaddress", + "params": [address_str, amount] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_txid_str, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let address = BitcoinAddress::from_string(&address_str).unwrap(); + let error = client + .send_to_address(&address, amount) + .expect_err("Should fail!"); + assert!(matches!( + error, + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) + )); +} + +#[test] +fn test_invalidate_block_ok() { + let hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "invalidateblock", + "params": [hash] + }); + + let mock_response = json!({ + "id": "stacks", + "result": null, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let bhh = BurnchainHeaderHash::from_hex(&hash).unwrap(); + client.invalidate_block(&bhh).expect("Should be ok!"); +} + +#[test] +fn test_get_block_hash_ok() { + let height = 1; + let expected_hash = utils::BITCOIN_BLOCK_HASH; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getblockhash", + "params": [height] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_hash, + "error": null, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(mock_response.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let bhh = client.get_block_hash(height).expect("Should be ok!"); + assert_eq!(expected_hash, bhh.to_hex()); +} + +#[test] +pub fn test_convert_btc_to_sat() { + use convert_btc_string_to_sat as to_sat; + + // Valid conversions + assert_eq!(100_000_000, to_sat("1.0").unwrap(), "BTC 1.0 ok!"); + assert_eq!( + 100_000_000, + to_sat("1.00000000").unwrap(), + "BTC 1.00000000 ok!" + ); + assert_eq!(100_000_000, to_sat("1").unwrap(), "BTC 1 ok!"); + assert_eq!(50_000_000, to_sat("0.500").unwrap(), "BTC 0.500 ok!"); + assert_eq!(1, to_sat("0.00000001").unwrap(), "BTC 0.00000001 ok!"); + + // Invalid conversions + to_sat("0.123456789").expect_err("BTC 0.123456789 fails: decimals > 8"); + to_sat("NAN.1").expect_err("BTC NAN.1 fails: integer part is not a number"); + to_sat("1.NAN").expect_err("BTC 1.NAN fails: decimal part is not a number"); + to_sat("1.23.45").expect_err("BTC 1.23.45 fails: dots > 1"); +} + +#[test] +pub fn test_convert_sat_to_btc() { + use convert_sat_to_btc_string as to_btc; + + assert_eq!("1.00000000", to_btc(100_000_000), "SAT 100_000_000 ok!"); + assert_eq!("0.50000000", to_btc(50_000_000), "SAT 50_000_000 ok!"); + assert_eq!("0.00000001", to_btc(1), "SAT 1 ok!"); +} diff --git a/stacks-node/src/burnchains/rpc/mod.rs b/stacks-node/src/burnchains/rpc/mod.rs new file mode 100644 index 0000000000..53a0957ffc --- /dev/null +++ b/stacks-node/src/burnchains/rpc/mod.rs @@ -0,0 +1,17 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pub mod bitcoin_rpc_client; +pub mod rpc_transport; diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs new file mode 100644 index 0000000000..e8462b189f --- /dev/null +++ b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs @@ -0,0 +1,251 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! A simple JSON-RPC transport client using [`StacksHttpRequest`] for HTTP communication. +//! +//! This module provides a wrapper around basic JSON-RPC interactions with support +//! for configurable authentication and timeouts. It serializes requests and parses +//! responses while exposing error types for network, parsing, and service-level issues. + +use std::io; +use std::time::Duration; + +use base64::encode; +use serde::Deserialize; +use serde_json::Value; +use stacks::net::http::{HttpRequestContents, HttpResponsePayload}; +use stacks::net::httpcore::{send_http_request, StacksHttpRequest}; +use stacks::types::net::PeerHost; +use url::Url; + +#[cfg(test)] +mod tests; + +/// The JSON-RPC protocol version used in all requests. +/// Latest specification is `2.0` +const RPC_VERSION: &str = "2.0"; + +/// Represents a JSON-RPC request payload sent to the server. +#[derive(Serialize)] +struct JsonRpcRequest { + /// JSON-RPC protocol version. + jsonrpc: String, + /// Unique identifier for the request. + id: String, + /// Name of the RPC method to invoke. + method: String, + /// Parameters to be passed to the RPC method. + params: serde_json::Value, +} + +/// Represents a JSON-RPC response payload received from the server. +#[derive(Deserialize, Debug)] +struct JsonRpcResponse { + /// ID matching the original request. + id: String, + /// Result returned from the RPC method, if successful. + result: Option, + /// Error object returned by the RPC server, if the call failed. + error: Option, +} + +/// Represents the JSON-RPC response error received from the endpoint +#[derive(Deserialize, Debug, thiserror::Error)] +#[error("JsonRpcError code {code}: {message}")] +pub struct JsonRpcError { + /// error code + code: i32, + /// human-readable error message + message: String, + /// data can be any JSON value or omitted + data: Option, +} + +/// Represents a JSON-RPC error encountered during a transport operation. +#[derive(Debug, thiserror::Error)] +pub enum RpcError { + // Serde decoding error + #[error("JSON decoding error: {0}")] + DecodeJson(serde_json::Error), + // Serde encoding error + #[error("JSON encoding error: {0}")] + EncodeJson(serde_json::Error), + /// Indicates that the response doesn't contain a json payload + #[error("Invalid JSON payload error")] + InvalidJsonPayload, + // RPC Id mismatch between request and response + #[error("Id Mismatch! Request: {0}, Response: {1}")] + MismatchedId(String, String), + // Stacks common network error + #[error("Stacks Net error: {0}")] + NetworkStacksCommon(#[from] stacks_common::types::net::Error), + // Stacks network lib error + #[error("IO error: {0}")] + NetworkIO(#[from] io::Error), + // Stacks lib network error + #[error("Stacks Net error: {0}")] + NetworkStacksLib(#[from] stacks::net::Error), + /// Represents an error returned by the RPC service itself. + #[error("Service JSON error: {0}")] + Service(JsonRpcError), + // URL missing host error + #[error("URL missing host error: {0}")] + UrlMissingHost(Url), + // URL missing port error + #[error("URL missing port error: {0}")] + UrlMissingPort(Url), + // URL parse error + #[error("URL error: {0}")] + UrlParse(#[from] url::ParseError), +} + +/// Alias for results returned from RPC operations using [`RpcTransport`]. +pub type RpcResult = Result; + +/// Represents supported authentication mechanisms for RPC requests. +#[derive(Debug, Clone)] +pub enum RpcAuth { + /// No authentication is applied. + None, + /// HTTP Basic authentication using a username and password. + Basic { username: String, password: String }, +} + +/// A transport mechanism for sending JSON-RPC requests over HTTP. +/// +/// This struct encapsulates the target URL, optional authentication, +/// and an internal HTTP client. +#[derive(Debug)] +pub struct RpcTransport { + /// Host and port of the target JSON-RPC server. + peer: PeerHost, + /// Request path component of the URL (e.g., `/` or `/api`). + path: String, + /// Authentication to apply to outgoing requests. + auth: RpcAuth, + /// The maximum duration to wait for an HTTP request to complete. + timeout: Duration, +} + +impl RpcTransport { + /// Creates a new `RpcTransport` with the given URL, authentication, and optional timeout. + /// + /// # Arguments + /// + /// * `url` - The JSON-RPC server endpoint. + /// * `auth` - Authentication configuration (`None` or `Basic`). + /// * `timeout` - Optional timeout duration for HTTP requests. If `None`, defaults to 30 seconds. + /// + /// # Returns + /// + /// An instance of [`RpcTransport`] on success, or a [`RpcError`] otherwise. + pub fn new(url: String, auth: RpcAuth, timeout: Option) -> RpcResult { + let url_obj = Url::parse(&url)?; + let host = url_obj + .host_str() + .ok_or(RpcError::UrlMissingHost(url_obj.clone()))?; + let port = url_obj + .port_or_known_default() + .ok_or(RpcError::UrlMissingHost(url_obj.clone()))?; + + let peer: PeerHost = format!("{host}:{port}").parse()?; + let path = url_obj.path().to_string(); + let timeout = timeout.unwrap_or(Duration::from_secs(30)); + Ok(RpcTransport { + peer, + path, + auth, + timeout, + }) + } + + /// Sends a JSON-RPC request with the given ID, method name, and parameters. + /// + /// # Arguments + /// + /// * `id` - A unique identifier for correlating responses. + /// * `method` - The name of the JSON-RPC method to invoke. + /// * `params` - A list of parameters to pass to the method. + /// + /// # Returns + /// + /// Returns `RpcResult`, which is a result containing either the successfully deserialized response of type `T` + /// or an [`RpcError`] otherwise + pub fn send Deserialize<'de>>( + &self, + id: &str, + method: &str, + params: Vec, + ) -> RpcResult { + let payload = JsonRpcRequest { + jsonrpc: RPC_VERSION.to_string(), + id: id.to_string(), + method: method.to_string(), + params: Value::Array(params), + }; + + let json_payload = serde_json::to_value(payload).map_err(RpcError::EncodeJson)?; + + let mut request = StacksHttpRequest::new_for_peer( + self.peer.clone(), + "POST".to_string(), + self.path.clone(), + HttpRequestContents::new().payload_json(json_payload), + )?; + + request.add_header("Connection".into(), "close".into()); + + if let Some(auth_header) = self.auth_header() { + request.add_header("Authorization".to_string(), auth_header); + } + + let host = request.preamble().host.hostname(); + let port = request.preamble().host.port(); + + let response = send_http_request(&host, port, request, self.timeout)?; + let json_response = match response.destruct().1 { + HttpResponsePayload::JSON(js) => Ok(js), + _ => Err(RpcError::InvalidJsonPayload), + }?; + + let parsed_response: JsonRpcResponse = + serde_json::from_value(json_response).map_err(RpcError::DecodeJson)?; + + if id != parsed_response.id { + return Err(RpcError::MismatchedId(id.to_string(), parsed_response.id)); + } + + if let Some(error) = parsed_response.error { + return Err(RpcError::Service(error)); + } + + if let Some(result) = parsed_response.result { + Ok(result) + } else { + Ok(serde_json::from_value(Value::Null).map_err(RpcError::DecodeJson)?) + } + } + + /// Build auth header if needed + fn auth_header(&self) -> Option { + match &self.auth { + RpcAuth::None => None, + RpcAuth::Basic { username, password } => { + let credentials = format!("{}:{}", username, password); + Some(format!("Basic {}", encode(credentials))) + } + } + } +} diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs b/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs new file mode 100644 index 0000000000..30d8b7f77f --- /dev/null +++ b/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs @@ -0,0 +1,295 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Unit Tests for [`RpcTransport`] + +use std::thread; + +use serde_json::json; + +use super::*; + +mod utils { + use super::*; + + pub fn rpc_no_auth(server: &mockito::ServerGuard) -> RpcTransport { + RpcTransport::new(server.url(), RpcAuth::None, None) + .expect("Rpc no auth creation should be ok!") + } + + pub fn rpc_with_auth( + server: &mockito::ServerGuard, + username: String, + password: String, + ) -> RpcTransport { + RpcTransport::new(server.url(), RpcAuth::Basic { username, password }, None) + .expect("Rpc with auth creation should be ok!") + } +} + +#[test] +fn test_send_with_string_result_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "client_id", + "method": "some_method", + "params": ["param1"] + }); + + let response_body = json!({ + "id": "client_id", + "result": "some_result", + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(response_body.to_string()) + .create(); + + let transport = utils::rpc_no_auth(&server); + + let result: RpcResult = + transport.send("client_id", "some_method", vec!["param1".into()]); + assert_eq!(result.unwrap(), "some_result"); +} + +#[test] +fn test_send_with_string_result_with_basic_auth_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "client_id", + "method": "some_method", + "params": ["param1"] + }); + + let response_body = json!({ + "id": "client_id", + "result": "some_result", + "error": null + }); + + let username = "user".to_string(); + let password = "pass".to_string(); + let credentials = base64::encode(format!("{}:{}", username, password)); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header( + "authorization", + mockito::Matcher::Exact(format!("Basic {credentials}")), + ) + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(response_body.to_string()) + .create(); + + let transport = utils::rpc_with_auth(&server, username, password); + + let result: RpcResult = + transport.send("client_id", "some_method", vec!["param1".into()]); + assert_eq!(result.unwrap(), "some_result"); +} + +#[test] +fn test_send_fails_due_to_unreachable_endpoint() { + let unreachable_endpoint = "http://127.0.0.1:65535".to_string(); + let transport = RpcTransport::new(unreachable_endpoint, RpcAuth::None, None) + .expect("Should be created properly!"); + + let result: RpcResult = transport.send("client_id", "dummy_method", vec![]); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!( + matches!(err, RpcError::NetworkIO(_)), + "Expected NetworkIO error, got: {err:?}" + ); +} + +#[test] +fn test_send_fails_with_http_500() { + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(500) + .with_body("Internal Server Error") + .create(); + + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "dummy", vec![]); + + assert!(result.is_err()); + match result { + Err(RpcError::NetworkIO(e)) => { + let msg = e.to_string(); + assert!(msg.contains("500"), "Should contain error 500!"); + } + other => panic!("Expected NetworkIO error, got: {other:?}"), + } +} + +#[test] +fn test_send_fails_with_invalid_json() { + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body("not a valid json") + .create(); + + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "dummy", vec![]); + + assert!(result.is_err()); + match result { + Err(RpcError::NetworkIO(e)) => { + let msg = e.to_string(); + assert!( + msg.contains("invalid message"), + "Should contain 'invalid message'!" + ) + } + other => panic!("Expected NetworkIO error, got: {other:?}"), + } +} + +#[test] +fn test_send_ok_if_missing_both_result_and_error() { + let response_body = json!({ + "id": "client_id", + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(response_body.to_string()) + .create(); + + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "dummy", vec![]); + assert!(result.is_ok()); +} + +#[test] +fn test_send_fails_with_invalid_id() { + let response_body = json!({ + "id": "res_client_id_wrong", + "result": true, + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(response_body.to_string()) + .create(); + + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("req_client_id", "dummy", vec![]); + + match result { + Err(RpcError::MismatchedId(req_id, res_id)) => { + assert_eq!("req_client_id", req_id); + assert_eq!("res_client_id_wrong", res_id); + } + other => panic!("Expected MismatchedId, got {other:?}"), + } +} + +#[test] +fn test_send_fails_with_service_error() { + let response_body = json!({ + "id": "client_id", + "result": null, + "error": { + "code": -32601, + "message": "Method not found", + } + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(response_body.to_string()) + .create(); + + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "unknown_method", vec![]); + + match result { + Err(RpcError::Service(err)) => { + assert_eq!(-32601, err.code); + assert_eq!("Method not found", err.message); + } + other => panic!("Expected Service error, got {other:?}"), + } +} + +#[test] +fn test_send_fails_due_to_timeout() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "client_id", + "method": "delayed_method", + "params": [] + }); + + let response_body = json!({ + "id": "client_id", + "result": "should_not_get_this", + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_chunked_body(move |writer| { + // Simulate server delay + thread::sleep(Duration::from_secs(2)); + writer.write_all(response_body.to_string().as_bytes()) + }) + .create(); + + // Timeout shorter than the server's delay + let timeout = Duration::from_millis(500); + let transport = RpcTransport::new(server.url(), RpcAuth::None, Some(timeout)).unwrap(); + + let result: RpcResult = transport.send("client_id", "delayed_method", vec![]); + + assert!(result.is_err()); + match result.unwrap_err() { + RpcError::NetworkIO(e) => { + let msg = e.to_string(); + assert!(msg.contains("Timed out"), "Should contain 'Timed out'!"); + } + other => panic!("Expected NetworkIO error, got: {other:?}"), + } +} diff --git a/stacks-node/src/tests/bitcoin_regtest.rs b/stacks-node/src/tests/bitcoin_regtest.rs index 81e1176790..6b535c6bad 100644 --- a/stacks-node/src/tests/bitcoin_regtest.rs +++ b/stacks-node/src/tests/bitcoin_regtest.rs @@ -32,17 +32,103 @@ type BitcoinResult = Result; pub struct BitcoinCoreController { bitcoind_process: Option, - pub config: Config, + config: Config, + args: Vec, } impl BitcoinCoreController { + /// TODO: to be removed in favor of [`Self::from_stx_config`] pub fn new(config: Config) -> BitcoinCoreController { BitcoinCoreController { bitcoind_process: None, config, + args: vec![], } } + /// Create a [`BitcoinCoreController`] from Stacks Configuration, mainly using [`stacks::config::BurnchainConfig`] + pub fn from_stx_config(config: Config) -> Self { + let mut result = BitcoinCoreController { + bitcoind_process: None, + config: config.clone(), //TODO: clone can be removed once + args: vec![], + }; + + result.add_arg("-regtest"); + result.add_arg("-nodebug"); + result.add_arg("-nodebuglogfile"); + result.add_arg("-rest"); + result.add_arg("-persistmempool=1"); + result.add_arg("-dbcache=100"); + result.add_arg("-txindex=1"); + result.add_arg("-server=1"); + result.add_arg("-listenonion=0"); + result.add_arg("-rpcbind=127.0.0.1"); + result.add_arg(format!("-datadir={}", config.get_burnchain_path_str())); + + let peer_port = config.burnchain.peer_port; + if peer_port == BURNCHAIN_CONFIG_PEER_PORT_DISABLED { + info!("Peer Port is disabled. So `-listen=0` flag will be used"); + result.add_arg("-listen=0"); + } else { + result.add_arg(format!("-port={}", peer_port)); + } + + result.add_arg(format!("-rpcport={}", config.burnchain.rpc_port)); + + if let (Some(username), Some(password)) = + (&config.burnchain.username, &config.burnchain.password) + { + result.add_arg(format!("-rpcuser={username}")); + result.add_arg(format!("-rpcpassword={password}")); + } + + result + } + + /// Add argument (like "-name=value") to be used to run bitcoind process + pub fn add_arg(&mut self, arg: impl Into) -> &mut Self { + self.args.push(arg.into()); + self + } + + /// Start Bitcoind process + pub fn start_bitcoind_v2(&mut self) -> BitcoinResult<()> { + std::fs::create_dir_all(self.config.get_burnchain_path_str()).unwrap(); + + let mut command = Command::new("bitcoind"); + command.stdout(Stdio::piped()); + + command.args(self.args.clone()); + + eprintln!("bitcoind spawn: {command:?}"); + + let mut process = match command.spawn() { + Ok(child) => child, + Err(e) => return Err(BitcoinCoreError::SpawnFailed(format!("{e:?}"))), + }; + + let mut out_reader = BufReader::new(process.stdout.take().unwrap()); + + let mut line = String::new(); + while let Ok(bytes_read) = out_reader.read_line(&mut line) { + if bytes_read == 0 { + return Err(BitcoinCoreError::SpawnFailed( + "Bitcoind closed before spawning network".into(), + )); + } + if line.contains("Done loading") { + break; + } + } + + eprintln!("bitcoind startup finished"); + + self.bitcoind_process = Some(process); + + Ok(()) + } + fn add_rpc_cli_args(&self, command: &mut Command) { command.arg(format!("-rpcport={}", self.config.burnchain.rpc_port)); @@ -56,6 +142,7 @@ impl BitcoinCoreController { } } + /// TODO: to be removed in favor of [`Self::start_bitcoind_v2`] pub fn start_bitcoind(&mut self) -> BitcoinResult<()> { std::fs::create_dir_all(self.config.get_burnchain_path_str()).unwrap(); diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs new file mode 100644 index 0000000000..2f6801b53d --- /dev/null +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -0,0 +1,798 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Integration tests for [`BitcoinRpcClient`] + +use std::env; + +use stacks::burnchains::bitcoin::address::LegacyBitcoinAddressType; +use stacks::burnchains::bitcoin::BitcoinNetworkType; +use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; +use stacks::types::chainstate::BurnchainHeaderHash; + +use crate::burnchains::rpc::bitcoin_rpc_client::test_utils::AddressType; +use crate::burnchains::rpc::bitcoin_rpc_client::{ + BitcoinRpcClient, BitcoinRpcClientError, ImportDescriptorsRequest, Timestamp, +}; +use crate::burnchains::rpc::rpc_transport::RpcError; +use crate::tests::bitcoin_regtest::BitcoinCoreController; + +mod utils { + use std::net::TcpListener; + + use stacks::config::Config; + + use crate::burnchains::rpc::bitcoin_rpc_client::BitcoinRpcClient; + use crate::burnchains::rpc::rpc_transport::RpcAuth; + use crate::util::get_epoch_time_ms; + + pub fn create_stx_config() -> Config { + let mut config = Config::default(); + config.burnchain.magic_bytes = "T3".as_bytes().into(); + config.burnchain.username = Some(String::from("user")); + config.burnchain.password = Some(String::from("12345")); + // overriding default "0.0.0.0" because doesn't play nicely on Windows. + config.burnchain.peer_host = String::from("127.0.0.1"); + // avoiding peer port biding to reduce the number of ports to bind to. + config.burnchain.peer_port = 0; + + //Ask the OS for a free port. Not guaranteed to stay free, + //after TcpListner is dropped, but good enough for testing + //and starting bitcoind right after config is created + let tmp_listener = + TcpListener::bind("127.0.0.1:0").expect("Failed to bind to get a free port"); + let port = tmp_listener.local_addr().unwrap().port(); + + config.burnchain.rpc_port = port; + + let now = get_epoch_time_ms(); + let dir = format!("/tmp/rpc-client-{port}-{now}"); + config.node.working_dir = dir; + + config + } + + pub fn create_client_no_auth_from_stx_config(config: Config) -> BitcoinRpcClient { + BitcoinRpcClient::new( + config.burnchain.peer_host, + config.burnchain.rpc_port, + RpcAuth::None, + config.burnchain.wallet_name, + config.burnchain.timeout, + "stacks".to_string(), + ) + .expect("Rpc client creation should be ok!") + } +} + +#[ignore] +#[test] +fn test_rpc_call_fails_when_bitcond_with_auth_but_rpc_no_auth() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let config_with_auth = utils::create_stx_config(); + + let mut btcd_controller = BitcoinCoreController::new(config_with_auth.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = utils::create_client_no_auth_from_stx_config(config_with_auth); + + let err = client.get_blockchain_info().expect_err("Should fail!"); + + assert!( + matches!(err, BitcoinRpcClientError::Rpc(RpcError::NetworkIO(_))), + "Expected RpcError::Network, got: {err:?}" + ); +} + +#[ignore] +#[test] +fn test_rpc_call_fails_when_bitcond_no_auth_and_rpc_no_auth() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config_no_auth = utils::create_stx_config(); + config_no_auth.burnchain.username = None; + config_no_auth.burnchain.password = None; + + let mut btcd_controller = BitcoinCoreController::new(config_no_auth.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = utils::create_client_no_auth_from_stx_config(config_no_auth); + + let err = client.get_blockchain_info().expect_err("Should fail!"); + + assert!( + matches!(err, BitcoinRpcClientError::Rpc(RpcError::NetworkIO(_))), + "Expected RpcError::Network, got: {err:?}" + ); +} + +#[ignore] +#[test] +fn test_client_creation_fails_due_to_stx_config_missing_auth() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config_no_auth = utils::create_stx_config(); + config_no_auth.burnchain.username = None; + config_no_auth.burnchain.password = None; + + let err = BitcoinRpcClient::from_stx_config(&config_no_auth).expect_err("Client should fail!"); + + assert!(matches!(err, BitcoinRpcClientError::MissingCredentials)); +} + +#[ignore] +#[test] +fn test_get_blockchain_info_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let config = utils::create_stx_config(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + + let info = client.get_blockchain_info().expect("Should be ok!"); + assert_eq!(BitcoinNetworkType::Regtest, info.chain); + assert_eq!(0, info.blocks); + assert_eq!(0, info.headers); + assert_eq!( + BITCOIN_REGTEST_FIRST_BLOCK_HASH, + info.best_block_hash.to_hex() + ); +} + +#[ignore] +#[test] +fn test_wallet_listing_and_creation_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let config = utils::create_stx_config(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + + let wallets = client.list_wallets().unwrap(); + assert_eq!(0, wallets.len()); + + client + .create_wallet("mywallet1", Some(false)) + .expect("mywallet1 creation should be ok!"); + + let wallets = client.list_wallets().unwrap(); + assert_eq!(1, wallets.len()); + assert_eq!("mywallet1", wallets[0]); + + client + .create_wallet("mywallet2", Some(false)) + .expect("mywallet2 creation should be ok!"); + + let wallets = client.list_wallets().unwrap(); + assert_eq!(2, wallets.len()); + assert_eq!("mywallet1", wallets[0]); + assert_eq!("mywallet2", wallets[1]); +} + +#[ignore] +#[test] +fn test_wallet_creation_fails_if_already_exists() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let config = utils::create_stx_config(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + + client + .create_wallet("mywallet1", Some(false)) + .expect("mywallet1 creation should be ok!"); + + let err = client + .create_wallet("mywallet1", Some(false)) + .expect_err("mywallet1 creation should fail now!"); + + match &err { + BitcoinRpcClientError::Rpc(RpcError::NetworkIO(_)) => { + assert!(true, "Bitcoind v25 returns HTTP 500)"); + } + BitcoinRpcClientError::Rpc(RpcError::Service(_)) => { + assert!(true, "Bitcoind v26+ returns HTTP 200"); + } + _ => panic!("Expected Network or Service error, got {err:?}"), + } +} + +#[ignore] +#[test] +fn test_get_new_address_for_each_address_type() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + + // Check Legacy p2pkh type OK + let p2pkh = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("p2pkh address ok!"); + assert_eq!( + LegacyBitcoinAddressType::PublicKeyHash, + p2pkh.expect_legacy().addrtype + ); + + // Check Legacy p2sh type OK + let p2sh = client + .get_new_address(None, Some(AddressType::P2shSegwit)) + .expect("p2sh address ok!"); + assert_eq!( + LegacyBitcoinAddressType::ScriptHash, + p2sh.expect_legacy().addrtype + ); + + // Check Bech32 p2wpkh OK + let p2wpkh = client + .get_new_address(None, Some(AddressType::Bech32)) + .expect("p2wpkh address ok!"); + assert!(p2wpkh.expect_segwit().is_p2wpkh()); + + // Check Bech32m p2tr OK + let p2tr = client + .get_new_address(None, Some(AddressType::Bech32m)) + .expect("p2tr address ok!"); + assert!(p2tr.expect_segwit().is_p2tr()); + + // Check default to be bech32 p2wpkh + let default = client + .get_new_address(None, None) + .expect("default address ok!"); + assert!(default.expect_segwit().is_p2wpkh()); +} + +#[ignore] +#[test] +fn test_generate_to_address_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("Should work!"); + + let blocks = client + .generate_to_address(102, &address) + .expect("Should be ok!"); + assert_eq!(102, blocks.len()); +} + +#[ignore] +#[test] +fn test_list_unspent_empty_with_empty_wallet() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + + let utxos = client + .list_unspent(None, None, None, None, None, None) + .expect("all list_unspent should be ok!"); + assert_eq!(0, utxos.len()); +} + +#[ignore] +#[test] +fn test_list_unspent_with_defaults() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("Should work!"); + + _ = client + .generate_to_address(102, &address) + .expect("generate to address ok!"); + + let utxos = client + .list_unspent(None, None, None, None, None, None) + .expect("all list_unspent should be ok!"); + assert_eq!(2, utxos.len()); +} + +#[ignore] +#[test] +fn test_list_unspent_one_address_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("Should work!"); + + _ = client + .generate_to_address(102, &address) + .expect("generate to address ok!"); + + let all_utxos = client + .list_unspent(None, None, None, Some(false), Some(1), Some(10)) + .expect("all list_unspent should be ok!"); + assert_eq!(2, all_utxos.len()); + assert_eq!(address, all_utxos[0].address); + assert_eq!(address, all_utxos[1].address); + + let addr_utxos = client + .list_unspent( + None, + None, + Some(&[&address]), + Some(false), + Some(1), + Some(10), + ) + .expect("list_unspent per address should be ok!"); + assert_eq!(2, addr_utxos.len()); + assert_eq!(address, addr_utxos[0].address); + assert_eq!(address, addr_utxos[1].address); + + let max1_utxos = client + .list_unspent(None, None, None, Some(false), Some(1), Some(1)) + .expect("list_unspent per address and max count should be ok!"); + assert_eq!(1, max1_utxos.len()); + assert_eq!(address, max1_utxos[0].address); +} + +#[ignore] +#[test] +fn test_list_unspent_two_addresses_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + + let address1 = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("address 1 ok!"); + let address2 = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("address 2 ok!"); + + _ = client + .generate_to_address(2, &address1) + .expect("generate to address 1 ok!"); + _ = client + .generate_to_address(101, &address2) + .expect("generate to address 2 ok!"); + + let all_utxos = client + .list_unspent(None, None, None, Some(false), None, None) + .expect("all list_unspent should be ok!"); + assert_eq!(3, all_utxos.len()); + + let addr1_utxos = client + .list_unspent(None, None, Some(&[&address1]), Some(false), None, None) + .expect("list_unspent per address1 should be ok!"); + assert_eq!(2, addr1_utxos.len()); + assert_eq!(address1, addr1_utxos[0].address); + assert_eq!(address1, addr1_utxos[1].address); + + let addr2_utxos = client + .list_unspent(None, None, Some(&[&address2]), Some(false), None, None) + .expect("list_unspent per address2 should be ok!"); + assert_eq!(1, addr2_utxos.len()); + assert_eq!(address2, addr2_utxos[0].address); + + let all2_utxos = client + .list_unspent( + None, + None, + Some(&[&address1, &address2]), + Some(false), + None, + None, + ) + .expect("all list_unspent for both addresses should be ok!"); + assert_eq!(3, all2_utxos.len()); +} + +#[ignore] +#[test] +fn test_generate_block_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("get new address ok!"); + + let block_hash = client + .generate_block(&address, &[]) + .expect("generate block ok!"); + assert_eq!(64, block_hash.to_hex().len()); +} + +#[ignore] +#[test] +fn test_get_raw_transaction_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(config.clone()); + btcd_controller + .add_arg("-fallbackfee=0.0002") + .start_bitcoind_v2() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("get new address ok!"); + + //Create 1 UTXO + _ = client + .generate_to_address(101, &address) + .expect("generate to address ok!"); + + //Need `fallbackfee` arg + let txid = client + .send_to_address(&address, 2.0) + .expect("send to address ok!"); + + let raw_tx = client + .get_raw_transaction(&txid) + .expect("get raw transaction ok!"); + + assert_eq!(txid.to_bitcoin_hex(), raw_tx.txid().to_string()); +} + +#[ignore] +#[test] +fn test_get_transaction_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(config.clone()); + btcd_controller + .add_arg("-fallbackfee=0.0002") + .start_bitcoind_v2() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("get new address ok!"); + + //Create 1 UTXO + _ = client + .generate_to_address(101, &address) + .expect("generate to address ok!"); + + //Need `fallbackfee` arg + let txid = client + .send_to_address(&address, 2.0) + .expect("send to address ok!"); + + let resp = client.get_transaction(&txid).expect("get transaction ok!"); + assert_eq!(0, resp.confirmations); +} + +#[ignore] +#[test] +fn test_get_descriptor_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(config.clone()); + btcd_controller + .start_bitcoind_v2() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", None) + .expect("create wallet ok!"); + + let address = "mqqxPdP1dsGk75S7ta2nwyU8ujDnB2Yxvu"; + let checksum = "spfcmvsn"; + + let descriptor = format!("addr({address})"); + let info = client + .get_descriptor_info(&descriptor) + .expect("get descriptor ok!"); + assert_eq!(checksum, info.checksum); +} + +#[ignore] +#[test] +fn test_import_descriptor_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(config.clone()); + btcd_controller + .start_bitcoind_v2() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", Some(true)) + .expect("create wallet ok!"); + + let address = "mqqxPdP1dsGk75S7ta2nwyU8ujDnB2Yxvu"; + let checksum = "spfcmvsn"; + + let desc_req = ImportDescriptorsRequest { + descriptor: format!("addr({address})#{checksum}"), + timestamp: Timestamp::Time(0), + internal: Some(true), + }; + + let response = client + .import_descriptors(&[&desc_req]) + .expect("import descriptor ok!"); + assert_eq!(1, response.len()); + assert!(response[0].success); +} + +#[ignore] +#[test] +fn test_stop_bitcoind_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let config = utils::create_stx_config(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + let msg = client.stop().expect("Should shutdown!"); + assert_eq!("Bitcoin Core stopping", msg); +} + +#[ignore] +#[test] +fn test_invalidate_block_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("get new address ok!"); + let block_hash = client + .generate_block(&address, &[]) + .expect("generate block ok!"); + + client + .invalidate_block(&block_hash) + .expect("Invalidate valid hash should be ok!"); + + let nonexistent_hash = BurnchainHeaderHash::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); + client + .invalidate_block(&nonexistent_hash) + .expect_err("Invalidate nonexistent hash should fail!"); +} + +#[ignore] +#[test] +fn test_get_block_hash_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + + let bhh = client + .get_block_hash(0) + .expect("Should return regtest genesis block hash!"); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, bhh.to_hex()); +} + +#[ignore] +#[test] +fn test_send_raw_transaction_rebroadcast_ok() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let mut config = utils::create_stx_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(config.clone()); + btcd_controller + .add_arg("-fallbackfee=0.0002") + .start_bitcoind_v2() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("get new address ok!"); + + //Create 1 UTXO + _ = client + .generate_to_address(101, &address) + .expect("generate to address ok!"); + + //Need `fallbackfee` arg + let txid = client + .send_to_address(&address, 2.0) + .expect("send to address ok!"); + + let raw_tx = client + .get_raw_transaction(&txid) + .expect("get raw transaction ok!"); + + let txid = client + .send_raw_transaction(&raw_tx, None, None) + .expect("send raw transaction (rebroadcast) ok!"); + + assert_eq!(txid.to_bitcoin_hex(), raw_tx.txid().to_string()); +} diff --git a/stacks-node/src/tests/mod.rs b/stacks-node/src/tests/mod.rs index e4286e6334..6e35c9bb97 100644 --- a/stacks-node/src/tests/mod.rs +++ b/stacks-node/src/tests/mod.rs @@ -45,6 +45,7 @@ use crate::BitcoinRegtestController; mod atlas; pub mod bitcoin_regtest; +mod bitcoin_rpc_integrations; mod epoch_205; mod epoch_21; mod epoch_22; diff --git a/stackslib/src/burnchains/bitcoin/address.rs b/stackslib/src/burnchains/bitcoin/address.rs index 519c925e88..0a63923a45 100644 --- a/stackslib/src/burnchains/bitcoin/address.rs +++ b/stackslib/src/burnchains/bitcoin/address.rs @@ -536,7 +536,7 @@ impl BitcoinAddress { return false; } - #[cfg(test)] + #[cfg(any(test, feature = "testing"))] pub fn expect_legacy(self) -> LegacyBitcoinAddress { match self { BitcoinAddress::Legacy(addr) => addr, @@ -546,7 +546,7 @@ impl BitcoinAddress { } } - #[cfg(test)] + #[cfg(any(test, feature = "testing"))] pub fn expect_segwit(self) -> SegwitBitcoinAddress { match self { BitcoinAddress::Segwit(addr) => addr, diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index 6f4ac414b6..ab17c2b9e5 100644 --- a/stackslib/src/chainstate/stacks/mod.rs +++ b/stackslib/src/chainstate/stacks/mod.rs @@ -17,6 +17,8 @@ use std::hash::Hash; use std::{error, fmt, io}; +use clarity::util::hash::to_hex; +use clarity::util::HexError; use clarity::vm::contexts::GlobalContext; use clarity::vm::costs::{CostErrors, ExecutionCost}; use clarity::vm::errors::Error as clarity_interpreter_error; @@ -384,6 +386,27 @@ impl Txid { txid_bytes.reverse(); Self(txid_bytes) } + + /// Creates a [`Txid`] from a Bitcoin transaction hash given as a hex string. + /// + /// # Argument + /// * `hex` - A 64-character, hex-encoded transaction ID (human-readable, **big-endian**) + /// + /// Internally `Txid` stores the hash bytes in little-endian + pub fn from_bitcoin_hex(hex: &str) -> Result { + let hash = Sha256dHash::from_hex(hex)?; + Ok(Self(hash.to_bytes())) + } + + /// Convert a [`Txid`] to a Bitcoin transaction hex string (human-readable, **big-endian**) + /// + /// Internally is intended that bytes are stored in **little-endian** order, + /// so bytes will be reversed to compute the final hex string in **big-endian** order. + pub fn to_bitcoin_hex(&self) -> String { + let mut bytes = self.to_bytes(); + bytes.reverse(); + to_hex(&bytes) + } } /// How a transaction may be appended to the Stacks blockchain @@ -1694,4 +1717,42 @@ pub mod test { txs: txs_mblock, } } + + #[test] + fn test_txid_from_bitcoin_hex_ok() { + let btc_hex = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + let mut expected_bytes = hex_bytes(btc_hex).unwrap(); + expected_bytes.reverse(); + + let txid = Txid::from_bitcoin_hex(btc_hex).expect("Should be ok!"); + assert_eq!(expected_bytes, txid.as_bytes()); + } + + #[test] + fn test_txid_from_bitcoin_hex_failure() { + let short_hex = "short_hex"; + let error = Txid::from_bitcoin_hex(short_hex).expect_err("Should fail due to length!"); + assert!(matches!(error, HexError::BadLength(9))); + + let bad_hex = "Z000000000000000000000000000000000000000000000000000000000000000"; + let error = Txid::from_bitcoin_hex(bad_hex).expect_err("Should fail to invalid char!"); + assert!(matches!(error, HexError::BadCharacter('Z'))) + } + + #[test] + fn test_txid_to_bitcoin_hex_ok() { + let btc_hex = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + let mut txid_hex = hex_bytes(btc_hex).unwrap(); + txid_hex.reverse(); + let txid = Txid::from_bytes(&txid_hex).unwrap(); + assert_eq!(btc_hex, txid.to_bitcoin_hex()); + } + + #[test] + fn test_txid_from_to_bitcoin_hex_integration_ok() { + let btc_hex_input = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + let txid = Txid::from_bitcoin_hex(btc_hex_input).unwrap(); + let btc_hex_output = txid.to_bitcoin_hex(); + assert_eq!(btc_hex_input, btc_hex_output); + } }