From bca8621f2ca3217a17e2be60cfd055653b398625 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 7 Jul 2025 16:13:55 +0200 Subject: [PATCH 01/62] refactor: create BitcoinRpcClient, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs new file mode 100644 index 0000000000..c71dbb48b2 --- /dev/null +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -0,0 +1,226 @@ +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; +use reqwest::blocking::Client; + +use base64::encode; + +const RCP_CLIENT_ID: &str = "stacks"; +const RCP_VERSION: &str = "2.0"; + +#[derive(Serialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: String, + method: String, + params: serde_json::Value, +} + +#[derive(Deserialize, Debug)] +struct JsonRpcResponse { + result: Option, + error: Option, + //id: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum RpcError { + Network(String), + Parsing(String), + Bitcoind(String), +} + +pub type RpcResult = Result; + +/* +impl From for RPCError { + fn from(ioe: io::Error) -> Self { + Self::Network(format!("IO Error: {ioe:?}")) + } +} + +impl From for RPCError { + fn from(ne: NetError) -> Self { + Self::Network(format!("Net Error: {ne:?}")) + } +} + */ + + + +pub struct BitcoinRpcClient { + host: String, + port: u16, + ssl: bool, + username: String, + password: String, +} + +impl BitcoinRpcClient { + pub fn new( + host: String, + port: u16, + ssl: bool, + username: String, + password: String, + ) -> Self { + /* + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .unwrap(); + */ + Self { + host, + port, + ssl, + username, + password, + //client, + } + } + + pub fn create_wallet(&self, wallet_name: &str) -> RpcResult<()> { + let disable_private_keys = true; + + self.call::( + "createwallet", + vec![wallet_name.into(), disable_private_keys.into()], + None)?; + Ok(()) + } + + pub fn list_wallets(&self) -> RpcResult> { + self.call( + "listwallets", + vec![], + None) + } + + fn call Deserialize<'de>>( + &self, + method: &str, + params: Vec, + wallet: Option<&str>, + ) -> RpcResult { + let request = JsonRpcRequest { + jsonrpc: RCP_VERSION.to_string(), + id: RCP_CLIENT_ID.to_string(), + method: method.to_string(), + params: Value::Array(params), + }; + + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .unwrap(); + + let response = + //self.client + client + .post(&self.build_url(wallet)) + .header("Authorization", self.auth_header()) + .json(&request) + .send() + .map_err(|err| RpcError::Network(err.to_string()))?; + + let parsed: JsonRpcResponse = response.json().map_err(|e| { + RpcError::Parsing(format!("Failed to parse RPC response: {}", e)) + })?; + + match (parsed.result, parsed.error) { + (Some(result), None) => Ok(result), + (_, Some(err)) => Err(RpcError::Bitcoind(format!("{:#}", err))), + _ => Err(RpcError::Parsing("Missing both result and error".into())), + } + } + + fn build_url(&self, wallet_opt: Option<&str>) -> String { + let protocol = if self.ssl { "https" } else { "http" }; + let mut url = format!("{}://{}:{}", protocol, self.host, self.port); + if let Some(wallet) = wallet_opt { + url.push_str(&format!("/wallet/{}", wallet)); + } + url + } + + fn auth_header(&self) -> String { + let credentials = format!("{}:{}", self.username, self.password); + format!("Basic {}", encode(credentials)) + } +} + +#[cfg(test)] +mod unit_tests { + + use serde_json::json; + + use super::*; + + mod utils { + use super::*; + + pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { + let url = server.url(); + let parsed = url::Url::parse(&url).unwrap(); + + BitcoinRpcClient { + host: parsed.host_str().unwrap().to_string(), + port: parsed.port_or_known_default().unwrap(), + ssl: parsed.scheme() == "https", + username: "user".into(), + password: "pass".into(), + } + } + } + + #[test] + fn test_create_wallet() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "createwallet", + "params": ["testwallet", true] + }); + + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"result":true,"error":null}"#) + .create(); + + let client = utils::setup_client(&server); + let result = client.create_wallet("testwallet"); + result.expect("Should work"); + } + + #[test] + fn test_list_wallets() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listwallets", + "params": [] + }); + + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"result":["wallet1","wallet2"],"error":null}"#) + .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]); + } +} \ No newline at end of file From 85f3fe0b74500da9f384c763eb5748ff1c7170ef Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 8 Jul 2025 09:41:48 +0200 Subject: [PATCH 02/62] refactor: rpc list_unspent, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 128 +++++++++++------- testnet/stacks-node/src/burnchains/mod.rs | 1 + 2 files changed, 77 insertions(+), 52 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index c71dbb48b2..9043458fd3 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -2,10 +2,13 @@ use std::time::Duration; use serde::Deserialize; use serde_json::Value; +use serde_json::json; use reqwest::blocking::Client; use base64::encode; +use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; + const RCP_CLIENT_ID: &str = "stacks"; const RCP_VERSION: &str = "2.0"; @@ -47,9 +50,7 @@ impl From for RPCError { } */ - - -pub struct BitcoinRpcClient { +struct RpcTransport { host: String, port: u16, ssl: bool, @@ -57,48 +58,8 @@ pub struct BitcoinRpcClient { password: String, } -impl BitcoinRpcClient { - pub fn new( - host: String, - port: u16, - ssl: bool, - username: String, - password: String, - ) -> Self { - /* - let client = Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .unwrap(); - */ - Self { - host, - port, - ssl, - username, - password, - //client, - } - } - - pub fn create_wallet(&self, wallet_name: &str) -> RpcResult<()> { - let disable_private_keys = true; - - self.call::( - "createwallet", - vec![wallet_name.into(), disable_private_keys.into()], - None)?; - Ok(()) - } - - pub fn list_wallets(&self) -> RpcResult> { - self.call( - "listwallets", - vec![], - None) - } - - fn call Deserialize<'de>>( +impl RpcTransport { + pub fn send Deserialize<'de>>( &self, method: &str, params: Vec, @@ -149,6 +110,69 @@ impl BitcoinRpcClient { let credentials = format!("{}:{}", self.username, self.password); format!("Basic {}", encode(credentials)) } + +} + +pub struct BitcoinRpcClient { + transport: RpcTransport +} + +impl BitcoinRpcClient { + pub fn from_params( + host: String, + port: u16, + ssl: bool, + username: String, + password: String, + ) -> Self { + Self { + transport: RpcTransport { + host, + port, + ssl, + username, + password, + }, + } + } + + pub fn create_wallet(&self, wallet_name: &str) -> RpcResult<()> { + let disable_private_keys = true; + + self.transport.send::( + "createwallet", + vec![wallet_name.into(), disable_private_keys.into()], + None)?; + Ok(()) + } + + pub fn list_wallets(&self) -> RpcResult> { + self.transport.send( + "listwallets", + vec![], + None) + } + + pub fn list_unspent(&self, addresses: Vec, include_unsafe: bool, minimum_amount: u64, maximum_count: u64) -> RpcResult> { + let min_conf = 0i64; + let max_conf = 9999999i64; + let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_amount); + let maximum_count = maximum_count; + + let raw_utxos: Vec = self.transport.send( + "listunspent", + vec![ + min_conf.into(), max_conf.into(), addresses.into(), include_unsafe.into(), + json!({ + "minimumAmount": minimum_amount, + "maximumCount": maximum_count + }), + ], + None + )?; + + + } } #[cfg(test)] @@ -165,13 +189,13 @@ mod unit_tests { let url = server.url(); let parsed = url::Url::parse(&url).unwrap(); - BitcoinRpcClient { - host: parsed.host_str().unwrap().to_string(), - port: parsed.port_or_known_default().unwrap(), - ssl: parsed.scheme() == "https", - username: "user".into(), - password: "pass".into(), - } + BitcoinRpcClient::from_params( + parsed.host_str().unwrap().to_string(), + parsed.port_or_known_default().unwrap(), + parsed.scheme() == "https", + "user".into(), + "pass".into(), + ) } } diff --git a/testnet/stacks-node/src/burnchains/mod.rs b/testnet/stacks-node/src/burnchains/mod.rs index 11b946b0cc..6b02d7edf0 100644 --- a/testnet/stacks-node/src/burnchains/mod.rs +++ b/testnet/stacks-node/src/burnchains/mod.rs @@ -1,5 +1,6 @@ pub mod bitcoin_regtest_controller; pub mod mocknet_controller; +pub mod bitcoin_rpc_client; use std::time::Instant; From b61355a21a54aa0111b4688c021900c3256b049c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 8 Jul 2025 11:11:15 +0200 Subject: [PATCH 03/62] test: add list_unspent mock test, #6250 --- .../burnchains/bitcoin_regtest_controller.rs | 4 +- .../src/burnchains/bitcoin_rpc_client.rs | 98 ++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 16742f4fb8..14ee350da2 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -2332,10 +2332,10 @@ impl SerializedTx { #[allow(dead_code)] pub struct ParsedUTXO { txid: String, - vout: u32, + pub vout: u32, script_pub_key: String, amount: Box, - confirmations: u32, + pub confirmations: u32, } #[derive(Clone, Debug, PartialEq)] diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 9043458fd3..2407585043 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -162,7 +162,10 @@ impl BitcoinRpcClient { let raw_utxos: Vec = self.transport.send( "listunspent", vec![ - min_conf.into(), max_conf.into(), addresses.into(), include_unsafe.into(), + min_conf.into(), + max_conf.into(), + addresses.into(), + include_unsafe.into(), json!({ "minimumAmount": minimum_amount, "maximumCount": maximum_count @@ -171,7 +174,36 @@ impl BitcoinRpcClient { None )?; + let mut result = vec![]; + for raw_utxo in raw_utxos.iter() { + let txid = match raw_utxo.get_txid() { + Some(hash) => hash, + None => continue, + }; + + let script_pub_key = match raw_utxo.get_script_pub_key() { + Some(script_pub_key) => script_pub_key, + None => { + //TODO: add warn log? + continue; + } + }; + + let amount = match raw_utxo.get_sat_amount() { + Some(amount) => amount, + None => continue, //TODO: add warn log? + }; + + result.push(UTXO { + txid, + vout: raw_utxo.vout, + script_pub_key, + amount, + confirmations: raw_utxo.confirmations, + }); + } + Ok(result) } } @@ -179,6 +211,7 @@ impl BitcoinRpcClient { mod unit_tests { use serde_json::json; + use stacks::util::hash::to_hex; use super::*; @@ -247,4 +280,67 @@ mod unit_tests { assert_eq!("wallet1", result[0]); assert_eq!("wallet2", result[1]); } + + #[test] + fn test_list_unspent() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listunspent", + "params": [ + 0, + 9999999, + ["BTC_ADDRESS_1"], + true, + { + "minimumAmount": "0.00001000", + "maximumCount": 100 + } + ] + }); + + let response_body = json!({ + "result": [{ + "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "vout": 0, + "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + "amount": 0.00001, + "confirmations": 6 + }], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(response_body.to_string()) + .create(); + + let client = utils::setup_client(&server); + + let result = client.list_unspent( + vec!["BTC_ADDRESS_1".into()], + true, + 1000, // 1000 sats = 0.00001000 BTC + 100, + ).expect("Should parse unspent outputs"); + + assert_eq!(result.len(), 1); + let utxo = &result[0]; + assert_eq!(utxo.amount, 1000); + assert_eq!(utxo.vout, 0); + assert_eq!(utxo.confirmations, 6); + assert_eq!( + utxo.txid.to_string(), + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + ); + assert_eq!( + to_hex(&utxo.script_pub_key.to_bytes()), + "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac" + ); + } } \ No newline at end of file From 69cdc3e6d3d2deda2a73d321064893b3847729c5 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 8 Jul 2025 14:08:41 +0200 Subject: [PATCH 04/62] refactor: extract rcp_transport module, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 192 +++---------- testnet/stacks-node/src/burnchains/mod.rs | 3 +- .../src/burnchains/rpc_transport.rs | 269 ++++++++++++++++++ 3 files changed, 316 insertions(+), 148 deletions(-) create mode 100644 testnet/stacks-node/src/burnchains/rpc_transport.rs diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 2407585043..1c9904cd01 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -1,124 +1,14 @@ -use std::time::Duration; - -use serde::Deserialize; -use serde_json::Value; -use serde_json::json; -use reqwest::blocking::Client; - -use base64::encode; +use serde_json::{json, Value}; +use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; -const RCP_CLIENT_ID: &str = "stacks"; -const RCP_VERSION: &str = "2.0"; - -#[derive(Serialize)] -struct JsonRpcRequest { - jsonrpc: String, - id: String, - method: String, - params: serde_json::Value, -} - -#[derive(Deserialize, Debug)] -struct JsonRpcResponse { - result: Option, - error: Option, - //id: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub enum RpcError { - Network(String), - Parsing(String), - Bitcoind(String), -} - -pub type RpcResult = Result; - -/* -impl From for RPCError { - fn from(ioe: io::Error) -> Self { - Self::Network(format!("IO Error: {ioe:?}")) - } -} - -impl From for RPCError { - fn from(ne: NetError) -> Self { - Self::Network(format!("Net Error: {ne:?}")) - } -} - */ - -struct RpcTransport { - host: String, - port: u16, - ssl: bool, - username: String, - password: String, -} - -impl RpcTransport { - pub fn send Deserialize<'de>>( - &self, - method: &str, - params: Vec, - wallet: Option<&str>, - ) -> RpcResult { - let request = JsonRpcRequest { - jsonrpc: RCP_VERSION.to_string(), - id: RCP_CLIENT_ID.to_string(), - method: method.to_string(), - params: Value::Array(params), - }; - - let client = Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .unwrap(); - - let response = - //self.client - client - .post(&self.build_url(wallet)) - .header("Authorization", self.auth_header()) - .json(&request) - .send() - .map_err(|err| RpcError::Network(err.to_string()))?; - - let parsed: JsonRpcResponse = response.json().map_err(|e| { - RpcError::Parsing(format!("Failed to parse RPC response: {}", e)) - })?; - - match (parsed.result, parsed.error) { - (Some(result), None) => Ok(result), - (_, Some(err)) => Err(RpcError::Bitcoind(format!("{:#}", err))), - _ => Err(RpcError::Parsing("Missing both result and error".into())), - } - } - - fn build_url(&self, wallet_opt: Option<&str>) -> String { - let protocol = if self.ssl { "https" } else { "http" }; - let mut url = format!("{}://{}:{}", protocol, self.host, self.port); - if let Some(wallet) = wallet_opt { - url.push_str(&format!("/wallet/{}", wallet)); - } - url - } - - fn auth_header(&self) -> String { - let credentials = format!("{}:{}", self.username, self.password); - format!("Basic {}", encode(credentials)) - } - -} - pub struct BitcoinRpcClient { - transport: RpcTransport + transport: RpcTransport, } impl BitcoinRpcClient { - pub fn from_params( + pub fn from_params( host: String, port: u16, ssl: bool, @@ -138,40 +28,44 @@ impl BitcoinRpcClient { pub fn create_wallet(&self, wallet_name: &str) -> RpcResult<()> { let disable_private_keys = true; - + self.transport.send::( - "createwallet", + "createwallet", vec![wallet_name.into(), disable_private_keys.into()], - None)?; + None, + )?; Ok(()) } pub fn list_wallets(&self) -> RpcResult> { - self.transport.send( - "listwallets", - vec![], - None) + self.transport.send("listwallets", vec![], None) } - pub fn list_unspent(&self, addresses: Vec, include_unsafe: bool, minimum_amount: u64, maximum_count: u64) -> RpcResult> { + pub fn list_unspent( + &self, + addresses: Vec, + include_unsafe: bool, + minimum_amount: u64, + maximum_count: u64, + ) -> RpcResult> { let min_conf = 0i64; let max_conf = 9999999i64; let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_amount); let maximum_count = maximum_count; let raw_utxos: Vec = self.transport.send( - "listunspent", + "listunspent", vec![ - min_conf.into(), - max_conf.into(), - addresses.into(), + min_conf.into(), + max_conf.into(), + addresses.into(), include_unsafe.into(), json!({ "minimumAmount": minimum_amount, "maximumCount": maximum_count }), ], - None + None, )?; let mut result = vec![]; @@ -202,7 +96,7 @@ impl BitcoinRpcClient { confirmations: raw_utxo.confirmations, }); } - + Ok(result) } } @@ -233,7 +127,7 @@ mod unit_tests { } #[test] - fn test_create_wallet() { + fn test_create_wallet_ok() { let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", @@ -242,7 +136,8 @@ mod unit_tests { }); let mut server: mockito::ServerGuard = mockito::Server::new(); - let _m = server.mock("POST", "/") + let _m = server + .mock("POST", "/") .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) @@ -256,7 +151,7 @@ mod unit_tests { } #[test] - fn test_list_wallets() { + fn test_list_wallets_ok() { let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", @@ -265,8 +160,9 @@ mod unit_tests { }); let mut server = mockito::Server::new(); - let _m = server.mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -282,7 +178,7 @@ mod unit_tests { } #[test] - fn test_list_unspent() { + fn test_list_unspent_ok() { let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", @@ -322,25 +218,27 @@ mod unit_tests { let client = utils::setup_client(&server); - let result = client.list_unspent( - vec!["BTC_ADDRESS_1".into()], - true, - 1000, // 1000 sats = 0.00001000 BTC - 100, - ).expect("Should parse unspent outputs"); + let result = client + .list_unspent( + vec!["BTC_ADDRESS_1".into()], + true, + 1000, // 1000 sats = 0.00001000 BTC + 100, + ) + .expect("Should parse unspent outputs"); - assert_eq!(result.len(), 1); + assert_eq!(1, result.len()); let utxo = &result[0]; - assert_eq!(utxo.amount, 1000); - assert_eq!(utxo.vout, 0); - assert_eq!(utxo.confirmations, 6); + assert_eq!(1000, utxo.amount); + assert_eq!(0, utxo.vout); + assert_eq!(6, utxo.confirmations); assert_eq!( + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", utxo.txid.to_string(), - "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" ); assert_eq!( + "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", to_hex(&utxo.script_pub_key.to_bytes()), - "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac" ); } -} \ No newline at end of file +} diff --git a/testnet/stacks-node/src/burnchains/mod.rs b/testnet/stacks-node/src/burnchains/mod.rs index 6b02d7edf0..70c05d9136 100644 --- a/testnet/stacks-node/src/burnchains/mod.rs +++ b/testnet/stacks-node/src/burnchains/mod.rs @@ -1,6 +1,7 @@ pub mod bitcoin_regtest_controller; -pub mod mocknet_controller; pub mod bitcoin_rpc_client; +pub mod mocknet_controller; +pub mod rpc_transport; use std::time::Instant; diff --git a/testnet/stacks-node/src/burnchains/rpc_transport.rs b/testnet/stacks-node/src/burnchains/rpc_transport.rs new file mode 100644 index 0000000000..8126352624 --- /dev/null +++ b/testnet/stacks-node/src/burnchains/rpc_transport.rs @@ -0,0 +1,269 @@ +use std::time::Duration; + +use base64::encode; +use reqwest::blocking::Client; +use serde::Deserialize; +use serde_json::Value; + +const RCP_CLIENT_ID: &str = "stacks"; +const RCP_VERSION: &str = "2.0"; + +#[derive(Serialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: String, + method: String, + params: serde_json::Value, +} + +#[derive(Deserialize, Debug)] +struct JsonRpcResponse { + result: Option, + error: Option, + //id: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum RpcError { + Network(String), + Parsing(String), + //Bitcoind(String), + Service(String), +} + +pub type RpcResult = Result; + +/* +impl From for RPCError { + fn from(ioe: io::Error) -> Self { + Self::Network(format!("IO Error: {ioe:?}")) + } +} + +impl From for RPCError { + fn from(ne: NetError) -> Self { + Self::Network(format!("Net Error: {ne:?}")) + } +} + */ + +pub struct RpcTransport { + pub host: String, + pub port: u16, + pub ssl: bool, + pub username: String, + pub password: String, +} + +impl RpcTransport { + pub fn send Deserialize<'de>>( + &self, + method: &str, + params: Vec, + wallet: Option<&str>, + ) -> RpcResult { + let request = JsonRpcRequest { + jsonrpc: RCP_VERSION.to_string(), + id: RCP_CLIENT_ID.to_string(), + method: method.to_string(), + params: Value::Array(params), + }; + + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .unwrap(); + + //self.client + let response = client + .post(&self.build_url(wallet)) + .header("Authorization", self.auth_header()) + .json(&request) + .send() + .map_err(|err| RpcError::Network(err.to_string()))?; + + let parsed: JsonRpcResponse = response + .json() + .map_err(|e| RpcError::Parsing(format!("Failed to parse RPC response: {}", e)))?; + + match (parsed.result, parsed.error) { + (Some(result), None) => Ok(result), + (_, Some(err)) => Err(RpcError::Service(format!("{:#}", err))), + _ => Err(RpcError::Parsing("Missing both result and error".into())), + } + } + + fn build_url(&self, wallet_opt: Option<&str>) -> String { + let protocol = if self.ssl { "https" } else { "http" }; + let mut url = format!("{}://{}:{}", protocol, self.host, self.port); + if let Some(wallet) = wallet_opt { + url.push_str(&format!("/wallet/{}", wallet)); + } + url + } + + fn auth_header(&self) -> String { + let credentials = format!("{}:{}", self.username, self.password); + format!("Basic {}", encode(credentials)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + mod utils { + use super::*; + + pub fn setup_transport(server: &mockito::ServerGuard) -> RpcTransport { + let url = server.url(); + let parsed = url::Url::parse(&url).unwrap(); + RpcTransport { + host: parsed.host_str().unwrap().to_string(), + port: parsed.port_or_known_default().unwrap(), + ssl: parsed.scheme() == "https", + username: "user".into(), + password: "pass".into(), + } + } + } + + #[test] + fn test_send_with_string_result_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "some_method", + "params": ["param1"] + }); + + let response_body = json!({ + "result": "some_result", + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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::setup_transport(&server); + + let result: RpcResult = transport.send("some_method", vec!["param1".into()], None); + assert_eq!(result.unwrap(), "some_result"); + } + + #[test] + fn test_send_fails_with_network_error() { + let transport = RpcTransport { + host: "127.0.0.1".into(), + port: 65535, // assuming nothing is listening here + ssl: false, + username: "user".into(), + password: "pass".into(), + }; + + let result: RpcResult = transport.send("dummy_method", vec![], None); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RpcError::Network(_))); + } + + #[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::setup_transport(&server); + let result: RpcResult = transport.send("dummy", vec![], None); + + assert!(result.is_err()); + match result { + Err(RpcError::Parsing(msg)) => { + assert!(msg.starts_with("Failed to parse RPC response:")) + } + _ => panic!("Expected parse error"), + } + } + + #[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::setup_transport(&server); + let result: RpcResult = transport.send("dummy", vec![], None); + + assert!(result.is_err()); + match result { + Err(RpcError::Parsing(msg)) => { + assert!(msg.starts_with("Failed to parse RPC response:")) + } + _ => panic!("Expected parse error"), + } + } + + #[test] + fn test_send_missing_result_and_error() { + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"foo": "bar"}"#) + .create(); + + let transport = utils::setup_transport(&server); + let result: RpcResult = transport.send("dummy", vec![], None); + + match result { + Err(RpcError::Parsing(msg)) => assert_eq!("Missing both result and error", msg), + _ => panic!("Expected missing result/error error"), + } + } + + #[test] + fn test_send_fails_with_service_error() { + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body( + r#"{ + "result": null, + "error": { + "code": -32601, + "message": "Method not found" + } + }"#, + ) + .create(); + + let transport = utils::setup_transport(&server); + let result: RpcResult = transport.send("unknown_method", vec![], None); + + match result { + Err(RpcError::Service(msg)) => assert_eq!( + "{\n \"code\": -32601,\n \"message\": \"Method not found\"\n}", + msg + ), + _ => panic!("Expected service error"), + } + } +} From 1121105c6f1e17b8408ea49edc6b6261d0d586f4 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 9 Jul 2025 13:05:29 +0200 Subject: [PATCH 05/62] refactor: reviewed rpc endpoint, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 724 ++++++++++++++---- .../src/burnchains/rpc_transport.rs | 50 +- 2 files changed, 601 insertions(+), 173 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 1c9904cd01..f04a37ddc2 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -1,10 +1,22 @@ use serde_json::{json, Value}; +use stacks::config::Config; use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; +#[derive(Debug, Clone, Deserialize)] +pub struct TransactionInfo { + pub confirmations: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DescriptorInfo { + pub checksum: String, +} + pub struct BitcoinRpcClient { - transport: RpcTransport, + global_ep: RpcTransport, + wallet_ep: RpcTransport, } impl BitcoinRpcClient { @@ -14,33 +26,53 @@ impl BitcoinRpcClient { ssl: bool, username: String, password: String, + wallet_name: String, ) -> Self { + + let protocol = if ssl { "https" } else { "http" }; + let global_path = format!("{protocol}://{host}:{port}"); + let wallet_path = format!("{global_path}/wallet/{wallet_name}"); + + Self { + global_ep: RpcTransport::new(global_path, username.clone(), password.clone()), + wallet_ep: RpcTransport::new(wallet_path, username, password), + } + } + + //TODO: check config and eventually return Result + pub fn from_stx_config(config: &Config) -> Self { + let host = config.burnchain.peer_host.clone(); + let port = config.burnchain.rpc_port; + let ssl = config.burnchain.rpc_ssl; + let username = config.burnchain.username.clone().unwrap(); + let password = config.burnchain.password.clone().unwrap(); + let wallet_name = config.burnchain.wallet_name.clone(); + + let protocol = if ssl { "https" } else { "http" }; + let global_path = format!("{protocol}://{host}:{port}"); + let wallet_path = format!("{global_path}/wallet/{wallet_name}"); + Self { - transport: RpcTransport { - host, - port, - ssl, - username, - password, - }, + global_ep: RpcTransport::new(global_path, username.clone(), password.clone()), + wallet_ep: RpcTransport::new(wallet_path, username, password), } } pub fn create_wallet(&self, wallet_name: &str) -> RpcResult<()> { let disable_private_keys = true; - self.transport.send::( + self.global_ep.send::( "createwallet", vec![wallet_name.into(), disable_private_keys.into()], - None, )?; Ok(()) } pub fn list_wallets(&self) -> RpcResult> { - self.transport.send("listwallets", vec![], None) + self.global_ep.send("listwallets", vec![]) } + //TODO: Add wallet pub fn list_unspent( &self, addresses: Vec, @@ -53,7 +85,7 @@ impl BitcoinRpcClient { let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_amount); let maximum_count = maximum_count; - let raw_utxos: Vec = self.transport.send( + let raw_utxos: Vec = self.wallet_ep.send( "listunspent", vec![ min_conf.into(), @@ -64,8 +96,7 @@ impl BitcoinRpcClient { "minimumAmount": minimum_amount, "maximumCount": maximum_count }), - ], - None, + ] )?; let mut result = vec![]; @@ -99,146 +130,557 @@ impl BitcoinRpcClient { Ok(result) } + + pub fn generate_to_address(&self, num_block: u64, address: &str) -> RpcResult<()> { + self.global_ep.send::( + "generatetoaddress", + vec![num_block.into(), address.into()], + )?; + Ok(()) + } + + pub fn get_transaction(&self, txid: &str) -> RpcResult { + self.wallet_ep.send( + "gettransaction", + vec![txid.into()], + ) + } + + /// Broadcasts a raw transaction to the Bitcoin network. + /// + /// This method sends a hex-encoded raw Bitcoin transaction using the + /// `sendrawtransaction` RPC endpoint. It supports optional limits for the + /// maximum fee rate and maximum burn amount to prevent accidental overspending. + /// + /// # Arguments + /// + /// * `tx` - A hex-encoded string 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 + /// + /// * On success, returns the transaction ID (`txid`) as a `String`. + /// + /// # Errors + /// + /// Returns an `RpcError` if the RPC call fails, the transaction is invalid, + /// or if fee or burn limits are exceeded. + pub fn send_raw_transaction(&self, tx: &str, max_fee_rate: Option, max_burn_amount: Option) -> RpcResult { + let max_fee_rate = max_fee_rate.unwrap_or(0.10); + let max_burn_amount = max_burn_amount.unwrap_or(0); + + self.global_ep.send( + "sendrawtransaction", + vec![tx.into(), max_fee_rate.into(), max_burn_amount.into()], + ) + } + + /// Get descriptor info by address + /// Wraps the descriptor in `addr(...)` before sending. + pub fn get_descriptor_info(&self, address: &str) -> RpcResult { + let addr = format!("addr({})", address); + self.global_ep.send( + "getdescriptorinfo", + vec![addr.into()] + ) + } + + //TODO REMOVE: + pub fn get_blockchaininfo(&self) -> RpcResult<()> { + self.global_ep.send::( + "getblockchaininfo", + vec![], + )?; + Ok(()) + } } #[cfg(test)] -mod unit_tests { +impl BitcoinRpcClient { + pub fn get_raw_transaction(&self, txid: &str) -> RpcResult { + self.global_ep.send( + "getrawtransaction", + vec![txid.into()], + ) + } - use serde_json::json; - use stacks::util::hash::to_hex; + pub fn generate_block(&self, address: &str, tx_ids: Vec) -> RpcResult<()> { + self.global_ep.send::( + "generateblock", + vec![address.into(), tx_ids.into()], + )?; + Ok(()) + } +} +#[cfg(test)] +mod tests { use super::*; + #[cfg(test)] + mod unit { + + use serde_json::json; + use stacks::util::hash::to_hex; - mod utils { use super::*; - pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { - let url = server.url(); - let parsed = url::Url::parse(&url).unwrap(); + mod utils { + use super::*; + + pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { + let url = server.url(); + let parsed = url::Url::parse(&url).unwrap(); + + BitcoinRpcClient::from_params( + parsed.host_str().unwrap().to_string(), + parsed.port_or_known_default().unwrap(), + parsed.scheme() == "https", + "user".into(), + "pass".into(), + "mywallet".into() + ) + } + } + + #[test] + fn test_create_wallet_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "createwallet", + "params": ["testwallet", true] + }); - BitcoinRpcClient::from_params( - parsed.host_str().unwrap().to_string(), - parsed.port_or_known_default().unwrap(), - parsed.scheme() == "https", - "user".into(), - "pass".into(), - ) + let mut server: mockito::ServerGuard = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"result":true,"error":null}"#) + .create(); + + let client = utils::setup_client(&server); + let result = client.create_wallet("testwallet"); + result.expect("Should work"); } - } - #[test] - fn test_create_wallet_ok() { - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "createwallet", - "params": ["testwallet", true] - }); - - let mut server: mockito::ServerGuard = mockito::Server::new(); - let _m = server - .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") - .match_body(mockito::Matcher::PartialJson(expected_request.clone())) - .with_status(200) - .with_header("Content-Type", "application/json") - .with_body(r#"{"result":true,"error":null}"#) - .create(); - - let client = utils::setup_client(&server); - let result = client.create_wallet("testwallet"); - result.expect("Should work"); - } + #[test] + fn test_list_wallets_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listwallets", + "params": [] + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_body(mockito::Matcher::PartialJson(expected_request.clone())) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"result":["wallet1","wallet2"],"error":null}"#) + .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_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listunspent", + "params": [ + 0, + 9999999, + ["BTC_ADDRESS_1"], + true, + { + "minimumAmount": "0.00001000", + "maximumCount": 100 + } + ] + }); + + let mock_response = json!({ + "result": [{ + "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "vout": 0, + "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + "amount": 0.00001, + "confirmations": 6 + }], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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_unspent( + vec!["BTC_ADDRESS_1".into()], + true, + 1000, // 1000 sats = 0.00001000 BTC + 100, + ) + .expect("Should parse unspent outputs"); + + assert_eq!(1, result.len()); + let utxo = &result[0]; + assert_eq!(1000, utxo.amount); + assert_eq!(0, utxo.vout); + assert_eq!(6, utxo.confirmations); + assert_eq!( + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + utxo.txid.to_string(), + ); + assert_eq!( + "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + to_hex(&utxo.script_pub_key.to_bytes()), + ); + } + + #[test] + fn test_generate_to_address() { + // Arrange + let num_blocks = 3; + let address = "00000000000000000000000000000000000000000000000000000"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generatetoaddress", + "params": [num_blocks, address], + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_body(mockito::Matcher::PartialJson(expected_request)) + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"result": true, "error": null}"#) + .create(); + + let client = utils::setup_client(&server); + + let result = client.generate_to_address(num_blocks, address); + assert!(result.is_ok()); + } + + #[test] + fn test_get_transaction_ok() { + let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "gettransaction", + "params": [txid] + }); + + let mock_response = json!({ + "result": { + "confirmations": 6, + }, + "error": null, + //"id": "stacks" + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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_transaction(txid).expect("Should be ok!"); + assert_eq!(6, info.confirmations); + } - #[test] - fn test_list_wallets_ok() { - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "listwallets", - "params": [] - }); - - let mut server = mockito::Server::new(); - let _m = server - .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") - .match_body(mockito::Matcher::PartialJson(expected_request.clone())) - .with_status(200) - .with_header("Content-Type", "application/json") - .with_body(r#"{"result":["wallet1","wallet2"],"error":null}"#) - .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_get_raw_transaction_ok() { + let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + let expected_ser_tx = "000111222333444555666"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getrawtransaction", + "params": [txid] + }); + + let mock_response = json!({ + "result": expected_ser_tx, + "error": null, + //"id": "stacks" + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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 ser_tx = client.get_raw_transaction(txid).expect("Should be ok!"); + assert_eq!(expected_ser_tx, ser_tx); + } + + #[test] + fn test_generate_block_ok() { + let addr = "myaddr"; + let txid1 = "txid1"; + let txid2 = "txid2"; + let expected_block_hash = "block_hash"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generateblock", + "params": [addr, [txid1, txid2]] + }); + + let mock_response = json!({ + "result": { + "hash" : expected_block_hash + }, + "error": null, + //"id": "stacks" + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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.generate_block(addr, vec![txid1.to_string(), txid2.to_string()]).expect("Should be ok!"); + //assert_eq!(expected_ser_tx, ser_tx); + } + + #[test] + fn test_send_raw_transaction_ok_with_defaults() { + let raw_tx = "raw_tx_hex"; + let expected_txid = "txid1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx, 0.10, 0] + }); + + let mock_response = json!({ + "result": expected_txid, + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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 txid = client.send_raw_transaction(raw_tx, None, None).expect("Should work!"); + assert_eq!(txid, expected_txid); + } + + #[test] + fn test_send_raw_transaction_ok_with_custom_params() { + let raw_tx = "raw_tx_hex"; + let expected_txid = "txid1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx, 0.0, 5_000] + }); + + let mock_response = json!({ + "result": expected_txid, + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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 txid = client.send_raw_transaction(raw_tx, Some(0.0), Some(5_000)).expect("Should work!"); + assert_eq!(txid, expected_txid); + } + + #[test] + fn test_get_descriptor_info_ok() { + let address = "bc1_address"; + let expected_checksum = "mychecksum"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getdescriptorinfo", + "params": [format!("addr({address})")] + }); + + let mock_response = json!({ + "result": { + "checksum": expected_checksum + }, + "error": null, + //"id": "stacks" + }); + + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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(address).expect("Should work!"); + assert_eq!(expected_checksum, info.checksum); + } } - #[test] - fn test_list_unspent_ok() { - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "listunspent", - "params": [ - 0, - 9999999, - ["BTC_ADDRESS_1"], - true, - { - "minimumAmount": "0.00001000", - "maximumCount": 100 - } - ] - }); - - let response_body = json!({ - "result": [{ - "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - "vout": 0, - "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - "amount": 0.00001, - "confirmations": 6 - }], - "error": null - }); - - let mut server = mockito::Server::new(); - let _m = server - .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") - .match_body(mockito::Matcher::PartialJson(expected_request.clone())) - .with_status(200) - .with_header("Content-Type", "application/json") - .with_body(response_body.to_string()) - .create(); - - let client = utils::setup_client(&server); - - let result = client - .list_unspent( - vec!["BTC_ADDRESS_1".into()], - true, - 1000, // 1000 sats = 0.00001000 BTC - 100, - ) - .expect("Should parse unspent outputs"); - - assert_eq!(1, result.len()); - let utxo = &result[0]; - assert_eq!(1000, utxo.amount); - assert_eq!(0, utxo.vout); - assert_eq!(6, utxo.confirmations); - assert_eq!( - "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - utxo.txid.to_string(), - ); - assert_eq!( - "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - to_hex(&utxo.script_pub_key.to_bytes()), - ); + #[cfg(test)] + mod inte { + use super::*; + + use crate::tests::bitcoin_regtest::BitcoinCoreController; + + mod utils { + use std::net::TcpListener; + use stacks::config::Config; + + use crate::util::get_epoch_time_ms; + + pub fn create_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 + } + } + + #[test] + fn test_wallet_listing_and_creation_ok() { + let config = utils::create_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); + + let wallets = client.list_wallets().unwrap(); + assert_eq!(0, wallets.len()); + + client.create_wallet("mywallet1").unwrap(); + + let wallets = client.list_wallets().unwrap(); + assert_eq!(1, wallets.len()); + assert_eq!("mywallet1", wallets[0]); + + client.create_wallet("mywallet2").unwrap(); + + let wallets = client.list_wallets().unwrap(); + assert_eq!(2, wallets.len()); + assert_eq!("mywallet1", wallets[0]); + assert_eq!("mywallet2", wallets[1]); + } + + + #[test] + fn test_generate_to_address_and_list_unspent_ok() { + let config = utils::create_config(); + //let miner_pub_key = config.burnchain.local_mining_public_key.clone().unwrap(); + + let mut btcd_controller = BitcoinCoreController::new(config.clone()); + btcd_controller + .start_bitcoind() + .expect("bitcoind should be started!"); + + let client = BitcoinRpcClient::from_stx_config(&config); + client.create_wallet("hello1").expect("OK"); + //client.create_wallet("hello2").expect("OK"); + //client.generate_to_address(64, address) + //client.get_transaction("1", "hello1").expect("Boh"); + client.get_blockchaininfo().expect("Boh"); + } + } -} +} \ No newline at end of file diff --git a/testnet/stacks-node/src/burnchains/rpc_transport.rs b/testnet/stacks-node/src/burnchains/rpc_transport.rs index 8126352624..fbe0349eeb 100644 --- a/testnet/stacks-node/src/burnchains/rpc_transport.rs +++ b/testnet/stacks-node/src/burnchains/rpc_transport.rs @@ -48,19 +48,20 @@ impl From for RPCError { */ pub struct RpcTransport { - pub host: String, - pub port: u16, - pub ssl: bool, + pub url: String, pub username: String, pub password: String, } impl RpcTransport { + pub fn new(url: String, username: String, password: String) -> Self { + RpcTransport { url, username, password } + } + pub fn send Deserialize<'de>>( &self, method: &str, params: Vec, - wallet: Option<&str>, ) -> RpcResult { let request = JsonRpcRequest { jsonrpc: RCP_VERSION.to_string(), @@ -76,7 +77,7 @@ impl RpcTransport { //self.client let response = client - .post(&self.build_url(wallet)) + .post(&self.url) .header("Authorization", self.auth_header()) .json(&request) .send() @@ -93,15 +94,6 @@ impl RpcTransport { } } - fn build_url(&self, wallet_opt: Option<&str>) -> String { - let protocol = if self.ssl { "https" } else { "http" }; - let mut url = format!("{}://{}:{}", protocol, self.host, self.port); - if let Some(wallet) = wallet_opt { - url.push_str(&format!("/wallet/{}", wallet)); - } - url - } - fn auth_header(&self) -> String { let credentials = format!("{}:{}", self.username, self.password); format!("Basic {}", encode(credentials)) @@ -118,12 +110,8 @@ mod tests { use super::*; pub fn setup_transport(server: &mockito::ServerGuard) -> RpcTransport { - let url = server.url(); - let parsed = url::Url::parse(&url).unwrap(); RpcTransport { - host: parsed.host_str().unwrap().to_string(), - port: parsed.port_or_known_default().unwrap(), - ssl: parsed.scheme() == "https", + url: server.url(), username: "user".into(), password: "pass".into(), } @@ -156,21 +144,19 @@ mod tests { let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("some_method", vec!["param1".into()], None); + let result: RpcResult = transport.send("some_method", vec!["param1".into()]); assert_eq!(result.unwrap(), "some_result"); } #[test] fn test_send_fails_with_network_error() { - let transport = RpcTransport { - host: "127.0.0.1".into(), - port: 65535, // assuming nothing is listening here - ssl: false, - username: "user".into(), - password: "pass".into(), - }; + let transport = RpcTransport::new( + "http://127.0.0.1:65535".to_string(), + "user".to_string(), + "pass".to_string(), + ); - let result: RpcResult = transport.send("dummy_method", vec![], None); + let result: RpcResult = transport.send("dummy_method", vec![]); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), RpcError::Network(_))); } @@ -185,7 +171,7 @@ mod tests { .create(); let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("dummy", vec![], None); + let result: RpcResult = transport.send("dummy", vec![]); assert!(result.is_err()); match result { @@ -207,7 +193,7 @@ mod tests { .create(); let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("dummy", vec![], None); + let result: RpcResult = transport.send("dummy", vec![]); assert!(result.is_err()); match result { @@ -229,7 +215,7 @@ mod tests { .create(); let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("dummy", vec![], None); + let result: RpcResult = transport.send("dummy", vec![]); match result { Err(RpcError::Parsing(msg)) => assert_eq!("Missing both result and error", msg), @@ -256,7 +242,7 @@ mod tests { .create(); let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("unknown_method", vec![], None); + let result: RpcResult = transport.send("unknown_method", vec![]); match result { Err(RpcError::Service(msg)) => assert_eq!( From 4ce80857f488ae620bb56a4c84e8c4eb5ffffc89 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 9 Jul 2025 16:09:32 +0200 Subject: [PATCH 06/62] refactor: add import_descriptor and get_descriptor_info, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 73 ++++++++++++++++--- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index f04a37ddc2..e78d995b11 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -5,12 +5,12 @@ use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; #[derive(Debug, Clone, Deserialize)] -pub struct TransactionInfo { +pub struct TransactionInfoResult { pub confirmations: u32, } #[derive(Debug, Clone, Deserialize)] -pub struct DescriptorInfo { +pub struct DescriptorInfoResult { pub checksum: String, } @@ -139,7 +139,7 @@ impl BitcoinRpcClient { Ok(()) } - pub fn get_transaction(&self, txid: &str) -> RpcResult { + pub fn get_transaction(&self, txid: &str) -> RpcResult { self.wallet_ep.send( "gettransaction", vec![txid.into()], @@ -180,16 +180,28 @@ impl BitcoinRpcClient { ) } - /// Get descriptor info by address - /// Wraps the descriptor in `addr(...)` before sending. - pub fn get_descriptor_info(&self, address: &str) -> RpcResult { - let addr = format!("addr({})", address); + pub fn get_descriptor_info(&self, descriptor: &str) -> RpcResult { self.global_ep.send( "getdescriptorinfo", - vec![addr.into()] + vec![descriptor.into()] ) } + //TODO: Improve with descriptor_list + pub fn import_descriptor(&self, descriptor: &str) -> RpcResult<()> { + //let addr = format!("addr({})", address); + let timestamp = 0; + let internal = true; + + self.global_ep.send::( + "importdescriptors", + vec![ + json!([{ "desc": descriptor, "timestamp": timestamp, "internal": internal }]), + ] + )?; + Ok(()) + } + //TODO REMOVE: pub fn get_blockchaininfo(&self) -> RpcResult<()> { self.global_ep.send::( @@ -564,13 +576,14 @@ mod tests { #[test] fn test_get_descriptor_info_ok() { let address = "bc1_address"; + let descriptor = format!("addr({address})"); let expected_checksum = "mychecksum"; let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", "method": "getdescriptorinfo", - "params": [format!("addr({address})")] + "params": [descriptor] }); let mock_response = json!({ @@ -591,9 +604,49 @@ mod tests { .create(); let client = utils::setup_client(&server); - let info = client.get_descriptor_info(address).expect("Should work!"); + let info = client.get_descriptor_info(&descriptor).expect("Should work!"); assert_eq!(expected_checksum, info.checksum); } + + #[test] + fn test_import_descriptor_ok() { + let descriptor = "addr(1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa)#checksum"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "importdescriptors", + "params": [ + [{ + "desc": descriptor, + "timestamp": 0, + "internal": true + }] + ] + }); + + let mock_response = json!({ + "result": [{ + "success": true, + "warnings": [] + }], + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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.import_descriptor(&descriptor); + assert!(result.is_ok()); + + } } #[cfg(test)] From b855f256278d8d26392e8f56398fc56ab09c6b80 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 9 Jul 2025 16:26:51 +0200 Subject: [PATCH 07/62] refactor: add stop rpc, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index e78d995b11..c0aea02827 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -228,6 +228,24 @@ impl BitcoinRpcClient { )?; Ok(()) } + + /// Gracefully shuts down the Bitcoin Core node. + /// + /// Sends the `"stop"` RPC command using the global endpoint to request that `bitcoind` shuts down + /// cleanly. This includes flushing the mempool, writing state to disk, and terminating the process. + /// + /// # Returns + /// On success, returns the string: + /// `"Bitcoin Core stopping"` + /// + /// # Errors + /// Returns an error if the RPC command fails (e.g., connection issue or insufficient permissions). + pub fn stop(&self) -> RpcResult { + self.global_ep.send( + "stop", + vec![], + ) + } } #[cfg(test)] @@ -644,8 +662,35 @@ mod tests { let client = utils::setup_client(&server); let result = client.import_descriptor(&descriptor); - assert!(result.is_ok()); - + 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!({ + "result": "Bitcoin Core stopping", + "error": null + }); + + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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); } } @@ -716,7 +761,6 @@ mod tests { assert_eq!("mywallet2", wallets[1]); } - #[test] fn test_generate_to_address_and_list_unspent_ok() { let config = utils::create_config(); @@ -735,5 +779,18 @@ mod tests { client.get_blockchaininfo().expect("Boh"); } + #[test] + fn test_stop_bitcoind_ok() { + let config = utils::create_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); + let msg = client.stop().expect("Should shutdown!"); + assert_eq!("Bitcoin Core stopping", msg); + } } } \ No newline at end of file From 61644716b3b479e553a995c77d3b22f5b0542037 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 10 Jul 2025 17:47:21 +0200 Subject: [PATCH 08/62] test: add rpc integration tests, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 235 ++++++++++++------ .../src/burnchains/rpc_transport.rs | 6 +- 2 files changed, 160 insertions(+), 81 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index c0aea02827..d291038dbe 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -1,19 +1,24 @@ use serde_json::{json, Value}; use stacks::config::Config; -use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; +use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; #[derive(Debug, Clone, Deserialize)] -pub struct TransactionInfoResult { +pub struct TransactionInfoResponse { pub confirmations: u32, } #[derive(Debug, Clone, Deserialize)] -pub struct DescriptorInfoResult { +pub struct DescriptorInfoResponse { pub checksum: String, } +#[derive(Debug, Clone, Deserialize)] +struct GenerateBlockResponse { + hash: String, +} + pub struct BitcoinRpcClient { global_ep: RpcTransport, wallet_ep: RpcTransport, @@ -28,11 +33,10 @@ impl BitcoinRpcClient { password: String, wallet_name: String, ) -> Self { - let protocol = if ssl { "https" } else { "http" }; let global_path = format!("{protocol}://{host}:{port}"); let wallet_path = format!("{global_path}/wallet/{wallet_name}"); - + Self { global_ep: RpcTransport::new(global_path, username.clone(), password.clone()), wallet_ep: RpcTransport::new(wallet_path, username, password), @@ -47,19 +51,23 @@ impl BitcoinRpcClient { let username = config.burnchain.username.clone().unwrap(); let password = config.burnchain.password.clone().unwrap(); let wallet_name = config.burnchain.wallet_name.clone(); - + let protocol = if ssl { "https" } else { "http" }; let global_path = format!("{protocol}://{host}:{port}"); let wallet_path = format!("{global_path}/wallet/{wallet_name}"); - + Self { global_ep: RpcTransport::new(global_path, username.clone(), password.clone()), wallet_ep: RpcTransport::new(wallet_path, username, password), } } - pub fn create_wallet(&self, wallet_name: &str) -> RpcResult<()> { - let disable_private_keys = true; + pub fn create_wallet( + &self, + wallet_name: &str, + disable_private_keys: Option, + ) -> RpcResult<()> { + let disable_private_keys = disable_private_keys.unwrap_or(false); self.global_ep.send::( "createwallet", @@ -96,7 +104,7 @@ impl BitcoinRpcClient { "minimumAmount": minimum_amount, "maximumCount": maximum_count }), - ] + ], )?; let mut result = vec![]; @@ -131,19 +139,13 @@ impl BitcoinRpcClient { Ok(result) } - pub fn generate_to_address(&self, num_block: u64, address: &str) -> RpcResult<()> { - self.global_ep.send::( - "generatetoaddress", - vec![num_block.into(), address.into()], - )?; - Ok(()) + pub fn generate_to_address(&self, num_block: u64, address: &str) -> RpcResult> { + self.global_ep + .send("generatetoaddress", vec![num_block.into(), address.into()]) } - pub fn get_transaction(&self, txid: &str) -> RpcResult { - self.wallet_ep.send( - "gettransaction", - vec![txid.into()], - ) + pub fn get_transaction(&self, txid: &str) -> RpcResult { + self.wallet_ep.send("gettransaction", vec![txid.into()]) } /// Broadcasts a raw transaction to the Bitcoin network. @@ -170,21 +172,24 @@ impl BitcoinRpcClient { /// /// Returns an `RpcError` if the RPC call fails, the transaction is invalid, /// or if fee or burn limits are exceeded. - pub fn send_raw_transaction(&self, tx: &str, max_fee_rate: Option, max_burn_amount: Option) -> RpcResult { + pub fn send_raw_transaction( + &self, + tx: &str, + max_fee_rate: Option, + max_burn_amount: Option, + ) -> RpcResult { let max_fee_rate = max_fee_rate.unwrap_or(0.10); let max_burn_amount = max_burn_amount.unwrap_or(0); self.global_ep.send( - "sendrawtransaction", - vec![tx.into(), max_fee_rate.into(), max_burn_amount.into()], + "sendrawtransaction", + vec![tx.into(), max_fee_rate.into(), max_burn_amount.into()], ) } - pub fn get_descriptor_info(&self, descriptor: &str) -> RpcResult { - self.global_ep.send( - "getdescriptorinfo", - vec![descriptor.into()] - ) + pub fn get_descriptor_info(&self, descriptor: &str) -> RpcResult { + self.global_ep + .send("getdescriptorinfo", vec![descriptor.into()]) } //TODO: Improve with descriptor_list @@ -195,19 +200,14 @@ impl BitcoinRpcClient { self.global_ep.send::( "importdescriptors", - vec![ - json!([{ "desc": descriptor, "timestamp": timestamp, "internal": internal }]), - ] + vec![json!([{ "desc": descriptor, "timestamp": timestamp, "internal": internal }])], )?; Ok(()) } //TODO REMOVE: pub fn get_blockchaininfo(&self) -> RpcResult<()> { - self.global_ep.send::( - "getblockchaininfo", - vec![], - )?; + self.global_ep.send::("getblockchaininfo", vec![])?; Ok(()) } } @@ -215,18 +215,14 @@ impl BitcoinRpcClient { #[cfg(test)] impl BitcoinRpcClient { pub fn get_raw_transaction(&self, txid: &str) -> RpcResult { - self.global_ep.send( - "getrawtransaction", - vec![txid.into()], - ) + self.global_ep.send("getrawtransaction", vec![txid.into()]) } - pub fn generate_block(&self, address: &str, tx_ids: Vec) -> RpcResult<()> { - self.global_ep.send::( - "generateblock", - vec![address.into(), tx_ids.into()], - )?; - Ok(()) + pub fn generate_block(&self, address: &str, tx_ids: Vec) -> RpcResult { + let response = self + .global_ep + .send::("generateblock", vec![address.into(), tx_ids.into()])?; + Ok(response.hash) } /// Gracefully shuts down the Bitcoin Core node. @@ -241,10 +237,19 @@ impl BitcoinRpcClient { /// # Errors /// Returns an error if the RPC command fails (e.g., connection issue or insufficient permissions). pub fn stop(&self) -> RpcResult { - self.global_ep.send( - "stop", - vec![], - ) + self.global_ep.send("stop", vec![]) + } + + pub fn get_new_address( + &self, + label: Option<&str>, + address_type: Option<&str>, + ) -> RpcResult { + let label = label.unwrap_or(""); + let address_type = address_type.unwrap_or("legacy"); //default NULL (serde_json::Value::Null) + + self.global_ep + .send("getnewaddress", vec![label.into(), address_type.into()]) } } @@ -272,7 +277,7 @@ mod tests { parsed.scheme() == "https", "user".into(), "pass".into(), - "mywallet".into() + "mywallet".into(), ) } } @@ -297,7 +302,7 @@ mod tests { .create(); let client = utils::setup_client(&server); - let result = client.create_wallet("testwallet"); + let result = client.create_wallet("testwallet", Some(false)); result.expect("Should work"); } @@ -406,20 +411,32 @@ mod tests { "params": [num_blocks, address], }); + let mock_response = json!({ + "result": [ + "block_hash1", + "block_hash2", + ], + "error": null + }); + let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") + .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") - .with_body(r#"{"result": true, "error": null}"#) + .with_body(mock_response.to_string()) .create(); let client = utils::setup_client(&server); - let result = client.generate_to_address(num_blocks, address); - assert!(result.is_ok()); + let result = client + .generate_to_address(num_blocks, address) + .expect("Should work!"); + assert_eq!(2, result.len()); + assert_eq!("block_hash1", result[0]); + assert_eq!("block_hash2", result[1]); } #[test] @@ -525,10 +542,12 @@ mod tests { let client = utils::setup_client(&server); - client.generate_block(addr, vec![txid1.to_string(), txid2.to_string()]).expect("Should be ok!"); - //assert_eq!(expected_ser_tx, ser_tx); + let result = client + .generate_block(addr, vec![txid1.to_string(), txid2.to_string()]) + .expect("Should be ok!"); + assert_eq!(expected_block_hash, result); } - + #[test] fn test_send_raw_transaction_ok_with_defaults() { let raw_tx = "raw_tx_hex"; @@ -547,7 +566,8 @@ mod tests { }); let mut server = mockito::Server::new(); - let _m = server.mock("POST", "/") + let _m = server + .mock("POST", "/") .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) @@ -556,7 +576,9 @@ mod tests { .create(); let client = utils::setup_client(&server); - let txid = client.send_raw_transaction(raw_tx, None, None).expect("Should work!"); + let txid = client + .send_raw_transaction(raw_tx, None, None) + .expect("Should work!"); assert_eq!(txid, expected_txid); } @@ -578,7 +600,8 @@ mod tests { }); let mut server = mockito::Server::new(); - let _m = server.mock("POST", "/") + let _m = server + .mock("POST", "/") .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) @@ -587,7 +610,9 @@ mod tests { .create(); let client = utils::setup_client(&server); - let txid = client.send_raw_transaction(raw_tx, Some(0.0), Some(5_000)).expect("Should work!"); + let txid = client + .send_raw_transaction(raw_tx, Some(0.0), Some(5_000)) + .expect("Should work!"); assert_eq!(txid, expected_txid); } @@ -613,7 +638,8 @@ mod tests { }); let mut server = mockito::Server::new(); - let _m = server.mock("POST", "/") + let _m = server + .mock("POST", "/") .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) @@ -622,14 +648,16 @@ mod tests { .create(); let client = utils::setup_client(&server); - let info = client.get_descriptor_info(&descriptor).expect("Should work!"); + let info = client + .get_descriptor_info(&descriptor) + .expect("Should work!"); assert_eq!(expected_checksum, info.checksum); } #[test] fn test_import_descriptor_ok() { let descriptor = "addr(1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa)#checksum"; - + let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", @@ -652,7 +680,8 @@ mod tests { }); let mut server = mockito::Server::new(); - let _m = server.mock("POST", "/") + let _m = server + .mock("POST", "/") .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) @@ -662,9 +691,9 @@ mod tests { let client = utils::setup_client(&server); let result = client.import_descriptor(&descriptor); - assert!(result.is_ok()); + assert!(result.is_ok()); } - + #[test] fn test_stop_ok() { let expected_request = json!({ @@ -680,7 +709,8 @@ mod tests { }); let mut server = mockito::Server::new(); - let _m = server.mock("POST", "/") + let _m = server + .mock("POST", "/") .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) @@ -690,18 +720,18 @@ mod tests { let client = utils::setup_client(&server); let result = client.stop().expect("Should work!"); - assert_eq!("Bitcoin Core stopping", result); + assert_eq!("Bitcoin Core stopping", result); } } #[cfg(test)] mod inte { use super::*; - use crate::tests::bitcoin_regtest::BitcoinCoreController; mod utils { use std::net::TcpListener; + use stacks::config::Config; use crate::util::get_epoch_time_ms; @@ -747,13 +777,13 @@ mod tests { let wallets = client.list_wallets().unwrap(); assert_eq!(0, wallets.len()); - client.create_wallet("mywallet1").unwrap(); + client.create_wallet("mywallet1", Some(false)).unwrap(); let wallets = client.list_wallets().unwrap(); assert_eq!(1, wallets.len()); assert_eq!("mywallet1", wallets[0]); - client.create_wallet("mywallet2").unwrap(); + client.create_wallet("mywallet2", Some(false)).unwrap(); let wallets = client.list_wallets().unwrap(); assert_eq!(2, wallets.len()); @@ -763,8 +793,8 @@ mod tests { #[test] fn test_generate_to_address_and_list_unspent_ok() { - let config = utils::create_config(); - //let miner_pub_key = config.burnchain.local_mining_public_key.clone().unwrap(); + let mut config = utils::create_config(); + config.burnchain.wallet_name = "my_wallet".to_string(); let mut btcd_controller = BitcoinCoreController::new(config.clone()); btcd_controller @@ -772,13 +802,58 @@ mod tests { .expect("bitcoind should be started!"); let client = BitcoinRpcClient::from_stx_config(&config); - client.create_wallet("hello1").expect("OK"); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + let address = client.get_new_address(None, None).expect("Should work!"); + + let utxos = client + .list_unspent(vec![], false, 1, 10) + .expect("list_unspent should be ok!"); + assert_eq!(0, utxos.len()); + + let blocks = client.generate_to_address(102, &address).expect("OK"); + assert_eq!(102, blocks.len()); + + let utxos = client + .list_unspent(vec![], false, 1, 10) + .expect("list_unspent should be ok!"); + assert_eq!(2, utxos.len()); + + let utxos = client + .list_unspent(vec![], false, 1, 1) + .expect("list_unspent should be ok!"); + assert_eq!(1, utxos.len()); + + //client.create_wallet("hello1").expect("OK"); //client.create_wallet("hello2").expect("OK"); //client.generate_to_address(64, address) //client.get_transaction("1", "hello1").expect("Boh"); - client.get_blockchaininfo().expect("Boh"); + //client.get_blockchaininfo().expect("Boh"); } - + + #[test] + fn test_generate_block_ok() { + let mut config = utils::create_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); + client.create_wallet("my_wallet", Some(false)).expect("OK"); + let address = client.get_new_address(None, None).expect("Should work!"); + + let block_hash = client.generate_block(&address, vec![]).expect("OK"); + assert_eq!(64, block_hash.len()); + + //client.create_wallet("hello1").expect("OK"); + //client.create_wallet("hello2").expect("OK"); + //client.generate_to_address(64, address) + //client.get_transaction("1", "hello1").expect("Boh"); + //client.get_blockchaininfo().expect("Boh"); + } + #[test] fn test_stop_bitcoind_ok() { let config = utils::create_config(); @@ -793,4 +868,4 @@ mod tests { assert_eq!("Bitcoin Core stopping", msg); } } -} \ No newline at end of file +} diff --git a/testnet/stacks-node/src/burnchains/rpc_transport.rs b/testnet/stacks-node/src/burnchains/rpc_transport.rs index fbe0349eeb..083da5c219 100644 --- a/testnet/stacks-node/src/burnchains/rpc_transport.rs +++ b/testnet/stacks-node/src/burnchains/rpc_transport.rs @@ -55,7 +55,11 @@ pub struct RpcTransport { impl RpcTransport { pub fn new(url: String, username: String, password: String) -> Self { - RpcTransport { url, username, password } + RpcTransport { + url, + username, + password, + } } pub fn send Deserialize<'de>>( From fed6853185d5503e6ba353ed35bd96dca72d398e Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 11 Jul 2025 13:08:53 +0200 Subject: [PATCH 09/62] test: add integration tests, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 144 ++++++++++++++++-- 1 file changed, 133 insertions(+), 11 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index d291038dbe..d7e98db08b 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -5,7 +5,7 @@ use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; #[derive(Debug, Clone, Deserialize)] -pub struct TransactionInfoResponse { +pub struct GetTransactionResponse { pub confirmations: u32, } @@ -144,7 +144,7 @@ impl BitcoinRpcClient { .send("generatetoaddress", vec![num_block.into(), address.into()]) } - pub fn get_transaction(&self, txid: &str) -> RpcResult { + pub fn get_transaction(&self, txid: &str) -> RpcResult { self.wallet_ep.send("gettransaction", vec![txid.into()]) } @@ -240,16 +240,48 @@ impl BitcoinRpcClient { self.global_ep.send("stop", vec![]) } + /// Get a new Bitcoin address from the wallet. + /// + /// # Arguments + /// + /// * `label` - Optional label to associate with the address. + /// * `address_type` - Optional address type ("legacy", "p2sh-segwit", "bech32", "bech32m"). + /// + /// # Returns + /// + /// A string representing the new Bitcoin address. pub fn get_new_address( &self, label: Option<&str>, address_type: Option<&str>, ) -> RpcResult { + let mut params = vec![]; + let label = label.unwrap_or(""); - let address_type = address_type.unwrap_or("legacy"); //default NULL (serde_json::Value::Null) + params.push(label.into()); + + if let Some(at) = address_type { + params.push(at.into()); + } self.global_ep - .send("getnewaddress", vec![label.into(), address_type.into()]) + .send("getnewaddress", params) + } + + /// Sends a specified amount of BTC to a given address. + /// + /// # Arguments + /// * `address` - The destination Bitcoin address. + /// * `amount` - Amount to send in BTC (not in satoshis). + /// + /// # Returns + /// The transaction ID as hex string + pub fn send_to_address( + &self, + address: &str, + amount: f64, + ) -> RpcResult { + self.wallet_ep.send("sendtoaddress", vec![address.into(), amount.into()]) } } @@ -302,7 +334,7 @@ mod tests { .create(); let client = utils::setup_client(&server); - let result = client.create_wallet("testwallet", Some(false)); + let result = client.create_wallet("testwallet", Some(true)); result.expect("Should work"); } @@ -399,7 +431,7 @@ mod tests { } #[test] - fn test_generate_to_address() { + fn test_generate_to_address_ok() { // Arrange let num_blocks = 3; let address = "00000000000000000000000000000000000000000000000000000"; @@ -722,6 +754,74 @@ mod tests { let result = client.stop().expect("Should work!"); assert_eq!("Bitcoin Core stopping", result); } + + #[test] + fn test_get_new_address_ok() { + let expected_address = "btc_addr_1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getnewaddress", + "params": [""] + }); + + let mock_response = json!({ + "result": expected_address, + "error": null, + //"id": "stacks" + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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); + } + + #[test] + fn test_send_to_address_ok() { + let address = "btc_addr_1"; + let amount = 0.5; + let expected_txid = "txid_1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendtoaddress", + "params": [address, amount] + }); + + let mock_response = json!({ + "result": expected_txid, + "error": null, + //"id": "stacks" + }); + + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/wallet/mywallet") + .match_header("authorization", "Basic dXNlcjpwYXNz") + .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 = client.send_to_address(address, amount).expect("Should be ok!"); + assert_eq!(expected_txid, txid); + } } #[cfg(test)] @@ -846,14 +946,36 @@ mod tests { let block_hash = client.generate_block(&address, vec![]).expect("OK"); assert_eq!(64, block_hash.len()); + } - //client.create_wallet("hello1").expect("OK"); - //client.create_wallet("hello2").expect("OK"); - //client.generate_to_address(64, address) - //client.get_transaction("1", "hello1").expect("Boh"); - //client.get_blockchaininfo().expect("Boh"); + #[test] + fn test_get_raw_transaction_ok() { + let mut config = utils::create_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); + client.create_wallet("my_wallet", Some(false)).expect("create wallet ok!"); + let address = client.get_new_address(None, None).expect("get new address ok!"); + + //Create 1 UTXO + _ = client.generate_to_address(101, &address).expect("generate to address ok!"); + + //Need .arg("-fallbackfee=0.0002") + 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_ne!("", raw_tx); + + let resp = client.get_transaction(&txid).expect("get raw transaction ok!"); + assert_eq!(0, resp.confirmations); } + #[test] fn test_stop_bitcoind_ok() { let config = utils::create_config(); From 71e1c9022b3e46d9c55bff0f9af8f4bb90a53a9b Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 11 Jul 2025 15:07:29 +0200 Subject: [PATCH 10/62] test: enhance bitcoin core controller to support custom arg, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 8 +- .../stacks-node/src/tests/bitcoin_regtest.rs | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index d7e98db08b..ee393f382c 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -953,9 +953,10 @@ mod tests { let mut config = utils::create_config(); config.burnchain.wallet_name = "my_wallet".to_string(); - let mut btcd_controller = BitcoinCoreController::new(config.clone()); + let mut btcd_controller = BitcoinCoreController::from_stx_config(config.clone()); btcd_controller - .start_bitcoind() + .add_arg("-fallbackfee=0.0002") + .start_bitcoind_v2() .expect("bitcoind should be started!"); let client = BitcoinRpcClient::from_stx_config(&config); @@ -965,7 +966,7 @@ mod tests { //Create 1 UTXO _ = client.generate_to_address(101, &address).expect("generate to address ok!"); - //Need .arg("-fallbackfee=0.0002") + //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!"); @@ -975,7 +976,6 @@ mod tests { assert_eq!(0, resp.confirmations); } - #[test] fn test_stop_bitcoind_ok() { let config = utils::create_config(); diff --git a/testnet/stacks-node/src/tests/bitcoin_regtest.rs b/testnet/stacks-node/src/tests/bitcoin_regtest.rs index 81e1176790..c0f0f13a0d 100644 --- a/testnet/stacks-node/src/tests/bitcoin_regtest.rs +++ b/testnet/stacks-node/src/tests/bitcoin_regtest.rs @@ -33,16 +33,106 @@ type BitcoinResult = Result; pub struct BitcoinCoreController { bitcoind_process: Option, pub config: Config, + args: Vec } impl BitcoinCoreController { + //TODO: to be removed in favor of `from_stx_config` pub fn new(config: Config) -> BitcoinCoreController { BitcoinCoreController { bitcoind_process: None, config, + args: vec![] } } + pub fn from_stx_config(config: Config) -> BitcoinCoreController { + let mut result = BitcoinCoreController { + bitcoind_process: None, + config, + args: vec![] + }; + + //TODO: Remove this once verified if `pub config` is really needed or not. + let config = result.config.clone(); + + 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 + } + + pub fn add_arg(&mut self, arg: impl Into) -> &mut Self { + //TODO: eventually protect againt duplicated arg + self.args.push(arg.into()); + self + } + + 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)); From 27dd024b308b9f334814ed0921035142b9f5cf85 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 11 Jul 2025 15:40:31 +0200 Subject: [PATCH 11/62] test: add get_transaction inte test, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 74 ++++++++++++++----- .../stacks-node/src/tests/bitcoin_regtest.rs | 17 ++--- 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs index ee393f382c..d8170af5a7 100644 --- a/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/testnet/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -260,28 +260,24 @@ impl BitcoinRpcClient { let label = label.unwrap_or(""); params.push(label.into()); - if let Some(at) = address_type { + if let Some(at) = address_type { params.push(at.into()); } - self.global_ep - .send("getnewaddress", params) + self.global_ep.send("getnewaddress", params) } /// Sends a specified amount of BTC to a given address. - /// + /// /// # Arguments /// * `address` - The destination Bitcoin address. /// * `amount` - Amount to send in BTC (not in satoshis). - /// + /// /// # Returns /// The transaction ID as hex string - pub fn send_to_address( - &self, - address: &str, - amount: f64, - ) -> RpcResult { - self.wallet_ep.send("sendtoaddress", vec![address.into(), amount.into()]) + pub fn send_to_address(&self, address: &str, amount: f64) -> RpcResult { + self.wallet_ep + .send("sendtoaddress", vec![address.into(), amount.into()]) } } @@ -819,7 +815,9 @@ mod tests { let client = utils::setup_client(&server); - let txid = client.send_to_address(address, amount).expect("Should be ok!"); + let txid = client + .send_to_address(address, amount) + .expect("Should be ok!"); assert_eq!(expected_txid, txid); } } @@ -960,19 +958,59 @@ mod tests { .expect("bitcoind should be started!"); let client = BitcoinRpcClient::from_stx_config(&config); - client.create_wallet("my_wallet", Some(false)).expect("create wallet ok!"); - let address = client.get_new_address(None, None).expect("get new address ok!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, None) + .expect("get new address ok!"); //Create 1 UTXO - _ = client.generate_to_address(101, &address).expect("generate to address ok!"); + _ = 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 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 raw_tx = client + .get_raw_transaction(&txid) + .expect("get raw transaction ok!"); assert_ne!("", raw_tx); + } + + #[test] + fn test_get_transaction_ok() { + let mut config = utils::create_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); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, None) + .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 raw transaction ok!"); + let resp = client.get_transaction(&txid).expect("get transaction ok!"); assert_eq!(0, resp.confirmations); } diff --git a/testnet/stacks-node/src/tests/bitcoin_regtest.rs b/testnet/stacks-node/src/tests/bitcoin_regtest.rs index c0f0f13a0d..a674723f85 100644 --- a/testnet/stacks-node/src/tests/bitcoin_regtest.rs +++ b/testnet/stacks-node/src/tests/bitcoin_regtest.rs @@ -33,7 +33,7 @@ type BitcoinResult = Result; pub struct BitcoinCoreController { bitcoind_process: Option, pub config: Config, - args: Vec + args: Vec, } impl BitcoinCoreController { @@ -42,7 +42,7 @@ impl BitcoinCoreController { BitcoinCoreController { bitcoind_process: None, config, - args: vec![] + args: vec![], } } @@ -50,7 +50,7 @@ impl BitcoinCoreController { let mut result = BitcoinCoreController { bitcoind_process: None, config, - args: vec![] + args: vec![], }; //TODO: Remove this once verified if `pub config` is really needed or not. @@ -78,15 +78,13 @@ impl BitcoinCoreController { result.add_arg(format!("-rpcport={}", config.burnchain.rpc_port)); - if let (Some(username), Some(password)) = ( - &config.burnchain.username, - &config.burnchain.password, - ) { + 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 } @@ -100,8 +98,7 @@ impl BitcoinCoreController { std::fs::create_dir_all(self.config.get_burnchain_path_str()).unwrap(); let mut command = Command::new("bitcoind"); - command - .stdout(Stdio::piped()); + command.stdout(Stdio::piped()); command.args(self.args.clone()); From 6d2d27c9b5283834d1188aea5f2d4893cea95965 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Sat, 12 Jul 2025 17:26:38 +0200 Subject: [PATCH 12/62] test: improve descriptor tests, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index d8170af5a7..3d48072a37 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -2,7 +2,7 @@ use serde_json::{json, Value}; use stacks::config::Config; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; -use crate::burnchains::rpc_transport::{RpcResult, RpcTransport}; +use crate::burnchains::rpc_transport::{RpcError, RpcResult, RpcTransport}; #[derive(Debug, Clone, Deserialize)] pub struct GetTransactionResponse { @@ -15,10 +15,24 @@ pub struct DescriptorInfoResponse { } #[derive(Debug, Clone, Deserialize)] -struct GenerateBlockResponse { +pub struct GenerateBlockResponse { hash: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct RpcErrorResponse { + pub code: i64, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ImportDescriptorsResponse { + pub success: bool, + #[serde(default)] + pub warnings: Vec, + pub error: Option +} + pub struct BitcoinRpcClient { global_ep: RpcTransport, wallet_ep: RpcTransport, @@ -193,16 +207,19 @@ impl BitcoinRpcClient { } //TODO: Improve with descriptor_list - pub fn import_descriptor(&self, descriptor: &str) -> RpcResult<()> { - //let addr = format!("addr({})", address); + pub fn import_descriptor(&self, descriptor: &str) -> RpcResult { let timestamp = 0; let internal = true; - self.global_ep.send::( + let result = self.global_ep.send::>( "importdescriptors", vec![json!([{ "desc": descriptor, "timestamp": timestamp, "internal": internal }])], )?; - Ok(()) + + result + .into_iter() + .next() + .ok_or_else(|| RpcError::Service("empty importdescriptors response".to_string())) } //TODO REMOVE: @@ -246,6 +263,8 @@ impl BitcoinRpcClient { /// /// * `label` - Optional label to associate with the address. /// * `address_type` - Optional address type ("legacy", "p2sh-segwit", "bech32", "bech32m"). + /// If `None`, the address type is determined by the `-addresstype` setting in `bitcoind`. + /// If `-addresstype` is also unset, the default behavior is `bech32` (for v0.20+). /// /// # Returns /// @@ -646,8 +665,7 @@ mod tests { #[test] fn test_get_descriptor_info_ok() { - let address = "bc1_address"; - let descriptor = format!("addr({address})"); + let descriptor = format!("addr(bc1_address)"); let expected_checksum = "mychecksum"; let expected_request = json!({ @@ -961,6 +979,7 @@ mod tests { client .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); + let address = client .get_new_address(None, None) .expect("get new address ok!"); @@ -1014,6 +1033,54 @@ mod tests { assert_eq!(0, resp.confirmations); } + #[test] + fn test_get_descriptor_ok() { + let mut config = utils::create_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); + 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); + } + + #[test] + fn test_import_descriptor_ok() { + let mut config = utils::create_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); + client + .create_wallet("my_wallet", Some(true)) + .expect("create wallet ok!"); + + let address = "mqqxPdP1dsGk75S7ta2nwyU8ujDnB2Yxvu"; + let checksum = "spfcmvsn"; + + let descriptor = format!("addr({address})#{checksum}"); + let import = client.import_descriptor(&descriptor) + .expect("import descriptor ok!"); + assert!(import.success); + } + #[test] fn test_stop_bitcoind_ok() { let config = utils::create_config(); From 48400c99a41230ec11335e674df2ac04bffe338a Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 21 Jul 2025 13:32:03 +0200 Subject: [PATCH 13/62] chore: add docs for testing api, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 3d48072a37..ed67d386a3 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -94,7 +94,6 @@ impl BitcoinRpcClient { self.global_ep.send("listwallets", vec![]) } - //TODO: Add wallet pub fn list_unspent( &self, addresses: Vec, @@ -229,23 +228,54 @@ impl BitcoinRpcClient { } } +/// Test-only utilities for `BitcoinRpcClient` #[cfg(test)] impl BitcoinRpcClient { + /// Retrieves the raw hex-encoded transaction by its ID. + /// + /// # Arguments + /// * `txid` - Transaction ID (hash) to fetch. + /// + /// # Returns + /// A raw transaction as a hex-encoded string. + /// + /// # Errors + /// Returns an error if the transaction is not found or if the RPC request fails. + /// + /// # Availability + /// Available in Bitcoin Core since **v0.7.0**. pub fn get_raw_transaction(&self, txid: &str) -> RpcResult { self.global_ep.send("getrawtransaction", vec![txid.into()]) } - pub fn generate_block(&self, address: &str, tx_ids: Vec) -> RpcResult { + /// Mines a new block including the given transactions to a specified address. + /// + /// # Arguments + /// * `address` - Address 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 + /// The block hash of the newly generated block. + /// + /// # Errors + /// Returns an error if block generation fails (e.g., invalid address, missing transactions, or malformed data). + /// + /// # Availability + /// Available in Bitcoin Core since **v22.0**. Requires `regtest` or similar testing networks. + pub fn generate_block(&self, address: &str, txs: Vec) -> RpcResult { let response = self .global_ep - .send::("generateblock", vec![address.into(), tx_ids.into()])?; + .send::("generateblock", vec![address.into(), txs.into()])?; Ok(response.hash) } /// Gracefully shuts down the Bitcoin Core node. /// - /// Sends the `"stop"` RPC command using the global endpoint to request that `bitcoind` shuts down - /// cleanly. This includes flushing the mempool, writing state to disk, and terminating the process. + /// 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: @@ -253,22 +283,31 @@ impl BitcoinRpcClient { /// /// # Errors /// Returns an error if the RPC command fails (e.g., connection issue or insufficient permissions). + /// + /// # Availability + /// Available in Bitcoin Core since **v0.1.0**. pub fn stop(&self) -> RpcResult { self.global_ep.send("stop", vec![]) } - /// Get a new Bitcoin address from the wallet. + /// Retrieves a new Bitcoin address from the wallet. /// /// # Arguments - /// /// * `label` - Optional label to associate with the address. - /// * `address_type` - Optional address type ("legacy", "p2sh-segwit", "bech32", "bech32m"). - /// If `None`, the address type is determined by the `-addresstype` setting in `bitcoind`. - /// If `-addresstype` is also unset, the default behavior is `bech32` (for v0.20+). + /// * `address_type` - Optional address type (`"legacy"`, `"p2sh-segwit"`, `"bech32"`, `"bech32m"`). + /// 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 string representing the newly generated Bitcoin address. + /// + /// # Errors + /// Returns an error if the wallet is not loaded or if address generation fails. /// - /// A string representing the new Bitcoin address. + /// # Availability + /// Available in Bitcoin Core since **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>, From d71dcd42a268cbfb33153fe0616bf25ebf7bc95e Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 21 Jul 2025 13:43:57 +0200 Subject: [PATCH 14/62] chore: add copyright, #6250 --- stacks-node/src/burnchains/bitcoin_rpc_client.rs | 15 +++++++++++++++ stacks-node/src/burnchains/rpc_transport.rs | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index ed67d386a3..9c6587b8c2 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -1,3 +1,18 @@ +// 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 . + use serde_json::{json, Value}; use stacks::config::Config; diff --git a/stacks-node/src/burnchains/rpc_transport.rs b/stacks-node/src/burnchains/rpc_transport.rs index 083da5c219..61ae195bec 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc_transport.rs @@ -1,3 +1,18 @@ +// 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 . + use std::time::Duration; use base64::encode; From 14f87922f74733b86b939c0ef6dc4cb1810f5ea9 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 22 Jul 2025 12:51:21 +0200 Subject: [PATCH 15/62] feat: complete rpc_transport implementation, #6250 --- stacks-node/Cargo.toml | 2 +- .../src/burnchains/bitcoin_rpc_client.rs | 117 ++++-- stacks-node/src/burnchains/rpc_transport.rs | 353 ++++++++++++++---- stacks-signer/Cargo.toml | 2 +- 4 files changed, 364 insertions(+), 110 deletions(-) diff --git a/stacks-node/Cargo.toml b/stacks-node/Cargo.toml index 61766e5053..eaae84765a 100644 --- a/stacks-node/Cargo.toml +++ b/stacks-node/Cargo.toml @@ -31,6 +31,7 @@ async-h1 = { version = "2.3.2", optional = true } async-std = { version = "1.6", optional = true, features = ["attributes"] } http-types = { version = "2.12", default-features = false, optional = true } thiserror = { workspace = true } +reqwest = { version = "0.11.24", default-features = false, features = ["blocking", "json", "rustls-tls"] } # This dependency is used for the multiversion integration tests which live behind the build-v3-1-0-0-13 feature flag signer_v3_1_0_0_13 = { package = "stacks-signer", git = "https://github.com/stacks-network/stacks-core.git", rev="8a79aaa7df0f13dfc5ab0d0d0bcb8201c90bcba2", optional = true, features = ["testing", "default"]} @@ -43,7 +44,6 @@ tikv-jemallocator = {workspace = true} [dev-dependencies] warp = "0.3.5" tokio = "1.15" -reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls", "rustls-tls"] } clarity = { path = "../clarity", features = ["default", "testing"]} stacks-common = { path = "../stacks-common", features = ["default", "testing"] } stacks = { package = "stackslib", path = "../stackslib", features = ["default", "testing"] } diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 9c6587b8c2..715af3e868 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -13,11 +13,13 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::time::Duration; + use serde_json::{json, Value}; use stacks::config::Config; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; -use crate::burnchains::rpc_transport::{RpcError, RpcResult, RpcTransport}; +use crate::burnchains::rpc_transport::{RpcAuth, RpcError, RpcResult, RpcTransport}; #[derive(Debug, Clone, Deserialize)] pub struct GetTransactionResponse { @@ -45,10 +47,11 @@ pub struct ImportDescriptorsResponse { pub success: bool, #[serde(default)] pub warnings: Vec, - pub error: Option + pub error: Option, } pub struct BitcoinRpcClient { + client_id: String, global_ep: RpcTransport, wallet_ep: RpcTransport, } @@ -65,10 +68,15 @@ impl BitcoinRpcClient { let protocol = if ssl { "https" } else { "http" }; let global_path = format!("{protocol}://{host}:{port}"); let wallet_path = format!("{global_path}/wallet/{wallet_name}"); + let client_id = "stacks"; + let auth = RpcAuth::Basic { username, password }; Self { - global_ep: RpcTransport::new(global_path, username.clone(), password.clone()), - wallet_ep: RpcTransport::new(wallet_path, username, password), + client_id: client_id.to_string(), + global_ep: RpcTransport::new(global_path, auth.clone(), None) + .expect("Global endpoint should be ok!"), + wallet_ep: RpcTransport::new(wallet_path, auth, None) + .expect("Wallet endpoint should be ok!"), } } @@ -85,9 +93,16 @@ impl BitcoinRpcClient { let global_path = format!("{protocol}://{host}:{port}"); let wallet_path = format!("{global_path}/wallet/{wallet_name}"); + let client_id = "stacks"; + let auth = RpcAuth::Basic { username, password }; + let timeout = Duration::from_secs(u64::from(config.burnchain.timeout)); + Self { - global_ep: RpcTransport::new(global_path, username.clone(), password.clone()), - wallet_ep: RpcTransport::new(wallet_path, username, password), + client_id: client_id.to_string(), + global_ep: RpcTransport::new(global_path, auth.clone(), Some(timeout.clone())) + .expect("Global endpoint should be ok!"), + wallet_ep: RpcTransport::new(wallet_path, auth, Some(timeout)) + .expect("Wallet endpoint should be ok!"), } } @@ -99,6 +114,7 @@ impl BitcoinRpcClient { 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()], )?; @@ -106,7 +122,7 @@ impl BitcoinRpcClient { } pub fn list_wallets(&self) -> RpcResult> { - self.global_ep.send("listwallets", vec![]) + self.global_ep.send(&self.client_id, "listwallets", vec![]) } pub fn list_unspent( @@ -122,6 +138,7 @@ impl BitcoinRpcClient { let maximum_count = maximum_count; let raw_utxos: Vec = self.wallet_ep.send( + &self.client_id, "listunspent", vec![ min_conf.into(), @@ -168,12 +185,16 @@ impl BitcoinRpcClient { } pub fn generate_to_address(&self, num_block: u64, address: &str) -> RpcResult> { - self.global_ep - .send("generatetoaddress", vec![num_block.into(), address.into()]) + self.global_ep.send( + &self.client_id, + "generatetoaddress", + vec![num_block.into(), address.into()], + ) } pub fn get_transaction(&self, txid: &str) -> RpcResult { - self.wallet_ep.send("gettransaction", vec![txid.into()]) + self.wallet_ep + .send(&self.client_id, "gettransaction", vec![txid.into()]) } /// Broadcasts a raw transaction to the Bitcoin network. @@ -210,14 +231,18 @@ impl BitcoinRpcClient { let max_burn_amount = max_burn_amount.unwrap_or(0); self.global_ep.send( + &self.client_id, "sendrawtransaction", vec![tx.into(), max_fee_rate.into(), max_burn_amount.into()], ) } pub fn get_descriptor_info(&self, descriptor: &str) -> RpcResult { - self.global_ep - .send("getdescriptorinfo", vec![descriptor.into()]) + self.global_ep.send( + &self.client_id, + "getdescriptorinfo", + vec![descriptor.into()], + ) } //TODO: Improve with descriptor_list @@ -226,6 +251,7 @@ impl BitcoinRpcClient { let internal = true; let result = self.global_ep.send::>( + &self.client_id, "importdescriptors", vec![json!([{ "desc": descriptor, "timestamp": timestamp, "internal": internal }])], )?; @@ -238,7 +264,8 @@ impl BitcoinRpcClient { //TODO REMOVE: pub fn get_blockchaininfo(&self) -> RpcResult<()> { - self.global_ep.send::("getblockchaininfo", vec![])?; + self.global_ep + .send::(&self.client_id, "getblockchaininfo", vec![])?; Ok(()) } } @@ -260,7 +287,8 @@ impl BitcoinRpcClient { /// # Availability /// Available in Bitcoin Core since **v0.7.0**. pub fn get_raw_transaction(&self, txid: &str) -> RpcResult { - self.global_ep.send("getrawtransaction", vec![txid.into()]) + self.global_ep + .send(&self.client_id, "getrawtransaction", vec![txid.into()]) } /// Mines a new block including the given transactions to a specified address. @@ -281,9 +309,11 @@ impl BitcoinRpcClient { /// # Availability /// Available in Bitcoin Core since **v22.0**. Requires `regtest` or similar testing networks. pub fn generate_block(&self, address: &str, txs: Vec) -> RpcResult { - let response = self - .global_ep - .send::("generateblock", vec![address.into(), txs.into()])?; + let response = self.global_ep.send::( + &self.client_id, + "generateblock", + vec![address.into(), txs.into()], + )?; Ok(response.hash) } @@ -302,7 +332,7 @@ impl BitcoinRpcClient { /// # Availability /// Available in Bitcoin Core since **v0.1.0**. pub fn stop(&self) -> RpcResult { - self.global_ep.send("stop", vec![]) + self.global_ep.send(&self.client_id, "stop", vec![]) } /// Retrieves a new Bitcoin address from the wallet. @@ -337,7 +367,8 @@ impl BitcoinRpcClient { params.push(at.into()); } - self.global_ep.send("getnewaddress", params) + self.global_ep + .send(&self.client_id, "getnewaddress", params) } /// Sends a specified amount of BTC to a given address. @@ -349,8 +380,11 @@ impl BitcoinRpcClient { /// # Returns /// The transaction ID as hex string pub fn send_to_address(&self, address: &str, amount: f64) -> RpcResult { - self.wallet_ep - .send("sendtoaddress", vec![address.into(), amount.into()]) + self.wallet_ep.send( + &self.client_id, + "sendtoaddress", + vec![address.into(), amount.into()], + ) } } @@ -392,6 +426,12 @@ mod tests { "params": ["testwallet", true] }); + let mock_response = json!({ + "id": "stacks", + "result": true, + "error": null + }); + let mut server: mockito::ServerGuard = mockito::Server::new(); let _m = server .mock("POST", "/") @@ -399,7 +439,7 @@ mod tests { .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") - .with_body(r#"{"result":true,"error":null}"#) + .with_body(mock_response.to_string()) .create(); let client = utils::setup_client(&server); @@ -416,6 +456,12 @@ mod tests { "params": [] }); + let mock_response = json!({ + "id": "stacks", + "result": ["wallet1", "wallet2"], + "error": null + }); + let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") @@ -423,7 +469,7 @@ mod tests { .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") - .with_body(r#"{"result":["wallet1","wallet2"],"error":null}"#) + .with_body(mock_response.to_string()) .create(); let client = utils::setup_client(&server); @@ -453,6 +499,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": [{ "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", "vout": 0, @@ -513,6 +560,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": [ "block_hash1", "block_hash2", @@ -552,6 +600,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": { "confirmations": 6, }, @@ -588,9 +637,9 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": expected_ser_tx, "error": null, - //"id": "stacks" }); let mut server = mockito::Server::new(); @@ -624,6 +673,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": { "hash" : expected_block_hash }, @@ -662,6 +712,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": expected_txid, "error": null }); @@ -696,6 +747,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": expected_txid, "error": null }); @@ -730,6 +782,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": { "checksum": expected_checksum }, @@ -772,6 +825,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": [{ "success": true, "warnings": [] @@ -804,6 +858,7 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": "Bitcoin Core stopping", "error": null }); @@ -835,9 +890,9 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": expected_address, "error": null, - //"id": "stacks" }); let mut server = mockito::Server::new(); @@ -870,9 +925,9 @@ mod tests { }); let mock_response = json!({ + "id": "stacks", "result": expected_txid, "error": null, - //"id": "stacks" }); let mut server = mockito::Server::new(); @@ -1033,7 +1088,7 @@ mod tests { client .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); - + let address = client .get_new_address(None, None) .expect("get new address ok!"); @@ -1106,7 +1161,8 @@ mod tests { let checksum = "spfcmvsn"; let descriptor = format!("addr({address})"); - let info = client.get_descriptor_info(&descriptor) + let info = client + .get_descriptor_info(&descriptor) .expect("get descriptor ok!"); assert_eq!(checksum, info.checksum); } @@ -1130,9 +1186,10 @@ mod tests { let checksum = "spfcmvsn"; let descriptor = format!("addr({address})#{checksum}"); - let import = client.import_descriptor(&descriptor) + let import = client + .import_descriptor(&descriptor) .expect("import descriptor ok!"); - assert!(import.success); + assert!(import.success); } #[test] diff --git a/stacks-node/src/burnchains/rpc_transport.rs b/stacks-node/src/burnchains/rpc_transport.rs index 61ae195bec..e1a34844f0 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc_transport.rs @@ -13,127 +13,207 @@ // 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 `reqwest` 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::time::Duration; use base64::encode; -use reqwest::blocking::Client; +use reqwest::blocking::Client as ReqwestClient; +use reqwest::header::AUTHORIZATION; +use reqwest::Error as ReqwestError; use serde::Deserialize; use serde_json::Value; -const RCP_CLIENT_ID: &str = "stacks"; +/// The JSON-RPC protocol version used in all requests. +/// Latest specification is `2.0` const RCP_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, - //id: String, } +/// Represents a JSON-RPC error encountered during a transport operation. #[derive(Debug, Clone, Deserialize, Serialize)] pub enum RpcError { + /// Represents a network-level error, such as connection failures or timeouts. Network(String), + /// Indicates that the response could not be parsed or was malformed. Parsing(String), - //Bitcoind(String), + /// Represents an error returned by the RPC service itself. Service(String), } +/// Alias for results returned from RPC operations using `RpcTransport`. pub type RpcResult = Result; -/* -impl From for RPCError { - fn from(ioe: io::Error) -> Self { - Self::Network(format!("IO Error: {ioe:?}")) - } +/// 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 }, } -impl From for RPCError { - fn from(ne: NetError) -> Self { - Self::Network(format!("Net Error: {ne:?}")) - } -} - */ - +/// A transport mechanism for sending JSON-RPC requests over HTTP. +/// +/// This struct encapsulates the target URL, optional authentication, +/// and an internal HTTP client. pub struct RpcTransport { + /// The base URL of the JSON-RPC endpoint. pub url: String, - pub username: String, - pub password: String, + /// Optional authentication to apply to outgoing requests. + pub auth: RpcAuth, + /// The reqwest http client + client: ReqwestClient, } impl RpcTransport { - pub fn new(url: String, username: String, password: String) -> Self { - RpcTransport { - url, - username, - password, - } + /// 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 request timeout duration. + /// + /// # Errors + /// + /// Returns `RpcError::Network` if the HTTP client could not be built. + pub fn new(url: String, auth: RpcAuth, timeout: Option) -> RpcResult { + let client = ReqwestClient::builder() + .timeout(timeout) + .build() + .map_err(|e| RpcError::Network(format!("Failed to build HTTP client: {}", e)))?; + + Ok(RpcTransport { url, auth, client }) } + /// 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. + /// + /// # Errors + /// + /// Returns: + /// * `RpcError::Network` on network issues, + /// * `RpcError::Parsing` for malformed or invalid responses, + /// * `RpcError::Service` if the RPC server returns an error. pub fn send Deserialize<'de>>( &self, + id: &str, method: &str, params: Vec, ) -> RpcResult { let request = JsonRpcRequest { jsonrpc: RCP_VERSION.to_string(), - id: RCP_CLIENT_ID.to_string(), + id: id.to_string(), method: method.to_string(), params: Value::Array(params), }; - let client = Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .unwrap(); + let mut request_builder = self.client.post(&self.url).json(&request); - //self.client - let response = client - .post(&self.url) - .header("Authorization", self.auth_header()) - .json(&request) + if let Some(auth_header) = self.auth_header() { + request_builder = request_builder.header(AUTHORIZATION, auth_header); + } + + let response = request_builder .send() .map_err(|err| RpcError::Network(err.to_string()))?; - let parsed: JsonRpcResponse = response - .json() - .map_err(|e| RpcError::Parsing(format!("Failed to parse RPC response: {}", e)))?; + let parsed: JsonRpcResponse = response.json().map_err(Self::classify_parse_error)?; + + if id != parsed.id { + return Err(RpcError::Parsing(format!( + "Invalid response: mismatched 'id': expected '{}', got '{}'", + id, parsed.id + ))); + } match (parsed.result, parsed.error) { (Some(result), None) => Ok(result), (_, Some(err)) => Err(RpcError::Service(format!("{:#}", err))), - _ => Err(RpcError::Parsing("Missing both result and error".into())), + _ => Err(RpcError::Parsing( + "Invalid response: missing both 'result' and 'error'".to_string(), + )), } } - fn auth_header(&self) -> String { - let credentials = format!("{}:{}", self.username, self.password); - format!("Basic {}", encode(credentials)) + /// 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))) + } + } + } + + /// Classify possible error coming from Json parsing + fn classify_parse_error(e: ReqwestError) -> RpcError { + if e.is_timeout() { + RpcError::Network("Request timed out".to_string()) + } else if e.is_decode() { + RpcError::Parsing(format!("Failed to parse RPC response: {e}")) + } else { + RpcError::Network(format!("Network error: {e}")) + } } } #[cfg(test)] mod tests { + use std::thread; + use serde_json::json; use super::*; mod utils { - use super::*; + use crate::burnchains::rpc_transport::{RpcAuth, RpcTransport}; - pub fn setup_transport(server: &mockito::ServerGuard) -> RpcTransport { - RpcTransport { - url: server.url(), - username: "user".into(), - password: "pass".into(), - } + 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!") } } @@ -141,41 +221,79 @@ mod tests { fn test_send_with_string_result_ok() { let expected_request = json!({ "jsonrpc": "2.0", - "id": "stacks", + "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", "Basic dXNlcjpwYXNz") + .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::setup_transport(&server); + let transport = utils::rpc_with_auth(&server, username, password); - let result: RpcResult = transport.send("some_method", vec!["param1".into()]); + let result: RpcResult = + transport.send("client_id", "some_method", vec!["param1".into()]); assert_eq!(result.unwrap(), "some_result"); } #[test] fn test_send_fails_with_network_error() { - let transport = RpcTransport::new( - "http://127.0.0.1:65535".to_string(), - "user".to_string(), - "pass".to_string(), - ); + let transport = + RpcTransport::new("http://127.0.0.1:65535".to_string(), RpcAuth::None, None) + .expect("Should be created properly!"); - let result: RpcResult = transport.send("dummy_method", vec![]); + let result: RpcResult = transport.send("client_id", "dummy_method", vec![]); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), RpcError::Network(_))); } @@ -189,8 +307,8 @@ mod tests { .with_body("Internal Server Error") .create(); - let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("dummy", vec![]); + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "dummy", vec![]); assert!(result.is_err()); match result { @@ -211,8 +329,8 @@ mod tests { .with_body("not a valid json") .create(); - let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("dummy", vec![]); + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "dummy", vec![]); assert!(result.is_err()); match result { @@ -224,44 +342,79 @@ mod tests { } #[test] - fn test_send_missing_result_and_error() { + fn test_send_fails_due_to_missing_result_and_error() { + let response_body = json!({ + "id": "client_id", + "foo": "bar", + }); + let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") .with_status(200) .with_header("Content-Type", "application/json") - .with_body(r#"{"foo": "bar"}"#) + .with_body(response_body.to_string()) .create(); - let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("dummy", vec![]); + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "dummy", vec![]); match result { - Err(RpcError::Parsing(msg)) => assert_eq!("Missing both result and error", msg), + Err(RpcError::Parsing(msg)) => { + assert_eq!("Invalid response: missing both 'result' and 'error'", msg) + } + _ => panic!("Expected missing result/error error"), + } + } + + #[test] + fn test_send_fails_with_invalid_id() { + let response_body = json!({ + "id": "wrong_client_id", + "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("client_id", "dummy", vec![]); + + match result { + Err(RpcError::Parsing(msg)) => assert_eq!( + "Invalid response: mismatched 'id': expected 'client_id', got 'wrong_client_id'", + msg + ), _ => panic!("Expected missing result/error error"), } } #[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( - r#"{ - "result": null, - "error": { - "code": -32601, - "message": "Method not found" - } - }"#, - ) + .with_body(response_body.to_string()) .create(); - let transport = utils::setup_transport(&server); - let result: RpcResult = transport.send("unknown_method", vec![]); + let transport = utils::rpc_no_auth(&server); + let result: RpcResult = transport.send("client_id", "unknown_method", vec![]); match result { Err(RpcError::Service(msg)) => assert_eq!( @@ -271,4 +424,48 @@ mod tests { _ => panic!("Expected service error"), } } + + #[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::Network(msg) => { + assert_eq!("Request timed out", msg); + } + err => panic!("Expected network error, got: {:?}", err), + } + } } diff --git a/stacks-signer/Cargo.toml b/stacks-signer/Cargo.toml index 347be5731f..31034c71fa 100644 --- a/stacks-signer/Cargo.toml +++ b/stacks-signer/Cargo.toml @@ -29,7 +29,7 @@ libsigner = { path = "../libsigner" } libstackerdb = { path = "../libstackerdb" } prometheus = { version = "0.9", optional = true } rand_core = "0.6" -reqwest = { version = "0.11.22", default-features = false, features = ["blocking", "json", "rustls-tls"] } +reqwest = { version = "0.11.24", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = "1" slog = { version = "2.5.2", features = [ "max_level_trace" ] } slog-json = { version = "2.3.0", optional = true } From 4bb1962459b367ee69fdc774493de06e0b7912b9 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 23 Jul 2025 14:43:52 +0200 Subject: [PATCH 16/62] chore: improve rpc api and docs, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 549 +++++++++++++----- stacks-node/src/burnchains/rpc_transport.rs | 2 +- 2 files changed, 415 insertions(+), 136 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 715af3e868..3a31dd6210 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -15,47 +15,176 @@ use std::time::Duration; +use serde::{Deserialize, Deserializer}; +use serde_json::value::RawValue; use serde_json::{json, Value}; use stacks::config::Config; use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; -use crate::burnchains::rpc_transport::{RpcAuth, RpcError, RpcResult, RpcTransport}; - +use crate::burnchains::rpc_transport::{RpcAuth, RpcError, RpcTransport}; + +/// Response structure for the `gettransaction` RPC call. +/// +/// Contains metadata about a wallet transaction, currently limited to the confirmation count. +/// +/// # Note +/// 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. +/// +/// # Note +/// 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, } -#[derive(Debug, Clone, Deserialize)] -pub struct GenerateBlockResponse { - hash: 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: +/// +/// - `Now` — Tells the wallet to start tracking from the current blockchain time. +/// - `Time(u64)` — A Unix timestamp (in seconds) specifying when the wallet should begin scanning. +/// +/// # Serialization +/// This enum serializes to either the string `"now"` or a numeric timestamp, +/// matching the format expected by Bitcoin Core. +#[derive(Debug, Clone)] +pub enum Timestamp { + Now, + Time(u64), } -#[derive(Debug, Clone, Deserialize)] -pub struct RpcErrorResponse { - pub code: i64, - pub message: String, +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)] +#[serde(rename_all = "camelCase")] +pub struct ListUnspentResponse { + /// The transaction ID of the UTXO. + pub txid: String, + /// The index of the output in the transaction. + pub vout: u32, + /// The script associated with the output. + pub script_pub_key: String, + /// The amount in BTC, deserialized as a string to preserve full precision. + #[serde(deserialize_with = "serde_raw_to_string")] + pub amount: String, + /// The number of confirmations for the transaction. + pub confirmations: u32, +} + +/// Deserializes any raw JSON value into its unprocessed string representation. +/// +/// Useful when you need to defer parsing, preserve exact formatting (e.g., precision), +/// or handle heterogeneous value types dynamically. +fn serde_raw_to_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let raw: Box = Deserialize::deserialize(deserializer)?; + Ok(raw.get().to_string()) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GenerateBlockResponse { + hash: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RpcErrorResponse { + pub code: i64, + pub message: String, +} + pub struct BitcoinRpcClient { client_id: String, global_ep: RpcTransport, wallet_ep: RpcTransport, } +#[derive(Debug)] +pub enum BitcoinRpcClientError { + // Transport or server-side errors + Rpc(RpcError), + // Local JSON issues + Serialization(serde_json::Error), +} + +impl From for BitcoinRpcClientError { + fn from(err: RpcError) -> Self { + BitcoinRpcClientError::Rpc(err) + } +} + +impl From for BitcoinRpcClientError { + fn from(err: serde_json::Error) -> Self { + BitcoinRpcClientError::Serialization(err) + } +} + +/// Alias for results returned from client operations. +pub type BitcoinRpcClientResult = Result; + impl BitcoinRpcClient { pub fn from_params( host: String, @@ -106,11 +235,35 @@ impl BitcoinRpcClient { } } + /// 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. + /// + /// # Errors + /// Returns an error if the wallet creation fails. This includes: + /// - The wallet already exists. + /// - Invalid parameters. + /// - Node-level failures or RPC connection issues. + /// + /// # Availability + /// Available in Bitcoin Core since **v0.17.0**. + /// + /// # Notes: + /// This method supports only 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, - ) -> RpcResult<()> { + ) -> BitcoinRpcClientResult<()> { let disable_private_keys = disable_private_keys.unwrap_or(false); self.global_ep.send::( @@ -121,28 +274,71 @@ impl BitcoinRpcClient { Ok(()) } - pub fn list_wallets(&self) -> RpcResult> { - self.global_ep.send(&self.client_id, "listwallets", vec![]) + /// Returns a list of currently loaded wallets by the Bitcoin Core node. + /// + /// # Returns + /// A vector of wallet names as strings. + /// + /// # Errors + /// Returns an error if the RPC call fails or if communication with the node is interrupted. + /// + /// # 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. + /// * `max_confirmations` - Maximum number of confirmations allowed. Use `None` for effectively unlimited. + /// * `addresses` - Optional list of addresses to filter UTXOs by. If `None`, all UTXOs are returned. + /// * `include_unsafe` - Whether to include UTXOs from unconfirmed unsafe transactions. + /// * `minimum_amount` - Minimum amount (in satoshis) a UTXO must have to be included. + /// * `maximum_count` - Maximum number of UTXOs to return. Use `None` for effectively unlimited. + /// + /// Default values are applied for omitted parameters: + /// - `min_confirmations` defaults to 0 + /// - `max_confirmations` defaults to 9,999,999 + /// - `addresses` defaults to an empty list (no filtering) + /// - `include_unsafe` defaults to `true` + /// - `minimum_amount` defaults to 0 satoshis + /// - `maximum_count` defaults to 9,999,999 + /// + /// # Returns + /// A `Vec` containing the matching UTXOs. + /// + /// # Errors + /// Returns a `BitcoinRpcClientError` if the RPC call fails or the response cannot be parsed. + /// + /// # Notes: + /// This method supports only 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, - addresses: Vec, - include_unsafe: bool, - minimum_amount: u64, - maximum_count: u64, - ) -> RpcResult> { - let min_conf = 0i64; - let max_conf = 9999999i64; - let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_amount); - let maximum_count = maximum_count; - - let raw_utxos: Vec = self.wallet_ep.send( + min_confirmations: Option, + max_confirmations: Option, + addresses: Option>, + 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(9999999); + let addresses = addresses.unwrap_or(vec![]); + let include_unsafe = include_unsafe.unwrap_or(true); + let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_amount.unwrap_or(0)); + let maximum_count = maximum_count.unwrap_or(9999999); + + Ok(self.wallet_ep.send( &self.client_id, "listunspent", vec![ - min_conf.into(), - max_conf.into(), + min_confirmations.into(), + max_confirmations.into(), addresses.into(), include_unsafe.into(), json!({ @@ -150,51 +346,57 @@ impl BitcoinRpcClient { "maximumCount": maximum_count }), ], - )?; - - let mut result = vec![]; - for raw_utxo in raw_utxos.iter() { - let txid = match raw_utxo.get_txid() { - Some(hash) => hash, - None => continue, - }; - - let script_pub_key = match raw_utxo.get_script_pub_key() { - Some(script_pub_key) => script_pub_key, - None => { - //TODO: add warn log? - continue; - } - }; - - let amount = match raw_utxo.get_sat_amount() { - Some(amount) => amount, - None => continue, //TODO: add warn log? - }; - - result.push(UTXO { - txid, - vout: raw_utxo.vout, - script_pub_key, - amount, - confirmations: raw_utxo.confirmations, - }); - } - - Ok(result) + )?) } - pub fn generate_to_address(&self, num_block: u64, address: &str) -> RpcResult> { - self.global_ep.send( + /// 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 Bitcoin address to receive the block rewards. + /// + /// # Returns + /// A vector of block hashes corresponding to the newly generated blocks. + /// + /// # Errors + /// Returns an error if the block generation fails (e.g., invalid address or RPC issues). + /// + /// # Availability + /// Available in Bitcoin Core since **v0.17.0**. + /// Typically used on `regtest` or test networks. + /// NOTE: Candidate to be a test util, but this api is used in production code when a burnchain is configured in `helium` mode + pub fn generate_to_address( + &self, + num_block: u64, + address: &str, + ) -> BitcoinRpcClientResult> { + Ok(self.global_ep.send( &self.client_id, "generatetoaddress", vec![num_block.into(), address.into()], - ) + )?) } - pub fn get_transaction(&self, txid: &str) -> RpcResult { - self.wallet_ep - .send(&self.client_id, "gettransaction", vec![txid.into()]) + /// 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 (txid) to query, as a hex-encoded string. + /// + /// # Returns + /// A [`GetTransactionResponse`] containing detailed metadata for the specified transaction. + /// + /// # Errors + /// Returns an error if the transaction is not found in the wallet, or if the RPC request fails. + /// + /// # Availability + /// Available in Bitcoin Core since **v0.10.0**. + pub fn get_transaction(&self, txid: &str) -> BitcoinRpcClientResult { + Ok(self + .wallet_ep + .send(&self.client_id, "gettransaction", vec![txid.into()])?) } /// Broadcasts a raw transaction to the Bitcoin network. @@ -226,44 +428,75 @@ impl BitcoinRpcClient { tx: &str, max_fee_rate: Option, max_burn_amount: Option, - ) -> RpcResult { + ) -> BitcoinRpcClientResult { let max_fee_rate = max_fee_rate.unwrap_or(0.10); let max_burn_amount = max_burn_amount.unwrap_or(0); - self.global_ep.send( + Ok(self.global_ep.send( &self.client_id, "sendrawtransaction", vec![tx.into(), max_fee_rate.into(), max_burn_amount.into()], - ) + )?) } - pub fn get_descriptor_info(&self, descriptor: &str) -> RpcResult { - self.global_ep.send( + /// 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. + /// + /// # Errors + /// Returns an error if the descriptor is invalid or the RPC call fails. + /// + /// # Availability + /// Available in Bitcoin Core since **v0.18.0**. + pub fn get_descriptor_info( + &self, + descriptor: &str, + ) -> BitcoinRpcClientResult { + Ok(self.global_ep.send( &self.client_id, "getdescriptorinfo", vec![descriptor.into()], - ) + )?) } - //TODO: Improve with descriptor_list - pub fn import_descriptor(&self, descriptor: &str) -> RpcResult { - let timestamp = 0; - let internal = true; - - let result = self.global_ep.send::>( + /// 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. + /// + /// # Errors + /// Returns an error if the request fails, if the input cannot be serialized, + /// or if the Bitcoin node responds with an error. + /// + /// # Availability + /// Available in Bitcoin Core since **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![json!([{ "desc": descriptor, "timestamp": timestamp, "internal": internal }])], - )?; - - result - .into_iter() - .next() - .ok_or_else(|| RpcError::Service("empty importdescriptors response".to_string())) + vec![descriptor_values.into()], + )?) } //TODO REMOVE: - pub fn get_blockchaininfo(&self) -> RpcResult<()> { + pub fn get_blockchaininfo(&self) -> BitcoinRpcClientResult<()> { self.global_ep .send::(&self.client_id, "getblockchaininfo", vec![])?; Ok(()) @@ -286,9 +519,10 @@ impl BitcoinRpcClient { /// /// # Availability /// Available in Bitcoin Core since **v0.7.0**. - pub fn get_raw_transaction(&self, txid: &str) -> RpcResult { - self.global_ep - .send(&self.client_id, "getrawtransaction", vec![txid.into()]) + pub fn get_raw_transaction(&self, txid: &str) -> BitcoinRpcClientResult { + Ok(self + .global_ep + .send(&self.client_id, "getrawtransaction", vec![txid.into()])?) } /// Mines a new block including the given transactions to a specified address. @@ -308,7 +542,11 @@ impl BitcoinRpcClient { /// /// # Availability /// Available in Bitcoin Core since **v22.0**. Requires `regtest` or similar testing networks. - pub fn generate_block(&self, address: &str, txs: Vec) -> RpcResult { + pub fn generate_block( + &self, + address: &str, + txs: Vec, + ) -> BitcoinRpcClientResult { let response = self.global_ep.send::( &self.client_id, "generateblock", @@ -331,8 +569,8 @@ impl BitcoinRpcClient { /// /// # Availability /// Available in Bitcoin Core since **v0.1.0**. - pub fn stop(&self) -> RpcResult { - self.global_ep.send(&self.client_id, "stop", vec![]) + pub fn stop(&self) -> BitcoinRpcClientResult { + Ok(self.global_ep.send(&self.client_id, "stop", vec![])?) } /// Retrieves a new Bitcoin address from the wallet. @@ -357,7 +595,7 @@ impl BitcoinRpcClient { &self, label: Option<&str>, address_type: Option<&str>, - ) -> RpcResult { + ) -> BitcoinRpcClientResult { let mut params = vec![]; let label = label.unwrap_or(""); @@ -367,8 +605,9 @@ impl BitcoinRpcClient { params.push(at.into()); } - self.global_ep - .send(&self.client_id, "getnewaddress", params) + Ok(self + .global_ep + .send(&self.client_id, "getnewaddress", params)?) } /// Sends a specified amount of BTC to a given address. @@ -379,12 +618,12 @@ impl BitcoinRpcClient { /// /// # Returns /// The transaction ID as hex string - pub fn send_to_address(&self, address: &str, amount: f64) -> RpcResult { - self.wallet_ep.send( + pub fn send_to_address(&self, address: &str, amount: f64) -> BitcoinRpcClientResult { + Ok(self.wallet_ep.send( &self.client_id, "sendtoaddress", vec![address.into(), amount.into()], - ) + )?) } } @@ -395,7 +634,6 @@ mod tests { mod unit { use serde_json::json; - use stacks::util::hash::to_hex; use super::*; @@ -428,7 +666,10 @@ mod tests { let mock_response = json!({ "id": "stacks", - "result": true, + "result": { + "name": "testwallet", + "warning": null + }, "error": null }); @@ -443,8 +684,9 @@ mod tests { .create(); let client = utils::setup_client(&server); - let result = client.create_wallet("testwallet", Some(true)); - result.expect("Should work"); + client + .create_wallet("testwallet", Some(true)) + .expect("create wallet should be ok!"); } #[test] @@ -487,13 +729,13 @@ mod tests { "id": "stacks", "method": "listunspent", "params": [ - 0, - 9999999, + 1, + 10, ["BTC_ADDRESS_1"], true, { "minimumAmount": "0.00001000", - "maximumCount": 100 + "maximumCount": 5 } ] }); @@ -524,31 +766,32 @@ mod tests { let result = client .list_unspent( - vec!["BTC_ADDRESS_1".into()], - true, - 1000, // 1000 sats = 0.00001000 BTC - 100, + Some(1), + Some(10), + Some(vec!["BTC_ADDRESS_1".into()]), + Some(true), + Some(1000), // 1000 sats = 0.00001000 BTC + Some(5), ) .expect("Should parse unspent outputs"); assert_eq!(1, result.len()); let utxo = &result[0]; - assert_eq!(1000, utxo.amount); + assert_eq!("0.00001", utxo.amount); assert_eq!(0, utxo.vout); assert_eq!(6, utxo.confirmations); assert_eq!( "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - utxo.txid.to_string(), + utxo.txid, ); assert_eq!( "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - to_hex(&utxo.script_pub_key.to_bytes()), + utxo.script_pub_key, ); } #[test] fn test_generate_to_address_ok() { - // Arrange let num_blocks = 3; let address = "00000000000000000000000000000000000000000000000000000"; @@ -605,7 +848,6 @@ mod tests { "confirmations": 6, }, "error": null, - //"id": "stacks" }); let mut server = mockito::Server::new(); @@ -678,7 +920,6 @@ mod tests { "hash" : expected_block_hash }, "error": null, - //"id": "stacks" }); let mut server = mockito::Server::new(); @@ -787,7 +1028,6 @@ mod tests { "checksum": expected_checksum }, "error": null, - //"id": "stacks" }); let mut server = mockito::Server::new(); @@ -808,19 +1048,23 @@ mod tests { } #[test] - fn test_import_descriptor_ok() { + 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 - }] + [ + { + "desc": descriptor, + "timestamp": 0, + "internal": true + } + ] ] }); @@ -844,7 +1088,13 @@ mod tests { .create(); let client = utils::setup_client(&server); - let result = client.import_descriptor(&descriptor); + + 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()); } @@ -1002,13 +1252,17 @@ mod tests { let wallets = client.list_wallets().unwrap(); assert_eq!(0, wallets.len()); - client.create_wallet("mywallet1", Some(false)).unwrap(); + 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)).unwrap(); + client + .create_wallet("mywallet2", Some(false)) + .expect("mywallet2 creation should be ok!"); let wallets = client.list_wallets().unwrap(); assert_eq!(2, wallets.len()); @@ -1016,6 +1270,31 @@ mod tests { assert_eq!("mywallet2", wallets[1]); } + #[test] + fn test_wallet_creation_fails_if_already_exists() { + let config = utils::create_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); + + 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!"); + + assert!(matches!( + err, + BitcoinRpcClientError::Rpc(RpcError::Service(_)) + )); + } + #[test] fn test_generate_to_address_and_list_unspent_ok() { let mut config = utils::create_config(); @@ -1031,7 +1310,7 @@ mod tests { let address = client.get_new_address(None, None).expect("Should work!"); let utxos = client - .list_unspent(vec![], false, 1, 10) + .list_unspent(None, None, None, Some(false), Some(1), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(0, utxos.len()); @@ -1039,20 +1318,14 @@ mod tests { assert_eq!(102, blocks.len()); let utxos = client - .list_unspent(vec![], false, 1, 10) + .list_unspent(None, None, None, Some(false), Some(1), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(2, utxos.len()); let utxos = client - .list_unspent(vec![], false, 1, 1) + .list_unspent(None, None, None, Some(false), Some(1), Some(1)) .expect("list_unspent should be ok!"); assert_eq!(1, utxos.len()); - - //client.create_wallet("hello1").expect("OK"); - //client.create_wallet("hello2").expect("OK"); - //client.generate_to_address(64, address) - //client.get_transaction("1", "hello1").expect("Boh"); - //client.get_blockchaininfo().expect("Boh"); } #[test] @@ -1185,11 +1458,17 @@ mod tests { let address = "mqqxPdP1dsGk75S7ta2nwyU8ujDnB2Yxvu"; let checksum = "spfcmvsn"; - let descriptor = format!("addr({address})#{checksum}"); - let import = client - .import_descriptor(&descriptor) + 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!(import.success); + assert_eq!(1, response.len()); + assert!(response[0].success); } #[test] diff --git a/stacks-node/src/burnchains/rpc_transport.rs b/stacks-node/src/burnchains/rpc_transport.rs index e1a34844f0..faa05dffd8 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc_transport.rs @@ -448,7 +448,7 @@ mod tests { .with_header("Content-Type", "application/json") .with_chunked_body(move |writer| { // Simulate server delay - thread::sleep(Duration::from_secs(2)); + thread::sleep(Duration::from_secs(2)); writer.write_all(response_body.to_string().as_bytes()) }) .create(); From 85ca874685acc5cd75866ddd1c8db536554e346e Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 23 Jul 2025 15:31:03 +0200 Subject: [PATCH 17/62] chore: add docs, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 155 +++++++----------- 1 file changed, 58 insertions(+), 97 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 3a31dd6210..7f7c589ccd 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -13,6 +13,15 @@ // 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}; @@ -20,14 +29,14 @@ use serde_json::value::RawValue; use serde_json::{json, Value}; use stacks::config::Config; -use crate::burnchains::bitcoin_regtest_controller::{ParsedUTXO, UTXO}; +use crate::burnchains::bitcoin_regtest_controller::ParsedUTXO; use crate::burnchains::rpc_transport::{RpcAuth, RpcError, RpcTransport}; /// Response structure for the `gettransaction` RPC call. /// /// Contains metadata about a wallet transaction, currently limited to the confirmation count. /// -/// # Note +/// # 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)] @@ -39,7 +48,7 @@ pub struct GetTransactionResponse { /// /// Contains information about a parsed descriptor, including its checksum. /// -/// # Note +/// # 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)] @@ -50,20 +59,17 @@ pub struct DescriptorInfoResponse { /// 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: -/// -/// - `Now` — Tells the wallet to start tracking from the current blockchain time. -/// - `Time(u64)` — A Unix timestamp (in seconds) specifying when the wallet should begin scanning. -/// -/// # Serialization -/// This enum serializes to either the string `"now"` or a numeric timestamp, -/// matching the format expected by Bitcoin Core. +/// 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 @@ -81,7 +87,7 @@ impl serde::Serialize for Timestamp { /// 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: +/// # 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)] @@ -98,7 +104,7 @@ pub struct ImportDescriptorsRequest { /// Response returned by the `importdescriptors` RPC method for each imported descriptor. /// -/// # Notes: +/// # 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)] @@ -109,12 +115,12 @@ pub struct ImportDescriptorsResponse { #[serde(default)] pub warnings: Vec, /// Optional detailed error information if the import failed for this descriptor - pub error: Option, + pub error: Option, } /// Represents a single UTXO (unspent transaction output) returned by the `listunspent` RPC method. -/// -/// # Notes: +/// +/// # 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)] @@ -134,7 +140,7 @@ pub struct ListUnspentResponse { } /// Deserializes any raw JSON value into its unprocessed string representation. -/// +/// /// Useful when you need to defer parsing, preserve exact formatting (e.g., precision), /// or handle heterogeneous value types dynamically. fn serde_raw_to_string<'de, D>(deserializer: D) -> Result @@ -145,28 +151,43 @@ where Ok(raw.get().to_string()) } +/// Represents the response returned by the `generateblock` RPC call. +/// +/// This struct is used to deserialize the JSON response, specifically extracting +/// the `hash` field from the response result, which contains the block hash +/// generated by the RPC call. #[derive(Debug, Clone, Deserialize)] -pub struct GenerateBlockResponse { +struct GenerateBlockResponse { + /// The hash of the generated block + #[cfg_attr(not(test), allow(dead_code))] hash: String, } +/// Represents an error message returned when importing descriptors fails. #[derive(Debug, Clone, Deserialize)] -pub struct RpcErrorResponse { +pub struct ImportDescriptorsErrorMessage { + /// Numeric error code identifying the type of error. pub code: i64, + /// Human-readable description of the error. pub message: String, } +/// Client for interacting with a Bitcoin RPC service. 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)] pub enum BitcoinRpcClientError { - // Transport or server-side errors + // RPC Transport errors Rpc(RpcError), - // Local JSON issues + // JSON serialization errors Serialization(serde_json::Error), } @@ -247,17 +268,11 @@ impl BitcoinRpcClient { /// # Returns /// Returns `Ok(())` if the wallet is created successfully. /// - /// # Errors - /// Returns an error if the wallet creation fails. This includes: - /// - The wallet already exists. - /// - Invalid parameters. - /// - Node-level failures or RPC connection issues. - /// /// # Availability /// Available in Bitcoin Core since **v0.17.0**. /// - /// # Notes: - /// This method supports only a subset of available RPC arguments to match current usage. + /// # 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, @@ -279,9 +294,6 @@ impl BitcoinRpcClient { /// # Returns /// A vector of wallet names as strings. /// - /// # Errors - /// Returns an error if the RPC call fails or if communication with the node is interrupted. - /// /// # Availability /// Available since Bitcoin Core **v0.15.0**. pub fn list_wallets(&self) -> BitcoinRpcClientResult> { @@ -293,29 +305,18 @@ impl BitcoinRpcClient { /// 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. - /// * `max_confirmations` - Maximum number of confirmations allowed. Use `None` for effectively unlimited. - /// * `addresses` - Optional list of addresses to filter UTXOs by. If `None`, all UTXOs are returned. - /// * `include_unsafe` - Whether to include UTXOs from unconfirmed unsafe transactions. - /// * `minimum_amount` - Minimum amount (in satoshis) a UTXO must have to be included. - /// * `maximum_count` - Maximum number of UTXOs to return. Use `None` for effectively unlimited. - /// - /// Default values are applied for omitted parameters: - /// - `min_confirmations` defaults to 0 - /// - `max_confirmations` defaults to 9,999,999 - /// - `addresses` defaults to an empty list (no filtering) - /// - `include_unsafe` defaults to `true` - /// - `minimum_amount` defaults to 0 satoshis - /// - `maximum_count` defaults to 9,999,999 + /// * `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) a UTXO must have to be included (Default: 0). + /// * `maximum_count` - Maximum number of UTXOs to return. Use `None` for effectively unlimited (Default: 9.999.999). /// /// # Returns - /// A `Vec` containing the matching UTXOs. + /// A Vec<[`ListUnspentResponse`]> containing the matching UTXOs. /// - /// # Errors - /// Returns a `BitcoinRpcClientError` if the RPC call fails or the response cannot be parsed. - /// - /// # Notes: - /// This method supports only a subset of available RPC arguments to match current usage. + /// # 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, @@ -358,13 +359,11 @@ impl BitcoinRpcClient { /// # Returns /// A vector of block hashes corresponding to the newly generated blocks. /// - /// # Errors - /// Returns an error if the block generation fails (e.g., invalid address or RPC issues). - /// /// # Availability /// Available in Bitcoin Core since **v0.17.0**. + /// + /// # Notes /// Typically used on `regtest` or test networks. - /// NOTE: Candidate to be a test util, but this api is used in production code when a burnchain is configured in `helium` mode pub fn generate_to_address( &self, num_block: u64, @@ -388,9 +387,6 @@ impl BitcoinRpcClient { /// # Returns /// A [`GetTransactionResponse`] containing detailed metadata for the specified transaction. /// - /// # Errors - /// Returns an error if the transaction is not found in the wallet, or if the RPC request fails. - /// /// # Availability /// Available in Bitcoin Core since **v0.10.0**. pub fn get_transaction(&self, txid: &str) -> BitcoinRpcClientResult { @@ -401,8 +397,7 @@ impl BitcoinRpcClient { /// Broadcasts a raw transaction to the Bitcoin network. /// - /// This method sends a hex-encoded raw Bitcoin transaction using the - /// `sendrawtransaction` RPC endpoint. It supports optional limits for the + /// 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 @@ -416,13 +411,7 @@ impl BitcoinRpcClient { /// - If `None`, defaults to `0`, meaning burning is not allowed. /// /// # Returns - /// - /// * On success, returns the transaction ID (`txid`) as a `String`. - /// - /// # Errors - /// - /// Returns an `RpcError` if the RPC call fails, the transaction is invalid, - /// or if fee or burn limits are exceeded. + /// A transaction ID as a `String`. pub fn send_raw_transaction( &self, tx: &str, @@ -447,9 +436,6 @@ impl BitcoinRpcClient { /// # Returns /// A `DescriptorInfoResponse` containing parsed descriptor information such as the checksum. /// - /// # Errors - /// Returns an error if the descriptor is invalid or the RPC call fails. - /// /// # Availability /// Available in Bitcoin Core since **v0.18.0**. pub fn get_descriptor_info( @@ -465,7 +451,6 @@ impl BitcoinRpcClient { /// 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. @@ -473,10 +458,6 @@ impl BitcoinRpcClient { /// # Returns /// A vector of `ImportDescriptorsResponse` results, one for each descriptor import attempt. /// - /// # Errors - /// Returns an error if the request fails, if the input cannot be serialized, - /// or if the Bitcoin node responds with an error. - /// /// # Availability /// Available in Bitcoin Core since **v0.21.0**. pub fn import_descriptors( @@ -494,13 +475,6 @@ impl BitcoinRpcClient { vec![descriptor_values.into()], )?) } - - //TODO REMOVE: - pub fn get_blockchaininfo(&self) -> BitcoinRpcClientResult<()> { - self.global_ep - .send::(&self.client_id, "getblockchaininfo", vec![])?; - Ok(()) - } } /// Test-only utilities for `BitcoinRpcClient` @@ -514,9 +488,6 @@ impl BitcoinRpcClient { /// # Returns /// A raw transaction as a hex-encoded string. /// - /// # Errors - /// Returns an error if the transaction is not found or if the RPC request fails. - /// /// # Availability /// Available in Bitcoin Core since **v0.7.0**. pub fn get_raw_transaction(&self, txid: &str) -> BitcoinRpcClientResult { @@ -537,9 +508,6 @@ impl BitcoinRpcClient { /// # Returns /// The block hash of the newly generated block. /// - /// # Errors - /// Returns an error if block generation fails (e.g., invalid address, missing transactions, or malformed data). - /// /// # Availability /// Available in Bitcoin Core since **v22.0**. Requires `regtest` or similar testing networks. pub fn generate_block( @@ -561,11 +529,7 @@ impl BitcoinRpcClient { /// to disk and the node exits cleanly. /// /// # Returns - /// On success, returns the string: - /// `"Bitcoin Core stopping"` - /// - /// # Errors - /// Returns an error if the RPC command fails (e.g., connection issue or insufficient permissions). + /// On success, returns the string: `"Bitcoin Core stopping"` /// /// # Availability /// Available in Bitcoin Core since **v0.1.0**. @@ -584,9 +548,6 @@ impl BitcoinRpcClient { /// # Returns /// A string representing the newly generated Bitcoin address. /// - /// # Errors - /// Returns an error if the wallet is not loaded or if address generation fails. - /// /// # Availability /// Available in Bitcoin Core since **v0.1.0**. /// `address_type` parameter supported since **v0.17.0**. From f284412c718a08dfceed2ac8da14d9b736d09eda Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 09:23:49 +0200 Subject: [PATCH 18/62] test: add test for bitcoin rpc auth, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 332 +++++++++++++----- stacks-node/src/burnchains/rpc_transport.rs | 25 +- 2 files changed, 263 insertions(+), 94 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index 7f7c589ccd..bcfc20e919 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -151,18 +151,6 @@ where Ok(raw.get().to_string()) } -/// Represents the response returned by the `generateblock` RPC call. -/// -/// This struct is used to deserialize the JSON response, specifically extracting -/// the `hash` field from the response result, which contains the block hash -/// generated by the RPC call. -#[derive(Debug, Clone, Deserialize)] -struct GenerateBlockResponse { - /// The hash of the generated block - #[cfg_attr(not(test), allow(dead_code))] - hash: String, -} - /// Represents an error message returned when importing descriptors fails. #[derive(Debug, Clone, Deserialize)] pub struct ImportDescriptorsErrorMessage { @@ -173,6 +161,7 @@ pub struct ImportDescriptorsErrorMessage { } /// 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, @@ -207,53 +196,55 @@ impl From for BitcoinRpcClientError { pub type BitcoinRpcClientResult = Result; impl BitcoinRpcClient { - pub fn from_params( - host: String, - port: u16, - ssl: bool, - username: String, - password: String, - wallet_name: String, - ) -> Self { - let protocol = if ssl { "https" } else { "http" }; - let global_path = format!("{protocol}://{host}:{port}"); - let wallet_path = format!("{global_path}/wallet/{wallet_name}"); - let client_id = "stacks"; - let auth = RpcAuth::Basic { username, password }; - - Self { - client_id: client_id.to_string(), - global_ep: RpcTransport::new(global_path, auth.clone(), None) - .expect("Global endpoint should be ok!"), - wallet_ep: RpcTransport::new(wallet_path, auth, None) - .expect("Wallet endpoint should be ok!"), - } - } - - //TODO: check config and eventually return Result - pub fn from_stx_config(config: &Config) -> Self { + /// Create a [`BitcoinRpcClient`] from Stacks Configuration, mainly using `BurnchainConfig` + pub fn from_stx_config(config: &Config) -> Result { let host = config.burnchain.peer_host.clone(); let port = config.burnchain.rpc_port; let ssl = config.burnchain.rpc_ssl; - let username = config.burnchain.username.clone().unwrap(); - let password = config.burnchain.password.clone().unwrap(); + 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("Missing RPC credentials!".to_string()), + }; + + Self::new(host, port, ssl, rpc_auth, wallet_name, timeout, client_id) + } + fn new( + host: String, + port: u16, + ssl: bool, + auth: RpcAuth, + wallet_name: String, + timeout: u32, + client_id: String, + ) -> Result { let protocol = if ssl { "https" } else { "http" }; - let global_path = format!("{protocol}://{host}:{port}"); - let wallet_path = format!("{global_path}/wallet/{wallet_name}"); - - let client_id = "stacks"; - let auth = RpcAuth::Basic { username, password }; - let timeout = Duration::from_secs(u64::from(config.burnchain.timeout)); - - Self { - client_id: client_id.to_string(), - global_ep: RpcTransport::new(global_path, auth.clone(), Some(timeout.clone())) - .expect("Global endpoint should be ok!"), - wallet_ep: RpcTransport::new(wallet_path, auth, Some(timeout)) - .expect("Wallet endpoint should be ok!"), - } + let rpc_global_path = format!("{protocol}://{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())) + .map_err(|e| format!("Failed to create global RpcTransport: {e:?}"))?; + let wallet_ep = RpcTransport::new(rpc_wallet_path, rpc_auth, Some(rpc_timeout)) + .map_err(|e| format!("Failed to create wallet RpcTransport: {e:?}"))?; + + Ok(Self { + client_id, + global_ep, + wallet_ep, + }) } /// Creates and loads a new wallet into the Bitcoin Core node. @@ -477,7 +468,35 @@ impl BitcoinRpcClient { } } -/// Test-only utilities for `BitcoinRpcClient` +/// Test-only utilities for [`BitcoinRpcClient`] + +/// 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. +#[cfg(test)] +#[derive(Debug, Clone, Deserialize)] +pub struct GetBlockChainInfoResponse { + /// the network name + pub chain: String, + /// 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")] + pub best_block_hash: String, +} + +/// Represents the response returned by the `generateblock` RPC call. +#[cfg(test)] +#[derive(Debug, Clone, Deserialize)] +struct GenerateBlockResponse { + /// The hash of the generated block + hash: String, +} + #[cfg(test)] impl BitcoinRpcClient { /// Retrieves the raw hex-encoded transaction by its ID. @@ -586,6 +605,19 @@ impl BitcoinRpcClient { vec![address.into(), amount.into()], )?) } + + /// 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![])?) + } } #[cfg(test)] @@ -605,17 +637,59 @@ mod tests { let url = server.url(); let parsed = url::Url::parse(&url).unwrap(); - BitcoinRpcClient::from_params( + BitcoinRpcClient::new( parsed.host_str().unwrap().to_string(), parsed.port_or_known_default().unwrap(), parsed.scheme() == "https", - "user".into(), - "pass".into(), + RpcAuth::None, "mywallet".into(), + 30, + "stacks".to_string(), ) + .expect("Rpc Client creation should be ok!") } } + #[test] + fn test_get_blockchain_info_ok() { + 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": "00000" + }, + "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!("regtest", info.chain); + assert_eq!(1, info.blocks); + assert_eq!(2, info.headers); + assert_eq!("00000", info.best_block_hash); + } + #[test] fn test_create_wallet_ok() { let expected_request = json!({ @@ -637,7 +711,6 @@ mod tests { let mut server: mockito::ServerGuard = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -668,7 +741,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -716,7 +788,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/wallet/mywallet") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -775,7 +846,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") @@ -814,7 +884,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/wallet/mywallet") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -848,7 +917,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -886,7 +954,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -922,7 +989,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") @@ -957,7 +1023,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") @@ -994,7 +1059,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") @@ -1041,7 +1105,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") @@ -1077,7 +1140,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request)) .with_status(200) .with_header("Content-Type", "application/json") @@ -1109,7 +1171,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -1144,7 +1205,6 @@ mod tests { let mut server = mockito::Server::new(); let _m = server .mock("POST", "/wallet/mywallet") - .match_header("authorization", "Basic dXNlcjpwYXNz") .match_body(mockito::Matcher::PartialJson(expected_request.clone())) .with_status(200) .with_header("Content-Type", "application/json") @@ -1170,9 +1230,11 @@ mod tests { use stacks::config::Config; + use crate::burnchains::bitcoin_rpc_client::BitcoinRpcClient; + use crate::burnchains::rpc_transport::RpcAuth; use crate::util::get_epoch_time_ms; - pub fn create_config() -> Config { + 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")); @@ -1197,18 +1259,108 @@ mod tests { config } + + pub fn create_client_no_auth_from_stx_config(config: Config) -> BitcoinRpcClient { + BitcoinRpcClient::new( + config.burnchain.peer_host, + config.burnchain.rpc_port, + config.burnchain.rpc_ssl, + RpcAuth::None, + config.burnchain.wallet_name, + config.burnchain.timeout, + "stacks".to_string(), + ) + .expect("Rpc client creation should be ok!") + } + } + + #[test] + fn test_rpc_call_fails_when_bitcond_with_auth_but_rpc_no_auth() { + 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!"); + + match err { + BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { + assert!(msg.contains("401")); + } + _ => panic!("Expected RpcError::Service, got: {:?}", err), + } + } + + #[test] + fn test_rpc_call_fails_when_bitcond_no_auth_and_rpc_no_auth() { + 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!"); + + match err { + BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { + assert!(msg.contains("401")); + } + _ => panic!("Expected RpcError::Service, got: {:?}", err), + } + } + + #[test] + fn test_client_creation_fails_due_to_stx_config_missing_auth() { + 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_eq!("Missing RPC credentials!", err); + } + + #[test] + fn test_get_blockchain_info_ok() { + 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!("regtest", info.chain); + assert_eq!(0, info.blocks); + assert_eq!(0, info.headers); + assert_eq!( + "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", + info.best_block_hash + ); } #[test] fn test_wallet_listing_and_creation_ok() { - let config = utils::create_config(); + 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); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); let wallets = client.list_wallets().unwrap(); assert_eq!(0, wallets.len()); @@ -1233,14 +1385,14 @@ mod tests { #[test] fn test_wallet_creation_fails_if_already_exists() { - let config = utils::create_config(); + 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); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); client .create_wallet("mywallet1", Some(false)) @@ -1258,7 +1410,7 @@ mod tests { #[test] fn test_generate_to_address_and_list_unspent_ok() { - let mut config = utils::create_config(); + let mut config = utils::create_stx_config(); config.burnchain.wallet_name = "my_wallet".to_string(); let mut btcd_controller = BitcoinCoreController::new(config.clone()); @@ -1266,7 +1418,7 @@ mod tests { .start_bitcoind() .expect("bitcoind should be started!"); - let client = BitcoinRpcClient::from_stx_config(&config); + 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, None).expect("Should work!"); @@ -1291,7 +1443,7 @@ mod tests { #[test] fn test_generate_block_ok() { - let mut config = utils::create_config(); + let mut config = utils::create_stx_config(); config.burnchain.wallet_name = "my_wallet".to_string(); let mut btcd_controller = BitcoinCoreController::new(config.clone()); @@ -1299,7 +1451,7 @@ mod tests { .start_bitcoind() .expect("bitcoind should be started!"); - let client = BitcoinRpcClient::from_stx_config(&config); + 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, None).expect("Should work!"); @@ -1309,7 +1461,7 @@ mod tests { #[test] fn test_get_raw_transaction_ok() { - let mut config = utils::create_config(); + 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()); @@ -1318,7 +1470,7 @@ mod tests { .start_bitcoind_v2() .expect("bitcoind should be started!"); - let client = BitcoinRpcClient::from_stx_config(&config); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); client .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); @@ -1345,7 +1497,7 @@ mod tests { #[test] fn test_get_transaction_ok() { - let mut config = utils::create_config(); + 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()); @@ -1354,7 +1506,7 @@ mod tests { .start_bitcoind_v2() .expect("bitcoind should be started!"); - let client = BitcoinRpcClient::from_stx_config(&config); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); client .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); @@ -1378,7 +1530,7 @@ mod tests { #[test] fn test_get_descriptor_ok() { - let mut config = utils::create_config(); + 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()); @@ -1386,7 +1538,7 @@ mod tests { .start_bitcoind_v2() .expect("bitcoind should be started!"); - let client = BitcoinRpcClient::from_stx_config(&config); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); client .create_wallet("my_wallet", None) .expect("create wallet ok!"); @@ -1403,7 +1555,7 @@ mod tests { #[test] fn test_import_descriptor_ok() { - let mut config = utils::create_config(); + 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()); @@ -1411,7 +1563,7 @@ mod tests { .start_bitcoind_v2() .expect("bitcoind should be started!"); - let client = BitcoinRpcClient::from_stx_config(&config); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); client .create_wallet("my_wallet", Some(true)) .expect("create wallet ok!"); @@ -1434,14 +1586,14 @@ mod tests { #[test] fn test_stop_bitcoind_ok() { - let config = utils::create_config(); + 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); + let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); let msg = client.stop().expect("Should shutdown!"); assert_eq!("Bitcoin Core stopping", msg); } diff --git a/stacks-node/src/burnchains/rpc_transport.rs b/stacks-node/src/burnchains/rpc_transport.rs index faa05dffd8..841a484c9e 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc_transport.rs @@ -83,11 +83,12 @@ pub enum RpcAuth { /// /// This struct encapsulates the target URL, optional authentication, /// and an internal HTTP client. +#[derive(Debug)] pub struct RpcTransport { /// The base URL of the JSON-RPC endpoint. - pub url: String, - /// Optional authentication to apply to outgoing requests. - pub auth: RpcAuth, + url: String, + /// Authentication to apply to outgoing requests. + auth: RpcAuth, /// The reqwest http client client: ReqwestClient, } @@ -99,7 +100,7 @@ impl RpcTransport { /// /// * `url` - The JSON-RPC server endpoint. /// * `auth` - Authentication configuration (`None` or `Basic`). - /// * `timeout` - Optional request timeout duration. + /// * `timeout` - Optional request timeout duration. (`None` to disable timeout) /// /// # Errors /// @@ -150,6 +151,22 @@ impl RpcTransport { .send() .map_err(|err| RpcError::Network(err.to_string()))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + let body_msg = if body.trim().is_empty() { + "".to_string() + } else { + body + }; + + return Err(RpcError::Service(format!( + "HTTP error {}: {}", + status.as_u16(), + body_msg, + ))); + } + let parsed: JsonRpcResponse = response.json().map_err(Self::classify_parse_error)?; if id != parsed.id { From 6d8a63464b5ede0ca9eae78e6fcb4874cda88b70 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 10:29:26 +0200 Subject: [PATCH 19/62] feat: add invalidate_block rpc, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 89 +++++++++++++++++-- stacks-node/src/burnchains/rpc_transport.rs | 41 +++------ 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index bcfc20e919..ef5c0325c1 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -499,6 +499,19 @@ struct GenerateBlockResponse { #[cfg(test)] 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 the raw hex-encoded transaction by its ID. /// /// # Arguments @@ -598,6 +611,8 @@ impl BitcoinRpcClient { /// /// # Returns /// The transaction ID as hex string + /// + /// Available in Bitcoin Core since **v0.1.0**. pub fn send_to_address(&self, address: &str, amount: f64) -> BitcoinRpcClientResult { Ok(self.wallet_ep.send( &self.client_id, @@ -606,18 +621,25 @@ impl BitcoinRpcClient { )?) } - /// Retrieve general information about the current state of the blockchain. + /// Invalidate a block by its block hash, forcing the node to reconsider its chain state. /// /// # Arguments - /// None. + /// * `hash` - The block hash (as a hex string) of the block to invalidate. /// /// # Returns - /// A [`GetBlockChainInfoResponse`] struct containing blockchain metadata. - pub fn get_blockchain_info(&self) -> BitcoinRpcClientResult { - Ok(self - .global_ep - .send(&self.client_id, "getblockchaininfo", vec![])?) + /// An empty `()` on success. + /// + /// # Availability + /// Available in Bitcoin Core since **v0.1.0**. + pub fn invalidate_block(&self, hash: &str) -> BitcoinRpcClientResult<()> { + self.global_ep.send::( + &self.client_id, + "invalidateblock", + vec![hash.into()], + )?; + Ok(()) } + } #[cfg(test)] @@ -1218,6 +1240,39 @@ mod tests { .expect("Should be ok!"); assert_eq!(expected_txid, txid); } + + #[test] + fn test_invalidate_block_ok() { + let hash = "0000"; + + 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); + + client + .invalidate_block(hash) + .expect("Should be ok!"); + } } #[cfg(test)] @@ -1597,5 +1652,25 @@ mod tests { let msg = client.stop().expect("Should shutdown!"); assert_eq!("Bitcoin Core stopping", msg); } + + #[test] + fn test_invalidate_block_ok() { + 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, None).expect("Should work!"); + let block_hash = client.generate_block(&address, vec![]).expect("OK"); + + client.invalidate_block(&block_hash).expect("Invalidate valid hash should be ok!"); + client.invalidate_block("invalid_hash").expect_err("Invalidate invalid hash should fail!"); + } + } } diff --git a/stacks-node/src/burnchains/rpc_transport.rs b/stacks-node/src/burnchains/rpc_transport.rs index 841a484c9e..43a2b2eb73 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc_transport.rs @@ -153,17 +153,9 @@ impl RpcTransport { if !response.status().is_success() { let status = response.status(); - let body = response.text().unwrap_or_default(); - let body_msg = if body.trim().is_empty() { - "".to_string() - } else { - body - }; - return Err(RpcError::Service(format!( - "HTTP error {}: {}", + "HTTP error {}", status.as_u16(), - body_msg, ))); } @@ -176,12 +168,14 @@ impl RpcTransport { ))); } - match (parsed.result, parsed.error) { - (Some(result), None) => Ok(result), - (_, Some(err)) => Err(RpcError::Service(format!("{:#}", err))), - _ => Err(RpcError::Parsing( - "Invalid response: missing both 'result' and 'error'".to_string(), - )), + if let Some(error) = parsed.error { + return Err(RpcError::Service(format!("{:#}", error))); + } + + if let Some(result) = parsed.result { + Ok(result) + } else { + Ok(serde_json::from_value(Value::Null).unwrap()) } } @@ -329,10 +323,10 @@ mod tests { assert!(result.is_err()); match result { - Err(RpcError::Parsing(msg)) => { - assert!(msg.starts_with("Failed to parse RPC response:")) + Err(RpcError::Service(msg)) => { + assert!(msg.contains("500")) } - _ => panic!("Expected parse error"), + _ => panic!("Expected error 500"), } } @@ -359,10 +353,9 @@ mod tests { } #[test] - fn test_send_fails_due_to_missing_result_and_error() { + fn test_send_ok_if_missing_both_result_and_error() { let response_body = json!({ "id": "client_id", - "foo": "bar", }); let mut server = mockito::Server::new(); @@ -375,13 +368,7 @@ mod tests { let transport = utils::rpc_no_auth(&server); let result: RpcResult = transport.send("client_id", "dummy", vec![]); - - match result { - Err(RpcError::Parsing(msg)) => { - assert_eq!("Invalid response: missing both 'result' and 'error'", msg) - } - _ => panic!("Expected missing result/error error"), - } + assert!(result.is_ok()); } #[test] From 523667a4b24e4c6b0ef21a43ecf1f0bb2889e2c7 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 12:10:13 +0200 Subject: [PATCH 20/62] feat: complete implementation and docs, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 147 ++++++++++++++---- stacks-node/src/burnchains/rpc_transport.rs | 7 +- 2 files changed, 119 insertions(+), 35 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index ef5c0325c1..f7bc19f00c 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -218,6 +218,22 @@ impl BitcoinRpcClient { Self::new(host, port, ssl, 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. + /// * `ssl` - If `true`, uses HTTPS for communication; otherwise, uses HTTP. + /// * `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 + /// + /// Returns `Ok(Self)` if both global and wallet RPC transports are successfully created, + /// or `Err(String)` if the underlying HTTP client setup fails.Stacks Configuration, mainly using `BurnchainConfig` fn new( host: String, port: u16, @@ -260,7 +276,7 @@ impl BitcoinRpcClient { /// Returns `Ok(())` if the wallet is created successfully. /// /// # Availability - /// Available in Bitcoin Core since **v0.17.0**. + /// - **Since**: Bitcoin Core **v0.17.0**. /// /// # Notes /// This method supports a subset of available RPC arguments to match current usage. @@ -351,7 +367,7 @@ impl BitcoinRpcClient { /// A vector of block hashes corresponding to the newly generated blocks. /// /// # Availability - /// Available in Bitcoin Core since **v0.17.0**. + /// - **Since**: Bitcoin Core **v0.17.0**. /// /// # Notes /// Typically used on `regtest` or test networks. @@ -379,7 +395,7 @@ impl BitcoinRpcClient { /// A [`GetTransactionResponse`] containing detailed metadata for the specified transaction. /// /// # Availability - /// Available in Bitcoin Core since **v0.10.0**. + /// - **Since**: Bitcoin Core **v0.10.0**. pub fn get_transaction(&self, txid: &str) -> BitcoinRpcClientResult { Ok(self .wallet_ep @@ -403,6 +419,10 @@ impl BitcoinRpcClient { /// /// # Returns /// A transaction ID as a `String`. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.7.0**. + /// - `maxburnamount` parameter is available starting from **v25.0**. pub fn send_raw_transaction( &self, tx: &str, @@ -428,7 +448,7 @@ impl BitcoinRpcClient { /// A `DescriptorInfoResponse` containing parsed descriptor information such as the checksum. /// /// # Availability - /// Available in Bitcoin Core since **v0.18.0**. + /// - **Since**: Bitcoin Core **v0.18.0**. pub fn get_descriptor_info( &self, descriptor: &str, @@ -450,7 +470,7 @@ impl BitcoinRpcClient { /// A vector of `ImportDescriptorsResponse` results, one for each descriptor import attempt. /// /// # Availability - /// Available in Bitcoin Core since **v0.21.0**. + /// - **Since**: Bitcoin Core **v0.21.0**. pub fn import_descriptors( &self, descriptors: &[ImportDescriptorsRequest], @@ -466,6 +486,22 @@ impl BitcoinRpcClient { 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 `String` representing the block hash in hexadecimal format. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.9.0**. + pub fn get_block_hash(&self, height: u64) -> BitcoinRpcClientResult { + Ok(self + .global_ep + .send(&self.client_id, "getblockhash", vec![height.into()])?) + } } /// Test-only utilities for [`BitcoinRpcClient`] @@ -521,7 +557,7 @@ impl BitcoinRpcClient { /// A raw transaction as a hex-encoded string. /// /// # Availability - /// Available in Bitcoin Core since **v0.7.0**. + /// - **Since**: Bitcoin Core **v0.7.0**. pub fn get_raw_transaction(&self, txid: &str) -> BitcoinRpcClientResult { Ok(self .global_ep @@ -541,7 +577,8 @@ impl BitcoinRpcClient { /// The block hash of the newly generated block. /// /// # Availability - /// Available in Bitcoin Core since **v22.0**. Requires `regtest` or similar testing networks. + /// - **Since**: Bitcoin Core **v22.0**. + /// - Requires `regtest` or similar testing networks. pub fn generate_block( &self, address: &str, @@ -564,7 +601,7 @@ impl BitcoinRpcClient { /// On success, returns the string: `"Bitcoin Core stopping"` /// /// # Availability - /// Available in Bitcoin Core since **v0.1.0**. + /// - **Since**: Bitcoin Core **v0.1.0**. pub fn stop(&self) -> BitcoinRpcClientResult { Ok(self.global_ep.send(&self.client_id, "stop", vec![])?) } @@ -581,9 +618,9 @@ impl BitcoinRpcClient { /// A string representing the newly generated Bitcoin address. /// /// # Availability - /// Available in Bitcoin Core since **v0.1.0**. - /// `address_type` parameter supported since **v0.17.0**. - /// Defaulting to `bech32` (when unset) introduced in **v0.20.0**. + /// - **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>, @@ -611,8 +648,9 @@ impl BitcoinRpcClient { /// /// # Returns /// The transaction ID as hex string - /// - /// Available in Bitcoin Core since **v0.1.0**. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. pub fn send_to_address(&self, address: &str, amount: f64) -> BitcoinRpcClientResult { Ok(self.wallet_ep.send( &self.client_id, @@ -628,18 +666,14 @@ impl BitcoinRpcClient { /// /// # Returns /// An empty `()` on success. - /// + /// /// # Availability - /// Available in Bitcoin Core since **v0.1.0**. + /// - **Since**: Bitcoin Core **v0.1.0**. pub fn invalidate_block(&self, hash: &str) -> BitcoinRpcClientResult<()> { - self.global_ep.send::( - &self.client_id, - "invalidateblock", - vec![hash.into()], - )?; + self.global_ep + .send::(&self.client_id, "invalidateblock", vec![hash.into()])?; Ok(()) } - } #[cfg(test)] @@ -1244,7 +1278,7 @@ mod tests { #[test] fn test_invalidate_block_ok() { let hash = "0000"; - + let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", @@ -1269,14 +1303,47 @@ mod tests { let client = utils::setup_client(&server); - client - .invalidate_block(hash) - .expect("Should be ok!"); + client.invalidate_block(hash).expect("Should be ok!"); + } + + #[test] + fn test_get_block_hash_ok() { + let height = 1; + let expected_hash = "0000"; + + 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 hash = client.get_block_hash(height).expect("Should be ok!"); + assert_eq!(expected_hash, hash); } } #[cfg(test)] mod inte { + use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; + use super::*; use crate::tests::bitcoin_regtest::BitcoinCoreController; @@ -1400,10 +1467,7 @@ mod tests { assert_eq!("regtest", info.chain); assert_eq!(0, info.blocks); assert_eq!(0, info.headers); - assert_eq!( - "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", - info.best_block_hash - ); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, info.best_block_hash); } #[test] @@ -1668,9 +1732,30 @@ mod tests { let address = client.get_new_address(None, None).expect("Should work!"); let block_hash = client.generate_block(&address, vec![]).expect("OK"); - client.invalidate_block(&block_hash).expect("Invalidate valid hash should be ok!"); - client.invalidate_block("invalid_hash").expect_err("Invalidate invalid hash should fail!"); + client + .invalidate_block(&block_hash) + .expect("Invalidate valid hash should be ok!"); + client + .invalidate_block("invalid_hash") + .expect_err("Invalidate invalid hash should fail!"); } + #[test] + fn test_get_block_hash_ok() { + 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 hash = client + .get_block_hash(0) + .expect("Should return regtest genesis block hash!"); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); + } } } diff --git a/stacks-node/src/burnchains/rpc_transport.rs b/stacks-node/src/burnchains/rpc_transport.rs index 43a2b2eb73..c7d12e622b 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc_transport.rs @@ -153,10 +153,9 @@ impl RpcTransport { if !response.status().is_success() { let status = response.status(); - return Err(RpcError::Service(format!( - "HTTP error {}", - status.as_u16(), - ))); + return Err(RpcError::Service( + format!("HTTP error {}", status.as_u16(),), + )); } let parsed: JsonRpcResponse = response.json().map_err(Self::classify_parse_error)?; From 8d6fa55c2b44f70792087784ee10904b5c01d05f Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 13:17:53 +0200 Subject: [PATCH 21/62] chore: cleaning, #6250 --- .../src/burnchains/bitcoin_rpc_client.rs | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index f7bc19f00c..d418eb0267 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -29,7 +29,6 @@ use serde_json::value::RawValue; use serde_json::{json, Value}; use stacks::config::Config; -use crate::burnchains::bitcoin_regtest_controller::ParsedUTXO; use crate::burnchains::rpc_transport::{RpcAuth, RpcError, RpcTransport}; /// Response structure for the `gettransaction` RPC call. @@ -316,7 +315,7 @@ impl BitcoinRpcClient { /// * `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) a UTXO must have to be included (Default: 0). + /// * `minimum_amount` - Minimum amount (in BTC. As 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: 9.999.999). /// /// # Returns @@ -329,16 +328,16 @@ impl BitcoinRpcClient { &self, min_confirmations: Option, max_confirmations: Option, - addresses: Option>, + addresses: Option<&[&str]>, include_unsafe: Option, - minimum_amount: Option, + minimum_amount: Option<&str>, maximum_count: Option, ) -> BitcoinRpcClientResult> { let min_confirmations = min_confirmations.unwrap_or(0); let max_confirmations = max_confirmations.unwrap_or(9999999); - let addresses = addresses.unwrap_or(vec![]); + let addresses = addresses.unwrap_or(&[]); let include_unsafe = include_unsafe.unwrap_or(true); - let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_amount.unwrap_or(0)); + let minimum_amount = minimum_amount.unwrap_or("0"); let maximum_count = maximum_count.unwrap_or(9999999); Ok(self.wallet_ep.send( @@ -473,7 +472,7 @@ impl BitcoinRpcClient { /// - **Since**: Bitcoin Core **v0.21.0**. pub fn import_descriptors( &self, - descriptors: &[ImportDescriptorsRequest], + descriptors: &[&ImportDescriptorsRequest], ) -> BitcoinRpcClientResult> { let descriptor_values = descriptors .iter() @@ -582,7 +581,7 @@ impl BitcoinRpcClient { pub fn generate_block( &self, address: &str, - txs: Vec, + txs: &[&str], ) -> BitcoinRpcClientResult { let response = self.global_ep.send::( &self.client_id, @@ -856,9 +855,9 @@ mod tests { .list_unspent( Some(1), Some(10), - Some(vec!["BTC_ADDRESS_1".into()]), + Some(&["BTC_ADDRESS_1"]), Some(true), - Some(1000), // 1000 sats = 0.00001000 BTC + Some("0.00001000"), // 1000 sats = 0.00001000 BTC Some(5), ) .expect("Should parse unspent outputs"); @@ -1019,7 +1018,7 @@ mod tests { let client = utils::setup_client(&server); let result = client - .generate_block(addr, vec![txid1.to_string(), txid2.to_string()]) + .generate_block(addr, &[txid1, txid2]) .expect("Should be ok!"); assert_eq!(expected_block_hash, result); } @@ -1174,7 +1173,7 @@ mod tests { timestamp: Timestamp::Time(timestamp), internal: Some(internal), }; - let result = client.import_descriptors(&[desc_req]); + let result = client.import_descriptors(&[&desc_req]); assert!(result.is_ok()); } @@ -1542,7 +1541,7 @@ mod tests { let address = client.get_new_address(None, None).expect("Should work!"); let utxos = client - .list_unspent(None, None, None, Some(false), Some(1), Some(10)) + .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(0, utxos.len()); @@ -1550,12 +1549,12 @@ mod tests { assert_eq!(102, blocks.len()); let utxos = client - .list_unspent(None, None, None, Some(false), Some(1), Some(10)) + .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(2, utxos.len()); let utxos = client - .list_unspent(None, None, None, Some(false), Some(1), Some(1)) + .list_unspent(None, None, None, Some(false), Some("1"), Some(1)) .expect("list_unspent should be ok!"); assert_eq!(1, utxos.len()); } @@ -1574,7 +1573,7 @@ mod tests { client.create_wallet("my_wallet", Some(false)).expect("OK"); let address = client.get_new_address(None, None).expect("Should work!"); - let block_hash = client.generate_block(&address, vec![]).expect("OK"); + let block_hash = client.generate_block(&address, &[]).expect("OK"); assert_eq!(64, block_hash.len()); } @@ -1697,7 +1696,7 @@ mod tests { }; let response = client - .import_descriptors(&[desc_req]) + .import_descriptors(&[&desc_req]) .expect("import descriptor ok!"); assert_eq!(1, response.len()); assert!(response[0].success); @@ -1730,7 +1729,7 @@ mod tests { 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, None).expect("Should work!"); - let block_hash = client.generate_block(&address, vec![]).expect("OK"); + let block_hash = client.generate_block(&address, &[]).expect("OK"); client .invalidate_block(&block_hash) From a7d3907e9ebbb9a66aaf24389294fbe3fcb35763 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 13:41:32 +0200 Subject: [PATCH 22/62] chore: make BitcoinCoreController::config private and update doc, #6250 --- stacks-node/src/burnchains/bitcoin_rpc_client.rs | 2 +- stacks-node/src/tests/bitcoin_regtest.rs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/bitcoin_rpc_client.rs index d418eb0267..9652293fb9 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/bitcoin_rpc_client.rs @@ -195,7 +195,7 @@ impl From for BitcoinRpcClientError { pub type BitcoinRpcClientResult = Result; impl BitcoinRpcClient { - /// Create a [`BitcoinRpcClient`] from Stacks Configuration, mainly using `BurnchainConfig` + /// Create a [`BitcoinRpcClient`] from Stacks Configuration, mainly using [`stacks::config::BurnchainConfig`] pub fn from_stx_config(config: &Config) -> Result { let host = config.burnchain.peer_host.clone(); let port = config.burnchain.rpc_port; diff --git a/stacks-node/src/tests/bitcoin_regtest.rs b/stacks-node/src/tests/bitcoin_regtest.rs index a674723f85..219923ca60 100644 --- a/stacks-node/src/tests/bitcoin_regtest.rs +++ b/stacks-node/src/tests/bitcoin_regtest.rs @@ -32,12 +32,12 @@ 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 `from_stx_config` + /// TODO: to be removed in favor of [`Self::from_stx_config`] pub fn new(config: Config) -> BitcoinCoreController { BitcoinCoreController { bitcoind_process: None, @@ -46,16 +46,14 @@ impl BitcoinCoreController { } } - pub fn from_stx_config(config: Config) -> BitcoinCoreController { + /// 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: config.clone(), //TODO: clone can be removed once args: vec![], }; - //TODO: Remove this once verified if `pub config` is really needed or not. - let config = result.config.clone(); - result.add_arg("-regtest"); result.add_arg("-nodebug"); result.add_arg("-nodebuglogfile"); @@ -88,12 +86,13 @@ impl BitcoinCoreController { result } + /// Add argument (like "-name=value") to be used to run bitcoind process pub fn add_arg(&mut self, arg: impl Into) -> &mut Self { - //TODO: eventually protect againt duplicated arg 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(); @@ -143,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(); From cc3a1db390291e5c08bcf31cec4598e17aea7321 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 13:57:49 +0200 Subject: [PATCH 23/62] chore: reorganize in rpc folder, #6250 --- stacks-node/src/burnchains/mod.rs | 3 +-- .../burnchains/{ => rpc}/bitcoin_rpc_client.rs | 13 ++++--------- stacks-node/src/burnchains/rpc/mod.rs | 17 +++++++++++++++++ .../src/burnchains/{ => rpc}/rpc_transport.rs | 2 +- stacks-node/src/tests/bitcoin_regtest.rs | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) rename stacks-node/src/burnchains/{ => rpc}/bitcoin_rpc_client.rs (99%) create mode 100644 stacks-node/src/burnchains/rpc/mod.rs rename stacks-node/src/burnchains/{ => rpc}/rpc_transport.rs (99%) diff --git a/stacks-node/src/burnchains/mod.rs b/stacks-node/src/burnchains/mod.rs index 70c05d9136..ab7e14dd66 100644 --- a/stacks-node/src/burnchains/mod.rs +++ b/stacks-node/src/burnchains/mod.rs @@ -1,7 +1,6 @@ pub mod bitcoin_regtest_controller; -pub mod bitcoin_rpc_client; pub mod mocknet_controller; -pub mod rpc_transport; +pub mod rpc; use std::time::Instant; diff --git a/stacks-node/src/burnchains/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs similarity index 99% rename from stacks-node/src/burnchains/bitcoin_rpc_client.rs rename to stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs index 9652293fb9..c801ebfedf 100644 --- a/stacks-node/src/burnchains/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs @@ -29,7 +29,7 @@ use serde_json::value::RawValue; use serde_json::{json, Value}; use stacks::config::Config; -use crate::burnchains::rpc_transport::{RpcAuth, RpcError, RpcTransport}; +use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; /// Response structure for the `gettransaction` RPC call. /// @@ -418,7 +418,7 @@ impl BitcoinRpcClient { /// /// # Returns /// A transaction ID as a `String`. - /// + /// /// # Availability /// - **Since**: Bitcoin Core **v0.7.0**. /// - `maxburnamount` parameter is available starting from **v25.0**. @@ -578,11 +578,7 @@ impl BitcoinRpcClient { /// # Availability /// - **Since**: Bitcoin Core **v22.0**. /// - Requires `regtest` or similar testing networks. - pub fn generate_block( - &self, - address: &str, - txs: &[&str], - ) -> BitcoinRpcClientResult { + pub fn generate_block(&self, address: &str, txs: &[&str]) -> BitcoinRpcClientResult { let response = self.global_ep.send::( &self.client_id, "generateblock", @@ -1351,8 +1347,7 @@ mod tests { use stacks::config::Config; - use crate::burnchains::bitcoin_rpc_client::BitcoinRpcClient; - use crate::burnchains::rpc_transport::RpcAuth; + use super::*; use crate::util::get_epoch_time_ms; pub fn create_stx_config() -> Config { 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_transport.rs b/stacks-node/src/burnchains/rpc/rpc_transport.rs similarity index 99% rename from stacks-node/src/burnchains/rpc_transport.rs rename to stacks-node/src/burnchains/rpc/rpc_transport.rs index c7d12e622b..1b58225cc2 100644 --- a/stacks-node/src/burnchains/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport.rs @@ -210,7 +210,7 @@ mod tests { use super::*; mod utils { - use crate::burnchains::rpc_transport::{RpcAuth, RpcTransport}; + use super::*; pub fn rpc_no_auth(server: &mockito::ServerGuard) -> RpcTransport { RpcTransport::new(server.url(), RpcAuth::None, None) diff --git a/stacks-node/src/tests/bitcoin_regtest.rs b/stacks-node/src/tests/bitcoin_regtest.rs index 219923ca60..6b535c6bad 100644 --- a/stacks-node/src/tests/bitcoin_regtest.rs +++ b/stacks-node/src/tests/bitcoin_regtest.rs @@ -50,7 +50,7 @@ impl BitcoinCoreController { pub fn from_stx_config(config: Config) -> Self { let mut result = BitcoinCoreController { bitcoind_process: None, - config: config.clone(), //TODO: clone can be removed once + config: config.clone(), //TODO: clone can be removed once args: vec![], }; From a29a8ac1dcc97e6c9b4ef6d31fe7183cf4979cc3 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 14:02:16 +0200 Subject: [PATCH 24/62] test: configure integration tests with ignore and BITCOIND_TEST, #6250 --- .../src/burnchains/rpc/bitcoin_rpc_client.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs index c801ebfedf..c9f483cb7b 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs @@ -1337,6 +1337,8 @@ mod tests { #[cfg(test)] mod inte { + use std::env; + use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; use super::*; @@ -1390,8 +1392,13 @@ mod tests { } } + #[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()); @@ -1411,8 +1418,13 @@ mod tests { } } + #[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; @@ -1434,8 +1446,13 @@ mod tests { } } + #[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; @@ -1446,8 +1463,13 @@ mod tests { assert_eq!("Missing RPC credentials!", err); } + #[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()); @@ -1464,8 +1486,13 @@ mod tests { assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, info.best_block_hash); } + #[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()); @@ -1496,8 +1523,13 @@ mod tests { 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()); @@ -1521,8 +1553,13 @@ mod tests { )); } + #[ignore] #[test] fn test_generate_to_address_and_list_unspent_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(); @@ -1554,8 +1591,13 @@ mod tests { assert_eq!(1, 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(); @@ -1572,8 +1614,13 @@ mod tests { assert_eq!(64, block_hash.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(); @@ -1608,8 +1655,13 @@ mod tests { assert_ne!("", raw_tx); } + #[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(); @@ -1641,8 +1693,13 @@ mod tests { 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(); @@ -1666,8 +1723,13 @@ mod tests { 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(); @@ -1697,8 +1759,13 @@ mod tests { 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()); @@ -1711,8 +1778,13 @@ mod tests { 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(); @@ -1734,8 +1806,13 @@ mod tests { .expect_err("Invalidate invalid 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(); From 32f901e435c62df36cad293225b31b118b816ae5 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 24 Jul 2025 14:56:47 +0200 Subject: [PATCH 25/62] chore: revert ParsedUTXO mod, #6250 --- stacks-node/src/burnchains/bitcoin_regtest_controller.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 8d4f3bb8d3..c14376399e 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -2332,10 +2332,10 @@ impl SerializedTx { #[allow(dead_code)] pub struct ParsedUTXO { txid: String, - pub vout: u32, + vout: u32, script_pub_key: String, amount: Box, - pub confirmations: u32, + confirmations: u32, } #[derive(Clone, Debug, PartialEq)] From 919e07b646f3e0af09174da958d84d960cb4046b Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 28 Jul 2025 10:36:41 +0200 Subject: [PATCH 26/62] refactor: move bitcoin_rpc_client test facilities in dedicated module, #6250 --- .../src/burnchains/rpc/bitcoin_rpc_client.rs | 171 +--------------- .../rpc/bitcoin_rpc_client/test_utils.rs | 183 ++++++++++++++++++ 2 files changed, 186 insertions(+), 168 deletions(-) create mode 100644 stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs index c9f483cb7b..6f3f99f779 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs @@ -31,6 +31,9 @@ use stacks::config::Config; use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; +#[cfg(test)] +mod test_utils; + /// Response structure for the `gettransaction` RPC call. /// /// Contains metadata about a wallet transaction, currently limited to the confirmation count. @@ -503,174 +506,6 @@ impl BitcoinRpcClient { } } -/// Test-only utilities for [`BitcoinRpcClient`] - -/// 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. -#[cfg(test)] -#[derive(Debug, Clone, Deserialize)] -pub struct GetBlockChainInfoResponse { - /// the network name - pub chain: String, - /// 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")] - pub best_block_hash: String, -} - -/// Represents the response returned by the `generateblock` RPC call. -#[cfg(test)] -#[derive(Debug, Clone, Deserialize)] -struct GenerateBlockResponse { - /// The hash of the generated block - hash: String, -} - -#[cfg(test)] -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 the raw hex-encoded transaction by its ID. - /// - /// # Arguments - /// * `txid` - Transaction ID (hash) to fetch. - /// - /// # Returns - /// A raw transaction as a hex-encoded string. - /// - /// # Availability - /// - **Since**: Bitcoin Core **v0.7.0**. - pub fn get_raw_transaction(&self, txid: &str) -> BitcoinRpcClientResult { - Ok(self - .global_ep - .send(&self.client_id, "getrawtransaction", vec![txid.into()])?) - } - - /// Mines a new block including the given transactions to a specified address. - /// - /// # Arguments - /// * `address` - Address 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 - /// 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: &str, txs: &[&str]) -> BitcoinRpcClientResult { - let response = self.global_ep.send::( - &self.client_id, - "generateblock", - vec![address.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 address type (`"legacy"`, `"p2sh-segwit"`, `"bech32"`, `"bech32m"`). - /// 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 string 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<&str>, - ) -> BitcoinRpcClientResult { - let mut params = vec![]; - - let label = label.unwrap_or(""); - params.push(label.into()); - - if let Some(at) = address_type { - params.push(at.into()); - } - - Ok(self - .global_ep - .send(&self.client_id, "getnewaddress", params)?) - } - - /// Sends a specified amount of BTC to a given address. - /// - /// # Arguments - /// * `address` - The destination Bitcoin address. - /// * `amount` - Amount to send in BTC (not in satoshis). - /// - /// # Returns - /// The transaction ID as hex string - /// - /// # Availability - /// - **Since**: Bitcoin Core **v0.1.0**. - pub fn send_to_address(&self, address: &str, amount: f64) -> BitcoinRpcClientResult { - Ok(self.wallet_ep.send( - &self.client_id, - "sendtoaddress", - vec![address.into(), amount.into()], - )?) - } - - /// Invalidate a block by its block hash, forcing the node to reconsider its chain state. - /// - /// # Arguments - /// * `hash` - The block hash (as a hex string) of the block to invalidate. - /// - /// # Returns - /// An empty `()` on success. - /// - /// # Availability - /// - **Since**: Bitcoin Core **v0.1.0**. - pub fn invalidate_block(&self, hash: &str) -> BitcoinRpcClientResult<()> { - self.global_ep - .send::(&self.client_id, "invalidateblock", vec![hash.into()])?; - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; 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..3e5e8df724 --- /dev/null +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -0,0 +1,183 @@ +// 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_json::Value; + +use crate::burnchains::rpc::bitcoin_rpc_client::{BitcoinRpcClient, BitcoinRpcClientResult}; + +/// 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 + pub chain: String, + /// 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")] + pub best_block_hash: String, +} + +/// Represents the response returned by the `generateblock` RPC call. +#[derive(Debug, Clone, Deserialize)] +struct GenerateBlockResponse { + /// The hash of the generated block + hash: String, +} + +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 the raw hex-encoded transaction by its ID. + /// + /// # Arguments + /// * `txid` - Transaction ID (hash) to fetch. + /// + /// # Returns + /// A raw transaction as a hex-encoded string. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.7.0**. + pub fn get_raw_transaction(&self, txid: &str) -> BitcoinRpcClientResult { + Ok(self + .global_ep + .send(&self.client_id, "getrawtransaction", vec![txid.into()])?) + } + + /// Mines a new block including the given transactions to a specified address. + /// + /// # Arguments + /// * `address` - Address 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 + /// 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: &str, txs: &[&str]) -> BitcoinRpcClientResult { + let response = self.global_ep.send::( + &self.client_id, + "generateblock", + vec![address.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 address type (`"legacy"`, `"p2sh-segwit"`, `"bech32"`, `"bech32m"`). + /// 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 string 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<&str>, + ) -> BitcoinRpcClientResult { + let mut params = vec![]; + + let label = label.unwrap_or(""); + params.push(label.into()); + + if let Some(at) = address_type { + params.push(at.into()); + } + + Ok(self + .global_ep + .send(&self.client_id, "getnewaddress", params)?) + } + + /// Sends a specified amount of BTC to a given address. + /// + /// # Arguments + /// * `address` - The destination Bitcoin address. + /// * `amount` - Amount to send in BTC (not in satoshis). + /// + /// # Returns + /// The transaction ID as hex string + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. + pub fn send_to_address(&self, address: &str, amount: f64) -> BitcoinRpcClientResult { + Ok(self.wallet_ep.send( + &self.client_id, + "sendtoaddress", + vec![address.into(), amount.into()], + )?) + } + + /// Invalidate a block by its block hash, forcing the node to reconsider its chain state. + /// + /// # Arguments + /// * `hash` - The block hash (as a hex string) of the block to invalidate. + /// + /// # Returns + /// An empty `()` on success. + /// + /// # Availability + /// - **Since**: Bitcoin Core **v0.1.0**. + pub fn invalidate_block(&self, hash: &str) -> BitcoinRpcClientResult<()> { + self.global_ep + .send::(&self.client_id, "invalidateblock", vec![hash.into()])?; + Ok(()) + } +} From a0365c2ac17132f178cd37797bd8c89ec72886f2 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 28 Jul 2025 11:04:40 +0200 Subject: [PATCH 27/62] refactor: move tests module in its own file tests.rs, #6250 --- .../src/burnchains/rpc/bitcoin_rpc_client.rs | 1163 +--------------- .../rpc/bitcoin_rpc_client/tests.rs | 1172 +++++++++++++++++ 2 files changed, 1175 insertions(+), 1160 deletions(-) create mode 100644 stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs index 6f3f99f779..40b408f636 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs @@ -34,6 +34,9 @@ use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; #[cfg(test)] 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. @@ -505,1163 +508,3 @@ impl BitcoinRpcClient { .send(&self.client_id, "getblockhash", vec![height.into()])?) } } - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(test)] - mod unit { - - use serde_json::json; - - use super::*; - - mod utils { - use super::*; - - 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(), - parsed.scheme() == "https", - RpcAuth::None, - "mywallet".into(), - 30, - "stacks".to_string(), - ) - .expect("Rpc Client creation should be ok!") - } - } - - #[test] - fn test_get_blockchain_info_ok() { - 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": "00000" - }, - "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!("regtest", info.chain); - assert_eq!(1, info.blocks); - assert_eq!(2, info.headers); - assert_eq!("00000", info.best_block_hash); - } - - #[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_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "listunspent", - "params": [ - 1, - 10, - ["BTC_ADDRESS_1"], - true, - { - "minimumAmount": "0.00001000", - "maximumCount": 5 - } - ] - }); - - let mock_response = json!({ - "id": "stacks", - "result": [{ - "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - "vout": 0, - "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - "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 result = client - .list_unspent( - Some(1), - Some(10), - Some(&["BTC_ADDRESS_1"]), - Some(true), - Some("0.00001000"), // 1000 sats = 0.00001000 BTC - Some(5), - ) - .expect("Should parse unspent outputs"); - - assert_eq!(1, result.len()); - let utxo = &result[0]; - assert_eq!("0.00001", utxo.amount); - assert_eq!(0, utxo.vout); - assert_eq!(6, utxo.confirmations); - assert_eq!( - "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - utxo.txid, - ); - assert_eq!( - "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - utxo.script_pub_key, - ); - } - - #[test] - fn test_generate_to_address_ok() { - let num_blocks = 3; - let address = "00000000000000000000000000000000000000000000000000000"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "generatetoaddress", - "params": [num_blocks, address], - }); - - let mock_response = json!({ - "id": "stacks", - "result": [ - "block_hash1", - "block_hash2", - ], - "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 - .generate_to_address(num_blocks, address) - .expect("Should work!"); - assert_eq!(2, result.len()); - assert_eq!("block_hash1", result[0]); - assert_eq!("block_hash2", result[1]); - } - - #[test] - fn test_get_transaction_ok() { - let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "gettransaction", - "params": [txid] - }); - - 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 info = client.get_transaction(txid).expect("Should be ok!"); - assert_eq!(6, info.confirmations); - } - - #[test] - fn test_get_raw_transaction_ok() { - let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; - let expected_ser_tx = "000111222333444555666"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "getrawtransaction", - "params": [txid] - }); - - let mock_response = json!({ - "id": "stacks", - "result": expected_ser_tx, - "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 ser_tx = client.get_raw_transaction(txid).expect("Should be ok!"); - assert_eq!(expected_ser_tx, ser_tx); - } - - #[test] - fn test_generate_block_ok() { - let addr = "myaddr"; - let txid1 = "txid1"; - let txid2 = "txid2"; - let expected_block_hash = "block_hash"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "generateblock", - "params": [addr, [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 result = client - .generate_block(addr, &[txid1, txid2]) - .expect("Should be ok!"); - assert_eq!(expected_block_hash, result); - } - - #[test] - fn test_send_raw_transaction_ok_with_defaults() { - let raw_tx = "raw_tx_hex"; - let expected_txid = "txid1"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "sendrawtransaction", - "params": [raw_tx, 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 txid = client - .send_raw_transaction(raw_tx, None, None) - .expect("Should work!"); - assert_eq!(txid, expected_txid); - } - - #[test] - fn test_send_raw_transaction_ok_with_custom_params() { - let raw_tx = "raw_tx_hex"; - let expected_txid = "txid1"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "sendrawtransaction", - "params": [raw_tx, 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 txid = client - .send_raw_transaction(raw_tx, Some(0.0), Some(5_000)) - .expect("Should work!"); - assert_eq!(txid, expected_txid); - } - - #[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 = "btc_addr_1"; - - 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); - } - - #[test] - fn test_send_to_address_ok() { - let address = "btc_addr_1"; - let amount = 0.5; - let expected_txid = "txid_1"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "sendtoaddress", - "params": [address, amount] - }); - - let mock_response = json!({ - "id": "stacks", - "result": expected_txid, - "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 = client - .send_to_address(address, amount) - .expect("Should be ok!"); - assert_eq!(expected_txid, txid); - } - - #[test] - fn test_invalidate_block_ok() { - let hash = "0000"; - - 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); - - client.invalidate_block(hash).expect("Should be ok!"); - } - - #[test] - fn test_get_block_hash_ok() { - let height = 1; - let expected_hash = "0000"; - - 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 hash = client.get_block_hash(height).expect("Should be ok!"); - assert_eq!(expected_hash, hash); - } - } - - #[cfg(test)] - mod inte { - use std::env; - - use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; - - use super::*; - use crate::tests::bitcoin_regtest::BitcoinCoreController; - - mod utils { - use std::net::TcpListener; - - use stacks::config::Config; - - use super::*; - 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, - config.burnchain.rpc_ssl, - 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!"); - - match err { - BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { - assert!(msg.contains("401")); - } - _ => panic!("Expected RpcError::Service, 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!"); - - match err { - BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { - assert!(msg.contains("401")); - } - _ => panic!("Expected RpcError::Service, 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_eq!("Missing RPC credentials!", err); - } - - #[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!("regtest", info.chain); - assert_eq!(0, info.blocks); - assert_eq!(0, info.headers); - assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, info.best_block_hash); - } - - #[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!"); - - assert!(matches!( - err, - BitcoinRpcClientError::Rpc(RpcError::Service(_)) - )); - } - - #[ignore] - #[test] - fn test_generate_to_address_and_list_unspent_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, None).expect("Should work!"); - - let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) - .expect("list_unspent should be ok!"); - assert_eq!(0, utxos.len()); - - let blocks = client.generate_to_address(102, &address).expect("OK"); - assert_eq!(102, blocks.len()); - - let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) - .expect("list_unspent should be ok!"); - assert_eq!(2, utxos.len()); - - let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(1)) - .expect("list_unspent should be ok!"); - assert_eq!(1, 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("OK"); - let address = client.get_new_address(None, None).expect("Should work!"); - - let block_hash = client.generate_block(&address, &[]).expect("OK"); - assert_eq!(64, block_hash.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, None) - .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_ne!("", raw_tx); - } - - #[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, None) - .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("OK"); - let address = client.get_new_address(None, None).expect("Should work!"); - let block_hash = client.generate_block(&address, &[]).expect("OK"); - - client - .invalidate_block(&block_hash) - .expect("Invalidate valid hash should be ok!"); - client - .invalidate_block("invalid_hash") - .expect_err("Invalidate invalid 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 hash = client - .get_block_hash(0) - .expect("Should return regtest genesis block hash!"); - assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); - } - } -} 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..fd8b80c695 --- /dev/null +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -0,0 +1,1172 @@ +// 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 . + +mod tests { + use super::super::*; + mod unit { + + use serde_json::json; + + use super::*; + + mod utils { + + use super::*; + + 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(), + parsed.scheme() == "https", + RpcAuth::None, + "mywallet".into(), + 30, + "stacks".to_string(), + ) + .expect("Rpc Client creation should be ok!") + } + } + + #[test] + fn test_get_blockchain_info_ok() { + 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": "00000" + }, + "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!("regtest", info.chain); + assert_eq!(1, info.blocks); + assert_eq!(2, info.headers); + assert_eq!("00000", info.best_block_hash); + } + + #[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_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listunspent", + "params": [ + 1, + 10, + ["BTC_ADDRESS_1"], + true, + { + "minimumAmount": "0.00001000", + "maximumCount": 5 + } + ] + }); + + let mock_response = json!({ + "id": "stacks", + "result": [{ + "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "vout": 0, + "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + "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 result = client + .list_unspent( + Some(1), + Some(10), + Some(&["BTC_ADDRESS_1"]), + Some(true), + Some("0.00001000"), // 1000 sats = 0.00001000 BTC + Some(5), + ) + .expect("Should parse unspent outputs"); + + assert_eq!(1, result.len()); + let utxo = &result[0]; + assert_eq!("0.00001", utxo.amount); + assert_eq!(0, utxo.vout); + assert_eq!(6, utxo.confirmations); + assert_eq!( + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + utxo.txid, + ); + assert_eq!( + "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + utxo.script_pub_key, + ); + } + + #[test] + fn test_generate_to_address_ok() { + let num_blocks = 3; + let address = "00000000000000000000000000000000000000000000000000000"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generatetoaddress", + "params": [num_blocks, address], + }); + + let mock_response = json!({ + "id": "stacks", + "result": [ + "block_hash1", + "block_hash2", + ], + "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 + .generate_to_address(num_blocks, address) + .expect("Should work!"); + assert_eq!(2, result.len()); + assert_eq!("block_hash1", result[0]); + assert_eq!("block_hash2", result[1]); + } + + #[test] + fn test_get_transaction_ok() { + let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "gettransaction", + "params": [txid] + }); + + 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 info = client.get_transaction(txid).expect("Should be ok!"); + assert_eq!(6, info.confirmations); + } + + #[test] + fn test_get_raw_transaction_ok() { + let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + let expected_ser_tx = "000111222333444555666"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getrawtransaction", + "params": [txid] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_ser_tx, + "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 ser_tx = client.get_raw_transaction(txid).expect("Should be ok!"); + assert_eq!(expected_ser_tx, ser_tx); + } + + #[test] + fn test_generate_block_ok() { + let addr = "myaddr"; + let txid1 = "txid1"; + let txid2 = "txid2"; + let expected_block_hash = "block_hash"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generateblock", + "params": [addr, [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 result = client + .generate_block(addr, &[txid1, txid2]) + .expect("Should be ok!"); + assert_eq!(expected_block_hash, result); + } + + #[test] + fn test_send_raw_transaction_ok_with_defaults() { + let raw_tx = "raw_tx_hex"; + let expected_txid = "txid1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx, 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 txid = client + .send_raw_transaction(raw_tx, None, None) + .expect("Should work!"); + assert_eq!(txid, expected_txid); + } + + #[test] + fn test_send_raw_transaction_ok_with_custom_params() { + let raw_tx = "raw_tx_hex"; + let expected_txid = "txid1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx, 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 txid = client + .send_raw_transaction(raw_tx, Some(0.0), Some(5_000)) + .expect("Should work!"); + assert_eq!(txid, expected_txid); + } + + #[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 = "btc_addr_1"; + + 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); + } + + #[test] + fn test_send_to_address_ok() { + let address = "btc_addr_1"; + let amount = 0.5; + let expected_txid = "txid_1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendtoaddress", + "params": [address, amount] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_txid, + "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 = client + .send_to_address(address, amount) + .expect("Should be ok!"); + assert_eq!(expected_txid, txid); + } + + #[test] + fn test_invalidate_block_ok() { + let hash = "0000"; + + 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); + + client.invalidate_block(hash).expect("Should be ok!"); + } + + #[test] + fn test_get_block_hash_ok() { + let height = 1; + let expected_hash = "0000"; + + 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 hash = client.get_block_hash(height).expect("Should be ok!"); + assert_eq!(expected_hash, hash); + } + } + + mod inte { + use std::env; + + use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; + + use super::*; + use crate::tests::bitcoin_regtest::BitcoinCoreController; + + mod utils { + use std::net::TcpListener; + + use stacks::config::Config; + + use super::*; + 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, + config.burnchain.rpc_ssl, + 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!"); + + match err { + BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { + assert!(msg.contains("401")); + } + _ => panic!("Expected RpcError::Service, 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!"); + + match err { + BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { + assert!(msg.contains("401")); + } + _ => panic!("Expected RpcError::Service, 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_eq!("Missing RPC credentials!", err); + } + + #[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!("regtest", info.chain); + assert_eq!(0, info.blocks); + assert_eq!(0, info.headers); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, info.best_block_hash); + } + + #[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!"); + + assert!(matches!( + err, + BitcoinRpcClientError::Rpc(RpcError::Service(_)) + )); + } + + #[ignore] + #[test] + fn test_generate_to_address_and_list_unspent_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, None).expect("Should work!"); + + let utxos = client + .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) + .expect("list_unspent should be ok!"); + assert_eq!(0, utxos.len()); + + let blocks = client.generate_to_address(102, &address).expect("OK"); + assert_eq!(102, blocks.len()); + + let utxos = client + .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) + .expect("list_unspent should be ok!"); + assert_eq!(2, utxos.len()); + + let utxos = client + .list_unspent(None, None, None, Some(false), Some("1"), Some(1)) + .expect("list_unspent should be ok!"); + assert_eq!(1, 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("OK"); + let address = client.get_new_address(None, None).expect("Should work!"); + + let block_hash = client.generate_block(&address, &[]).expect("OK"); + assert_eq!(64, block_hash.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, None) + .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_ne!("", raw_tx); + } + + #[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, None) + .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("OK"); + let address = client.get_new_address(None, None).expect("Should work!"); + let block_hash = client.generate_block(&address, &[]).expect("OK"); + + client + .invalidate_block(&block_hash) + .expect("Invalidate valid hash should be ok!"); + client + .invalidate_block("invalid_hash") + .expect_err("Invalidate invalid 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 hash = client + .get_block_hash(0) + .expect("Should return regtest genesis block hash!"); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); + } + } +} From 70699f4d7765893a3b7a9bb1d2b67f205aca43e0 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 28 Jul 2025 12:58:10 +0200 Subject: [PATCH 28/62] refactor: split rpc unit and integration tests, #6250 --- .../src/burnchains/rpc/bitcoin_rpc_client.rs | 2 +- .../rpc/bitcoin_rpc_client/tests.rs | 1762 ++++++----------- .../src/tests/bitcoin_rpc_integrations.rs | 511 +++++ stacks-node/src/tests/mod.rs | 1 + 4 files changed, 1145 insertions(+), 1131 deletions(-) create mode 100644 stacks-node/src/tests/bitcoin_rpc_integrations.rs diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs index 40b408f636..587d77ff68 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs @@ -239,7 +239,7 @@ impl BitcoinRpcClient { /// /// Returns `Ok(Self)` if both global and wallet RPC transports are successfully created, /// or `Err(String)` if the underlying HTTP client setup fails.Stacks Configuration, mainly using `BurnchainConfig` - fn new( + pub fn new( host: String, port: u16, ssl: bool, diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index fd8b80c695..4943db545e 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -13,1160 +13,662 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -mod tests { - use super::super::*; - mod unit { +//! Unit Tests for [`BitcoinRpcClient`] - use serde_json::json; +use serde_json::json; - use super::*; +use super::*; - mod utils { +mod utils { - use super::*; + use super::*; - pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { - let url = server.url(); - let parsed = url::Url::parse(&url).unwrap(); + 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(), - parsed.scheme() == "https", - RpcAuth::None, - "mywallet".into(), - 30, - "stacks".to_string(), - ) - .expect("Rpc Client creation should be ok!") - } - } - - #[test] - fn test_get_blockchain_info_ok() { - 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": "00000" - }, - "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!("regtest", info.chain); - assert_eq!(1, info.blocks); - assert_eq!(2, info.headers); - assert_eq!("00000", info.best_block_hash); - } - - #[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_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "listunspent", - "params": [ - 1, - 10, - ["BTC_ADDRESS_1"], - true, - { - "minimumAmount": "0.00001000", - "maximumCount": 5 - } - ] - }); - - let mock_response = json!({ - "id": "stacks", - "result": [{ - "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - "vout": 0, - "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - "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 result = client - .list_unspent( - Some(1), - Some(10), - Some(&["BTC_ADDRESS_1"]), - Some(true), - Some("0.00001000"), // 1000 sats = 0.00001000 BTC - Some(5), - ) - .expect("Should parse unspent outputs"); - - assert_eq!(1, result.len()); - let utxo = &result[0]; - assert_eq!("0.00001", utxo.amount); - assert_eq!(0, utxo.vout); - assert_eq!(6, utxo.confirmations); - assert_eq!( - "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - utxo.txid, - ); - assert_eq!( - "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - utxo.script_pub_key, - ); - } - - #[test] - fn test_generate_to_address_ok() { - let num_blocks = 3; - let address = "00000000000000000000000000000000000000000000000000000"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "generatetoaddress", - "params": [num_blocks, address], - }); - - let mock_response = json!({ - "id": "stacks", - "result": [ - "block_hash1", - "block_hash2", - ], - "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 - .generate_to_address(num_blocks, address) - .expect("Should work!"); - assert_eq!(2, result.len()); - assert_eq!("block_hash1", result[0]); - assert_eq!("block_hash2", result[1]); - } - - #[test] - fn test_get_transaction_ok() { - let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "gettransaction", - "params": [txid] - }); - - 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 info = client.get_transaction(txid).expect("Should be ok!"); - assert_eq!(6, info.confirmations); - } - - #[test] - fn test_get_raw_transaction_ok() { - let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; - let expected_ser_tx = "000111222333444555666"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "getrawtransaction", - "params": [txid] - }); - - let mock_response = json!({ - "id": "stacks", - "result": expected_ser_tx, - "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 ser_tx = client.get_raw_transaction(txid).expect("Should be ok!"); - assert_eq!(expected_ser_tx, ser_tx); - } - - #[test] - fn test_generate_block_ok() { - let addr = "myaddr"; - let txid1 = "txid1"; - let txid2 = "txid2"; - let expected_block_hash = "block_hash"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "generateblock", - "params": [addr, [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 result = client - .generate_block(addr, &[txid1, txid2]) - .expect("Should be ok!"); - assert_eq!(expected_block_hash, result); - } - - #[test] - fn test_send_raw_transaction_ok_with_defaults() { - let raw_tx = "raw_tx_hex"; - let expected_txid = "txid1"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "sendrawtransaction", - "params": [raw_tx, 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 txid = client - .send_raw_transaction(raw_tx, None, None) - .expect("Should work!"); - assert_eq!(txid, expected_txid); - } - - #[test] - fn test_send_raw_transaction_ok_with_custom_params() { - let raw_tx = "raw_tx_hex"; - let expected_txid = "txid1"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "sendrawtransaction", - "params": [raw_tx, 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 txid = client - .send_raw_transaction(raw_tx, Some(0.0), Some(5_000)) - .expect("Should work!"); - assert_eq!(txid, expected_txid); - } - - #[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 = "btc_addr_1"; - - 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); - } - - #[test] - fn test_send_to_address_ok() { - let address = "btc_addr_1"; - let amount = 0.5; - let expected_txid = "txid_1"; - - let expected_request = json!({ - "jsonrpc": "2.0", - "id": "stacks", - "method": "sendtoaddress", - "params": [address, amount] - }); - - let mock_response = json!({ - "id": "stacks", - "result": expected_txid, - "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 = client - .send_to_address(address, amount) - .expect("Should be ok!"); - assert_eq!(expected_txid, txid); - } - - #[test] - fn test_invalidate_block_ok() { - let hash = "0000"; - - 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); - - client.invalidate_block(hash).expect("Should be ok!"); - } - - #[test] - fn test_get_block_hash_ok() { - let height = 1; - let expected_hash = "0000"; - - 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 hash = client.get_block_hash(height).expect("Should be ok!"); - assert_eq!(expected_hash, hash); - } + BitcoinRpcClient::new( + parsed.host_str().unwrap().to_string(), + parsed.port_or_known_default().unwrap(), + parsed.scheme() == "https", + RpcAuth::None, + "mywallet".into(), + 30, + "stacks".to_string(), + ) + .expect("Rpc Client creation should be ok!") } +} - mod inte { - use std::env; - - use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; - - use super::*; - use crate::tests::bitcoin_regtest::BitcoinCoreController; - - mod utils { - use std::net::TcpListener; - - use stacks::config::Config; - - use super::*; - 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; +#[test] +fn test_get_blockchain_info_ok() { + 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": "00000" + }, + "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!("regtest", info.chain); + assert_eq!(1, info.blocks); + assert_eq!(2, info.headers); + assert_eq!("00000", info.best_block_hash); +} - config - } +#[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!"); +} - pub fn create_client_no_auth_from_stx_config(config: Config) -> BitcoinRpcClient { - BitcoinRpcClient::new( - config.burnchain.peer_host, - config.burnchain.rpc_port, - config.burnchain.rpc_ssl, - RpcAuth::None, - config.burnchain.wallet_name, - config.burnchain.timeout, - "stacks".to_string(), - ) - .expect("Rpc client creation 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]); +} - #[ignore] - #[test] - fn test_rpc_call_fails_when_bitcond_with_auth_but_rpc_no_auth() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; +#[test] +fn test_list_unspent_ok() { + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "listunspent", + "params": [ + 1, + 10, + ["BTC_ADDRESS_1"], + true, + { + "minimumAmount": "0.00001000", + "maximumCount": 5 } + ] + }); + + let mock_response = json!({ + "id": "stacks", + "result": [{ + "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "vout": 0, + "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + "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 result = client + .list_unspent( + Some(1), + Some(10), + Some(&["BTC_ADDRESS_1"]), + Some(true), + Some("0.00001000"), // 1000 sats = 0.00001000 BTC + Some(5), + ) + .expect("Should parse unspent outputs"); + + assert_eq!(1, result.len()); + let utxo = &result[0]; + assert_eq!("0.00001", utxo.amount); + assert_eq!(0, utxo.vout); + assert_eq!(6, utxo.confirmations); + assert_eq!( + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + utxo.txid, + ); + assert_eq!( + "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + utxo.script_pub_key, + ); +} - 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!"); +#[test] +fn test_generate_to_address_ok() { + let num_blocks = 3; + let address = "00000000000000000000000000000000000000000000000000000"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generatetoaddress", + "params": [num_blocks, address], + }); + + let mock_response = json!({ + "id": "stacks", + "result": [ + "block_hash1", + "block_hash2", + ], + "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 + .generate_to_address(num_blocks, address) + .expect("Should work!"); + assert_eq!(2, result.len()); + assert_eq!("block_hash1", result[0]); + assert_eq!("block_hash2", result[1]); +} - match err { - BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { - assert!(msg.contains("401")); - } - _ => panic!("Expected RpcError::Service, got: {:?}", err), - } - } +#[test] +fn test_get_transaction_ok() { + let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "gettransaction", + "params": [txid] + }); + + 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 info = client.get_transaction(txid).expect("Should be ok!"); + assert_eq!(6, info.confirmations); +} - #[ignore] - #[test] - fn test_rpc_call_fails_when_bitcond_no_auth_and_rpc_no_auth() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } +#[test] +fn test_get_raw_transaction_ok() { + let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + let expected_ser_tx = "000111222333444555666"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "getrawtransaction", + "params": [txid] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_ser_tx, + "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 ser_tx = client.get_raw_transaction(txid).expect("Should be ok!"); + assert_eq!(expected_ser_tx, ser_tx); +} - let mut config_no_auth = utils::create_stx_config(); - config_no_auth.burnchain.username = None; - config_no_auth.burnchain.password = None; +#[test] +fn test_generate_block_ok() { + let addr = "myaddr"; + let txid1 = "txid1"; + let txid2 = "txid2"; + let expected_block_hash = "block_hash"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "generateblock", + "params": [addr, [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 result = client + .generate_block(addr, &[txid1, txid2]) + .expect("Should be ok!"); + assert_eq!(expected_block_hash, result); +} - let mut btcd_controller = BitcoinCoreController::new(config_no_auth.clone()); - btcd_controller - .start_bitcoind() - .expect("bitcoind should be started!"); +#[test] +fn test_send_raw_transaction_ok_with_defaults() { + let raw_tx = "raw_tx_hex"; + let expected_txid = "txid1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx, 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 txid = client + .send_raw_transaction(raw_tx, None, None) + .expect("Should work!"); + assert_eq!(txid, expected_txid); +} - let client = utils::create_client_no_auth_from_stx_config(config_no_auth); +#[test] +fn test_send_raw_transaction_ok_with_custom_params() { + let raw_tx = "raw_tx_hex"; + let expected_txid = "txid1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendrawtransaction", + "params": [raw_tx, 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 txid = client + .send_raw_transaction(raw_tx, Some(0.0), Some(5_000)) + .expect("Should work!"); + assert_eq!(txid, expected_txid); +} - let err = client.get_blockchain_info().expect_err("Should fail!"); +#[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); +} - match err { - BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { - assert!(msg.contains("401")); +#[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 } - _ => panic!("Expected RpcError::Service, 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_eq!("Missing RPC credentials!", err); - } - - #[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!("regtest", info.chain); - assert_eq!(0, info.blocks); - assert_eq!(0, info.headers); - assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, info.best_block_hash); - } - - #[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!"); - - assert!(matches!( - err, - BitcoinRpcClientError::Rpc(RpcError::Service(_)) - )); - } - - #[ignore] - #[test] - fn test_generate_to_address_and_list_unspent_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, None).expect("Should work!"); - - let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) - .expect("list_unspent should be ok!"); - assert_eq!(0, utxos.len()); - - let blocks = client.generate_to_address(102, &address).expect("OK"); - assert_eq!(102, blocks.len()); - - let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) - .expect("list_unspent should be ok!"); - assert_eq!(2, utxos.len()); - - let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(1)) - .expect("list_unspent should be ok!"); - assert_eq!(1, 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("OK"); - let address = client.get_new_address(None, None).expect("Should work!"); - - let block_hash = client.generate_block(&address, &[]).expect("OK"); - assert_eq!(64, block_hash.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, None) - .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_ne!("", raw_tx); - } - - #[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, None) - .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 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()); +} - 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, None).expect("Should work!"); - let block_hash = client.generate_block(&address, &[]).expect("OK"); - - client - .invalidate_block(&block_hash) - .expect("Invalidate valid hash should be ok!"); - client - .invalidate_block("invalid_hash") - .expect_err("Invalidate invalid hash should fail!"); - } - - #[ignore] - #[test] - fn test_get_block_hash_ok() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } +#[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); +} - let mut config = utils::create_stx_config(); - config.burnchain.wallet_name = "my_wallet".to_string(); +#[test] +fn test_get_new_address_ok() { + let expected_address = "btc_addr_1"; + + 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); +} - let mut btcd_controller = BitcoinCoreController::new(config.clone()); - btcd_controller - .start_bitcoind() - .expect("bitcoind should be started!"); +#[test] +fn test_send_to_address_ok() { + let address = "btc_addr_1"; + let amount = 0.5; + let expected_txid = "txid_1"; + + let expected_request = json!({ + "jsonrpc": "2.0", + "id": "stacks", + "method": "sendtoaddress", + "params": [address, amount] + }); + + let mock_response = json!({ + "id": "stacks", + "result": expected_txid, + "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 = client + .send_to_address(address, amount) + .expect("Should be ok!"); + assert_eq!(expected_txid, txid); +} - let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); +#[test] +fn test_invalidate_block_ok() { + let hash = "0000"; + + 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); + + client.invalidate_block(hash).expect("Should be ok!"); +} - let hash = client - .get_block_hash(0) - .expect("Should return regtest genesis block hash!"); - assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); - } - } +#[test] +fn test_get_block_hash_ok() { + let height = 1; + let expected_hash = "0000"; + + 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 hash = client.get_block_hash(height).expect("Should be ok!"); + assert_eq!(expected_hash, hash); } 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..0f905557ed --- /dev/null +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -0,0 +1,511 @@ +// 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::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; + +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, + config.burnchain.rpc_ssl, + 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!"); + + match err { + BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { + assert!(msg.contains("401")); + } + _ => panic!("Expected RpcError::Service, 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!"); + + match err { + BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { + assert!(msg.contains("401")); + } + _ => panic!("Expected RpcError::Service, 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_eq!("Missing RPC credentials!", err); +} + +#[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!("regtest", info.chain); + assert_eq!(0, info.blocks); + assert_eq!(0, info.headers); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, info.best_block_hash); +} + +#[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!"); + + assert!(matches!( + err, + BitcoinRpcClientError::Rpc(RpcError::Service(_)) + )); +} + +#[ignore] +#[test] +fn test_generate_to_address_and_list_unspent_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, None).expect("Should work!"); + + let utxos = client + .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) + .expect("list_unspent should be ok!"); + assert_eq!(0, utxos.len()); + + let blocks = client.generate_to_address(102, &address).expect("OK"); + assert_eq!(102, blocks.len()); + + let utxos = client + .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) + .expect("list_unspent should be ok!"); + assert_eq!(2, utxos.len()); + + let utxos = client + .list_unspent(None, None, None, Some(false), Some("1"), Some(1)) + .expect("list_unspent should be ok!"); + assert_eq!(1, 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("OK"); + let address = client.get_new_address(None, None).expect("Should work!"); + + let block_hash = client.generate_block(&address, &[]).expect("OK"); + assert_eq!(64, block_hash.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, None) + .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_ne!("", raw_tx); +} + +#[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, None) + .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("OK"); + let address = client.get_new_address(None, None).expect("Should work!"); + let block_hash = client.generate_block(&address, &[]).expect("OK"); + + client + .invalidate_block(&block_hash) + .expect("Invalidate valid hash should be ok!"); + client + .invalidate_block("invalid_hash") + .expect_err("Invalidate invalid 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 hash = client + .get_block_hash(0) + .expect("Should return regtest genesis block hash!"); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); +} 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; From cb8473588dce129276c052bcab501f6fe024b888 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 28 Jul 2025 16:40:29 +0200 Subject: [PATCH 29/62] refactor: remove reqwest and use stacks http module, #6250 --- stacks-node/Cargo.toml | 2 +- .../src/burnchains/rpc/rpc_transport.rs | 416 ++++-------------- .../src/burnchains/rpc/rpc_transport/tests.rs | 283 ++++++++++++ .../src/tests/bitcoin_rpc_integrations.rs | 20 +- stacks-signer/Cargo.toml | 2 +- 5 files changed, 384 insertions(+), 339 deletions(-) create mode 100644 stacks-node/src/burnchains/rpc/rpc_transport/tests.rs diff --git a/stacks-node/Cargo.toml b/stacks-node/Cargo.toml index eaae84765a..61766e5053 100644 --- a/stacks-node/Cargo.toml +++ b/stacks-node/Cargo.toml @@ -31,7 +31,6 @@ async-h1 = { version = "2.3.2", optional = true } async-std = { version = "1.6", optional = true, features = ["attributes"] } http-types = { version = "2.12", default-features = false, optional = true } thiserror = { workspace = true } -reqwest = { version = "0.11.24", default-features = false, features = ["blocking", "json", "rustls-tls"] } # This dependency is used for the multiversion integration tests which live behind the build-v3-1-0-0-13 feature flag signer_v3_1_0_0_13 = { package = "stacks-signer", git = "https://github.com/stacks-network/stacks-core.git", rev="8a79aaa7df0f13dfc5ab0d0d0bcb8201c90bcba2", optional = true, features = ["testing", "default"]} @@ -44,6 +43,7 @@ tikv-jemallocator = {workspace = true} [dev-dependencies] warp = "0.3.5" tokio = "1.15" +reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls", "rustls-tls"] } clarity = { path = "../clarity", features = ["default", "testing"]} stacks-common = { path = "../stacks-common", features = ["default", "testing"] } stacks = { package = "stackslib", path = "../stackslib", features = ["default", "testing"] } diff --git a/stacks-node/src/burnchains/rpc/rpc_transport.rs b/stacks-node/src/burnchains/rpc/rpc_transport.rs index 1b58225cc2..bf08098715 100644 --- a/stacks-node/src/burnchains/rpc/rpc_transport.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport.rs @@ -13,20 +13,25 @@ // 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 `reqwest` for HTTP communication. +//! 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 reqwest::blocking::Client as ReqwestClient; -use reqwest::header::AUTHORIZATION; -use reqwest::Error as ReqwestError; 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` @@ -61,8 +66,10 @@ struct JsonRpcResponse { pub enum RpcError { /// Represents a network-level error, such as connection failures or timeouts. Network(String), - /// Indicates that the response could not be parsed or was malformed. - Parsing(String), + /// Indicates that the request could not be encoded properly + Encode(String), + /// Indicates that the response could not be decoded properly. + Decode(String), /// Represents an error returned by the RPC service itself. Service(String), } @@ -70,6 +77,24 @@ pub enum RpcError { /// Alias for results returned from RPC operations using `RpcTransport`. pub type RpcResult = Result; +impl From for RpcError { + fn from(e: url::ParseError) -> Self { + Self::Network(format!("Url Error: {e:?}")) + } +} + +impl From for RpcError { + fn from(e: stacks_common::types::net::Error) -> Self { + Self::Network(format!("Net Error: {e:?}")) + } +} + +impl From for RpcError { + fn from(e: io::Error) -> Self { + Self::Network(format!("IO Error: {e:?}")) + } +} + /// Represents supported authentication mechanisms for RPC requests. #[derive(Debug, Clone)] pub enum RpcAuth { @@ -85,12 +110,14 @@ pub enum RpcAuth { /// and an internal HTTP client. #[derive(Debug)] pub struct RpcTransport { - /// The base URL of the JSON-RPC endpoint. - url: String, + /// 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 reqwest http client - client: ReqwestClient, + /// The maximum duration to wait for an HTTP request to complete. + timeout: Duration, } impl RpcTransport { @@ -100,18 +127,29 @@ impl RpcTransport { /// /// * `url` - The JSON-RPC server endpoint. /// * `auth` - Authentication configuration (`None` or `Basic`). - /// * `timeout` - Optional request timeout duration. (`None` to disable timeout) + /// * `timeout` - Optional timeout duration for HTTP requests. If `None`, defaults to 30 seconds. /// - /// # Errors + /// # Returns /// - /// Returns `RpcError::Network` if the HTTP client could not be built. + /// An instance of [`RpcTransport`] on success, or a [`RpcError`] otherwise. pub fn new(url: String, auth: RpcAuth, timeout: Option) -> RpcResult { - let client = ReqwestClient::builder() - .timeout(timeout) - .build() - .map_err(|e| RpcError::Network(format!("Failed to build HTTP client: {}", e)))?; - - Ok(RpcTransport { url, auth, client }) + let url_obj = Url::parse(&url)?; + let host = url_obj + .host_str() + .ok_or(RpcError::Network(format!("Missing host in url: {url}")))?; + let port = url_obj + .port_or_known_default() + .ok_or(RpcError::Network(format!("Missing port in url: {url}")))?; + + 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. @@ -122,56 +160,67 @@ impl RpcTransport { /// * `method` - The name of the JSON-RPC method to invoke. /// * `params` - A list of parameters to pass to the method. /// - /// # Errors + /// # Returns /// - /// Returns: - /// * `RpcError::Network` on network issues, - /// * `RpcError::Parsing` for malformed or invalid responses, - /// * `RpcError::Service` if the RPC server returns an error. + /// 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 request = JsonRpcRequest { + let payload = JsonRpcRequest { jsonrpc: RCP_VERSION.to_string(), id: id.to_string(), method: method.to_string(), params: Value::Array(params), }; - let mut request_builder = self.client.post(&self.url).json(&request); + let json_payload = serde_json::to_value(payload) + .map_err(|e| RpcError::Encode(format!("Failed to encode request as JSON: {e:?}")))?; + + let mut request = StacksHttpRequest::new_for_peer( + self.peer.clone(), + "POST".to_string(), + self.path.clone(), + HttpRequestContents::new().payload_json(json_payload), + ) + .map_err(|e| { + RpcError::Encode(format!( + "Failed to encode infallible data as HTTP request {e:?}" + )) + })?; + request.add_header("Connection".into(), "close".into()); if let Some(auth_header) = self.auth_header() { - request_builder = request_builder.header(AUTHORIZATION, auth_header); + request.add_header("Authorization".to_string(), auth_header); } - let response = request_builder - .send() - .map_err(|err| RpcError::Network(err.to_string()))?; + let host = request.preamble().host.hostname(); + let port = request.preamble().host.port(); - if !response.status().is_success() { - let status = response.status(); - return Err(RpcError::Service( - format!("HTTP error {}", status.as_u16(),), - )); - } + let response = send_http_request(&host, port, request, self.timeout)?; + let json_response = match response.destruct().1 { + HttpResponsePayload::JSON(js) => Ok(js), + _ => Err(RpcError::Decode("Did not get a JSON response".to_string())), + }?; - let parsed: JsonRpcResponse = response.json().map_err(Self::classify_parse_error)?; + let parsed_response: JsonRpcResponse = serde_json::from_value(json_response) + .map_err(|e| RpcError::Decode(format!("Json Parse Error: {e:?}")))?; - if id != parsed.id { - return Err(RpcError::Parsing(format!( + if id != parsed_response.id { + return Err(RpcError::Decode(format!( "Invalid response: mismatched 'id': expected '{}', got '{}'", - id, parsed.id + id, parsed_response.id ))); } - if let Some(error) = parsed.error { + if let Some(error) = parsed_response.error { return Err(RpcError::Service(format!("{:#}", error))); } - if let Some(result) = parsed.result { + if let Some(result) = parsed_response.result { Ok(result) } else { Ok(serde_json::from_value(Value::Null).unwrap()) @@ -188,287 +237,4 @@ impl RpcTransport { } } } - - /// Classify possible error coming from Json parsing - fn classify_parse_error(e: ReqwestError) -> RpcError { - if e.is_timeout() { - RpcError::Network("Request timed out".to_string()) - } else if e.is_decode() { - RpcError::Parsing(format!("Failed to parse RPC response: {e}")) - } else { - RpcError::Network(format!("Network error: {e}")) - } - } -} - -#[cfg(test)] -mod tests { - 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_with_network_error() { - let transport = - RpcTransport::new("http://127.0.0.1:65535".to_string(), RpcAuth::None, None) - .expect("Should be created properly!"); - - let result: RpcResult = transport.send("client_id", "dummy_method", vec![]); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), RpcError::Network(_))); - } - - #[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::Service(msg)) => { - assert!(msg.contains("500")) - } - _ => panic!("Expected error 500"), - } - } - - #[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::Parsing(msg)) => { - assert!(msg.starts_with("Failed to parse RPC response:")) - } - _ => panic!("Expected parse error"), - } - } - - #[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": "wrong_client_id", - "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("client_id", "dummy", vec![]); - - match result { - Err(RpcError::Parsing(msg)) => assert_eq!( - "Invalid response: mismatched 'id': expected 'client_id', got 'wrong_client_id'", - msg - ), - _ => panic!("Expected missing result/error error"), - } - } - - #[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(msg)) => assert_eq!( - "{\n \"code\": -32601,\n \"message\": \"Method not found\"\n}", - msg - ), - _ => panic!("Expected service error"), - } - } - - #[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::Network(msg) => { - assert_eq!("Request timed out", msg); - } - err => panic!("Expected network error, got: {:?}", err), - } - } } 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..4590cc988b --- /dev/null +++ b/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs @@ -0,0 +1,283 @@ +// 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_with_network_error() { + let transport = RpcTransport::new("http://127.0.0.1:65535".to_string(), RpcAuth::None, None) + .expect("Should be created properly!"); + + let result: RpcResult = transport.send("client_id", "dummy_method", vec![]); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RpcError::Network(_))); +} + +#[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::Network(msg)) => { + assert!(msg.contains("500")) + } + _ => panic!("Expected error 500"), + } +} + +#[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::Network(msg)) => { + assert!(msg.contains("invalid message")) + } + _ => panic!("Expected network error"), + } +} + +#[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": "wrong_client_id", + "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("client_id", "dummy", vec![]); + + match result { + Err(RpcError::Decode(msg)) => assert_eq!( + "Invalid response: mismatched 'id': expected 'client_id', got 'wrong_client_id'", + msg + ), + _ => panic!("Expected missing result/error error"), + } +} + +#[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(msg)) => assert_eq!( + "{\n \"code\": -32601,\n \"message\": \"Method not found\"\n}", + msg + ), + _ => panic!("Expected service error"), + } +} + +#[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::Network(msg) => { + assert!(msg.contains("Timed out")); + } + err => panic!("Expected network error, got: {:?}", err), + } +} diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 0f905557ed..a7ba862927 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -92,12 +92,10 @@ fn test_rpc_call_fails_when_bitcond_with_auth_but_rpc_no_auth() { let err = client.get_blockchain_info().expect_err("Should fail!"); - match err { - BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { - assert!(msg.contains("401")); - } - _ => panic!("Expected RpcError::Service, got: {:?}", err), - } + assert!( + matches!(err, BitcoinRpcClientError::Rpc(RpcError::Network(_))), + "Expected RpcError::Network, got: {err:?}" + ); } #[ignore] @@ -120,12 +118,10 @@ fn test_rpc_call_fails_when_bitcond_no_auth_and_rpc_no_auth() { let err = client.get_blockchain_info().expect_err("Should fail!"); - match err { - BitcoinRpcClientError::Rpc(RpcError::Service(ref msg)) => { - assert!(msg.contains("401")); - } - _ => panic!("Expected RpcError::Service, got: {:?}", err), - } + assert!( + matches!(err, BitcoinRpcClientError::Rpc(RpcError::Network(_))), + "Expected RpcError::Network, got: {err:?}" + ); } #[ignore] diff --git a/stacks-signer/Cargo.toml b/stacks-signer/Cargo.toml index 31034c71fa..347be5731f 100644 --- a/stacks-signer/Cargo.toml +++ b/stacks-signer/Cargo.toml @@ -29,7 +29,7 @@ libsigner = { path = "../libsigner" } libstackerdb = { path = "../libstackerdb" } prometheus = { version = "0.9", optional = true } rand_core = "0.6" -reqwest = { version = "0.11.24", default-features = false, features = ["blocking", "json", "rustls-tls"] } +reqwest = { version = "0.11.22", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = "1" slog = { version = "2.5.2", features = [ "max_level_trace" ] } slog-json = { version = "2.3.0", optional = true } From 50f0d693641fe446c19f4eed5e7c71bbc76eacc0 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 28 Jul 2025 17:36:50 +0200 Subject: [PATCH 30/62] test: improve error message, #6250 --- stacks-node/src/tests/bitcoin_rpc_integrations.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index a7ba862927..30723f2035 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -224,10 +224,10 @@ fn test_wallet_creation_fails_if_already_exists() { .create_wallet("mywallet1", Some(false)) .expect_err("mywallet1 creation should fail now!"); - assert!(matches!( - err, - BitcoinRpcClientError::Rpc(RpcError::Service(_)) - )); + assert!( + matches!(err, BitcoinRpcClientError::Rpc(RpcError::Service(_))), + "Expected Service error, got {err:?}" + ); } #[ignore] From c92c8591086186ac0e77689701db9f771b00a12c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 29 Jul 2025 11:00:33 +0200 Subject: [PATCH 31/62] test: improve duplicate wallet creation, #6250 --- stacks-node/src/tests/bitcoin_rpc_integrations.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 30723f2035..7b3b7bf334 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -224,10 +224,15 @@ fn test_wallet_creation_fails_if_already_exists() { .create_wallet("mywallet1", Some(false)) .expect_err("mywallet1 creation should fail now!"); - assert!( - matches!(err, BitcoinRpcClientError::Rpc(RpcError::Service(_))), - "Expected Service error, got {err:?}" - ); + match &err { + BitcoinRpcClientError::Rpc(RpcError::Network(msg)) => { + assert!(msg.contains("500"), "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] From 795d41272f54607b4cafd2aac85b39f8c17ab4c3 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 30 Jul 2025 10:53:36 +0200 Subject: [PATCH 32/62] crc: reorganize file structure, #6250 --- .../rpc/{bitcoin_rpc_client.rs => bitcoin_rpc_client/mod.rs} | 0 .../src/burnchains/rpc/{rpc_transport.rs => rpc_transport/mod.rs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename stacks-node/src/burnchains/rpc/{bitcoin_rpc_client.rs => bitcoin_rpc_client/mod.rs} (100%) rename stacks-node/src/burnchains/rpc/{rpc_transport.rs => rpc_transport/mod.rs} (100%) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs similarity index 100% rename from stacks-node/src/burnchains/rpc/bitcoin_rpc_client.rs rename to stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs diff --git a/stacks-node/src/burnchains/rpc/rpc_transport.rs b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs similarity index 100% rename from stacks-node/src/burnchains/rpc/rpc_transport.rs rename to stacks-node/src/burnchains/rpc/rpc_transport/mod.rs From ecb8f925aec84cddb4914bafd3d02ff2b5c02ed7 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 5 Aug 2025 12:20:15 +0200 Subject: [PATCH 33/62] crc: update get_raw_transaction api, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 18 +++++++++++++++ .../rpc/bitcoin_rpc_client/test_utils.rs | 23 +++++++++++++------ .../rpc/bitcoin_rpc_client/tests.rs | 16 ++++++++----- .../src/tests/bitcoin_rpc_integrations.rs | 6 ++++- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 587d77ff68..8ac6ad739f 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -183,6 +183,10 @@ pub enum BitcoinRpcClientError { Rpc(RpcError), // JSON serialization errors Serialization(serde_json::Error), + // Bitcoin serialization errors + BitcoinSerialization(stacks_common::deps_common::bitcoin::network::serialize::Error), + // Hex conversion errors + Hex(stacks_common::util::HexError), } impl From for BitcoinRpcClientError { @@ -197,6 +201,20 @@ impl From for BitcoinRpcClientError { } } +impl From + for BitcoinRpcClientError +{ + fn from(err: stacks_common::deps_common::bitcoin::network::serialize::Error) -> Self { + BitcoinRpcClientError::BitcoinSerialization(err) + } +} + +impl From for BitcoinRpcClientError { + fn from(err: stacks_common::util::HexError) -> Self { + BitcoinRpcClientError::Hex(err) + } +} + /// Alias for results returned from client operations. pub type BitcoinRpcClientResult = Result; 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 index 3e5e8df724..ec1fbfb90c 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -16,6 +16,10 @@ //! Test-only utilities for [`BitcoinRpcClient`] use serde_json::Value; +use stacks::burnchains::Txid; +use stacks::util::hash::hex_bytes; +use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; +use stacks_common::deps_common::bitcoin::network::serialize::deserialize as btc_deserialize; use crate::burnchains::rpc::bitcoin_rpc_client::{BitcoinRpcClient, BitcoinRpcClientResult}; @@ -58,20 +62,25 @@ impl BitcoinRpcClient { .send(&self.client_id, "getblockchaininfo", vec![])?) } - /// Retrieves the raw hex-encoded transaction by its ID. + /// Retrieves and deserializes a raw Bitcoin transaction by its ID. /// /// # Arguments - /// * `txid` - Transaction ID (hash) to fetch. + /// * `txid` - Transaction ID to fetch. /// /// # Returns - /// A raw transaction as a hex-encoded string. + /// A [`Transaction`] struct representing the decoded transaction. /// /// # Availability /// - **Since**: Bitcoin Core **v0.7.0**. - pub fn get_raw_transaction(&self, txid: &str) -> BitcoinRpcClientResult { - Ok(self - .global_ep - .send(&self.client_id, "getrawtransaction", vec![txid.into()])?) + pub fn get_raw_transaction(&self, txid: &Txid) -> BitcoinRpcClientResult { + let raw_hex = self.global_ep.send::( + &self.client_id, + "getrawtransaction", + vec![txid.to_string().into()], + )?; + let raw_bytes = hex_bytes(&raw_hex)?; + let tx = btc_deserialize(&raw_bytes)?; + Ok(tx) } /// Mines a new block including the given transactions to a specified address. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 4943db545e..e01b5974f2 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -16,6 +16,8 @@ //! Unit Tests for [`BitcoinRpcClient`] use serde_json::json; +use stacks::burnchains::Txid; +use stacks_common::deps_common::bitcoin::network::serialize::serialize_hex; use super::*; @@ -288,19 +290,19 @@ fn test_get_transaction_ok() { #[test] fn test_get_raw_transaction_ok() { - let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; - let expected_ser_tx = "000111222333444555666"; + let txid_hex = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + let expected_tx_hex = "0100000001b1f2f67426d26301f0b20467e9fdd93557cb3cbbcb8d79f3a9c7b6c8ec7f69e8000000006a47304402206369d5eb2b7c99f540f4cf3ff2fd6f4b90f89c4328bfa0b6db0c30bb7f2c3d4c022015a1c0e5f6a0b08c271b2d218e6a7a29f5441dbe39d9a5cbcc223221ad5dbb59012103a34e84c8c7ebc8ecb7c2e59ff6672f392c792fc1c4f3c6fa2e7d3d314f1f38c9ffffffff0200e1f505000000001976a9144621d7f4ce0c956c80e6f0c1b9f78fe0c49cb82088ac80fae9c7000000001976a91488ac1f0f01c2a5c2e8f4b4f1a3b1a04d2f35b4c488ac00000000"; let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", "method": "getrawtransaction", - "params": [txid] + "params": [txid_hex] }); let mock_response = json!({ "id": "stacks", - "result": expected_ser_tx, + "result": expected_tx_hex, "error": null, }); @@ -315,8 +317,10 @@ fn test_get_raw_transaction_ok() { let client = utils::setup_client(&server); - let ser_tx = client.get_raw_transaction(txid).expect("Should be ok!"); - assert_eq!(expected_ser_tx, ser_tx); + let txid = Txid::from_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] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 7b3b7bf334..a03e4159e5 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -17,6 +17,7 @@ use std::env; +use stacks::burnchains::Txid; use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; use crate::burnchains::rpc::bitcoin_rpc_client::{ @@ -331,10 +332,13 @@ fn test_get_raw_transaction_ok() { .send_to_address(&address, 2.0) .expect("send to address ok!"); + let txid = Txid::from_hex(&txid).unwrap(); + let raw_tx = client .get_raw_transaction(&txid) .expect("get raw transaction ok!"); - assert_ne!("", raw_tx); + + assert_eq!(txid.to_string(), raw_tx.txid().to_string()); } #[ignore] From 129b6a4d7b20875935a948a69e00cb0b1192f2ba Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 5 Aug 2025 14:15:05 +0200 Subject: [PATCH 34/62] crc: update generate_block api, #6250 --- .../rpc/bitcoin_rpc_client/test_utils.rs | 29 ++++++++-- .../rpc/bitcoin_rpc_client/tests.rs | 56 +++++++++++++++++-- .../src/tests/bitcoin_rpc_integrations.rs | 32 ++++++++--- 3 files changed, 99 insertions(+), 18 deletions(-) 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 index ec1fbfb90c..7329060fa2 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -15,8 +15,11 @@ //! Test-only utilities for [`BitcoinRpcClient`] +use serde::{Deserialize, Deserializer}; use serde_json::Value; +use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; +use stacks::types::chainstate::BurnchainHeaderHash; use stacks::util::hash::hex_bytes; use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; use stacks_common::deps_common::bitcoin::network::serialize::deserialize as btc_deserialize; @@ -45,7 +48,19 @@ pub struct GetBlockChainInfoResponse { #[derive(Debug, Clone, Deserialize)] struct GenerateBlockResponse { /// The hash of the generated block - hash: String, + #[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) } impl BitcoinRpcClient { @@ -86,23 +101,27 @@ impl BitcoinRpcClient { /// Mines a new block including the given transactions to a specified address. /// /// # Arguments - /// * `address` - Address to which the block subsidy will be paid. + /// * `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 - /// The block hash of the newly generated block. + /// 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: &str, txs: &[&str]) -> BitcoinRpcClientResult { + pub fn generate_block( + &self, + address: &BitcoinAddress, + txs: &[&str], + ) -> BitcoinRpcClientResult { let response = self.global_ep.send::( &self.client_id, "generateblock", - vec![address.into(), txs.into()], + vec![address.to_string().into(), txs.into()], )?; Ok(response.hash) } diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index e01b5974f2..01540e7397 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -16,7 +16,9 @@ //! Unit Tests for [`BitcoinRpcClient`] use serde_json::json; +use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; +use stacks::types::Address; use stacks_common::deps_common::bitcoin::network::serialize::serialize_hex; use super::*; @@ -325,16 +327,16 @@ fn test_get_raw_transaction_ok() { #[test] fn test_generate_block_ok() { - let addr = "myaddr"; + let legacy_addr_str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; let txid1 = "txid1"; let txid2 = "txid2"; - let expected_block_hash = "block_hash"; + let expected_block_hash = "0000000000000000011f5b3c4e7e9f4dc2c88f0b6c3a3b17e5a7d0dfeb3bb3cd"; let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", "method": "generateblock", - "params": [addr, [txid1, txid2]] + "params": [legacy_addr_str, [txid1, txid2]] }); let mock_response = json!({ @@ -356,10 +358,54 @@ fn test_generate_block_ok() { 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]) + .generate_block(&addr, &[txid1, txid2]) .expect("Should be ok!"); - assert_eq!(expected_block_hash, result); + assert_eq!(expected_block_hash, result.to_hex()); +} + +#[test] +fn test_generate_block_fails_for_invalid_block_hash() { + let legacy_addr_str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; + 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::Decode(_)) + )); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index a03e4159e5..d5c83315f3 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -17,8 +17,10 @@ use std::env; +use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; +use stacks::types::Address; use crate::burnchains::rpc::bitcoin_rpc_client::{ BitcoinRpcClient, BitcoinRpcClientError, ImportDescriptorsRequest, Timestamp, @@ -290,11 +292,18 @@ fn test_generate_block_ok() { .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, None).expect("Should work!"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, Some("legacy")) + .expect("get new address ok!"); - let block_hash = client.generate_block(&address, &[]).expect("OK"); - assert_eq!(64, block_hash.len()); + let address = BitcoinAddress::from_string(&address).expect("valid address!"); + let block_hash = client + .generate_block(&address, &[]) + .expect("generate block ok!"); + assert_eq!(64, block_hash.to_hex().len()); } #[ignore] @@ -480,12 +489,19 @@ fn test_invalidate_block_ok() { .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, None).expect("Should work!"); - let block_hash = client.generate_block(&address, &[]).expect("OK"); + client + .create_wallet("my_wallet", Some(false)) + .expect("create wallet ok!"); + let address = client + .get_new_address(None, Some("legacy")) + .expect("get new address ok!"); + let address = BitcoinAddress::from_string(&address).expect("valid address!"); + let block_hash = client + .generate_block(&address, &[]) + .expect("generate block ok!"); client - .invalidate_block(&block_hash) + .invalidate_block(&block_hash.to_hex()) .expect("Invalidate valid hash should be ok!"); client .invalidate_block("invalid_hash") From 4c249d262e2f3584067d8a77bf044a2391aaa5e0 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 6 Aug 2025 10:16:00 +0200 Subject: [PATCH 35/62] crc: update get_new_address api, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 2 +- .../rpc/bitcoin_rpc_client/test_utils.rs | 73 +++++++++++++++-- .../rpc/bitcoin_rpc_client/tests.rs | 55 ++++++++++++- .../src/tests/bitcoin_rpc_integrations.rs | 79 ++++++++++++++++--- stackslib/src/burnchains/bitcoin/address.rs | 4 +- 5 files changed, 187 insertions(+), 26 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 8ac6ad739f..d7e09d3e8f 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -32,7 +32,7 @@ use stacks::config::Config; use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; #[cfg(test)] -mod test_utils; +pub mod test_utils; #[cfg(test)] mod tests; 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 index 7329060fa2..31edc7991e 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -20,6 +20,7 @@ use serde_json::Value; use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; use stacks::types::chainstate::BurnchainHeaderHash; +use stacks::types::Address; use stacks::util::hash::hex_bytes; use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; use stacks_common::deps_common::bitcoin::network::serialize::deserialize as btc_deserialize; @@ -63,6 +64,58 @@ where 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>, + { + let addr_str: String = Deserialize::deserialize(deserializer)?; + if addr_str.starts_with("bcrt") { + //Currently BitcoinAddress doesn't manage Regtest HRP + return Err(serde::de::Error::custom( + "BitcoinAddress cannot manage Regtest HRP ('bcrt')", + )); + } + + if let Some(addr) = BitcoinAddress::from_string(&addr_str) { + Ok(GetNewAddressResponse(addr)) + } else { + Err(serde::de::Error::custom( + "BitcoinAddress failed to create from string", + )) + } + } +} + impl BitcoinRpcClient { /// Retrieve general information about the current state of the blockchain. /// @@ -144,12 +197,12 @@ impl BitcoinRpcClient { /// /// # Arguments /// * `label` - Optional label to associate with the address. - /// * `address_type` - Optional address type (`"legacy"`, `"p2sh-segwit"`, `"bech32"`, `"bech32m"`). + /// * `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 string representing the newly generated Bitcoin address. + /// A [`BitcoinAddress`] variant representing the newly generated Bitcoin address. /// /// # Availability /// - **Since**: Bitcoin Core **v0.1.0**. @@ -158,20 +211,24 @@ impl BitcoinRpcClient { pub fn get_new_address( &self, label: Option<&str>, - address_type: Option<&str>, - ) -> BitcoinRpcClientResult { + 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.into()); + params.push(at.to_string().into()); } - Ok(self - .global_ep - .send(&self.client_id, "getnewaddress", params)?) + let response = self.global_ep.send::( + &self.client_id, + "getnewaddress", + params, + )?; + + Ok(response.0) } /// Sends a specified amount of BTC to a given address. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 01540e7397..17b4b47bd9 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -19,6 +19,7 @@ use serde_json::json; use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; use stacks::types::Address; +use stacks_common::deps_common::bech32; use stacks_common::deps_common::bitcoin::network::serialize::serialize_hex; use super::*; @@ -593,7 +594,7 @@ fn test_stop_ok() { #[test] fn test_get_new_address_ok() { - let expected_address = "btc_addr_1"; + let expected_address = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; let expected_request = json!({ "jsonrpc": "2.0", @@ -620,7 +621,44 @@ fn test_get_new_address_ok() { let client = utils::setup_client(&server); let address = client.get_new_address(None, None).expect("Should be ok!"); - assert_eq!(expected_address, address); + 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::Decode(_)) + )) } #[test] @@ -722,3 +760,16 @@ fn test_get_block_hash_ok() { let hash = client.get_block_hash(height).expect("Should be ok!"); assert_eq!(expected_hash, hash); } + +#[test] +fn test_addr() { + let regtest_hrp = "bcrt1qhzhzvy2v4ykg877s87nwuh37teqzxj95ecutjf"; + + let decoded = bech32::decode(regtest_hrp).unwrap(); + println!("WHAT: {decoded:?}"); + + let f = bech32::encode("tb", decoded.1, decoded.2).unwrap(); + println!("F===: {f:?}"); + + BitcoinAddress::from_string(&f).unwrap(); +} diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index d5c83315f3..c27bf4d36d 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -17,11 +17,11 @@ use std::env; -use stacks::burnchains::bitcoin::address::BitcoinAddress; +use stacks::burnchains::bitcoin::address::LegacyBitcoinAddressType; use stacks::burnchains::Txid; use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; -use stacks::types::Address; +use crate::burnchains::rpc::bitcoin_rpc_client::test_utils::AddressType; use crate::burnchains::rpc::bitcoin_rpc_client::{ BitcoinRpcClient, BitcoinRpcClientError, ImportDescriptorsRequest, Timestamp, }; @@ -238,6 +238,58 @@ fn test_wallet_creation_fails_if_already_exists() { } } +#[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 type OK + let legacy = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("legacy address ok!"); + assert_eq!( + LegacyBitcoinAddressType::PublicKeyHash, + legacy.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 + ); + + //Bech32 currently failing due to BitcoinAddress not supporting Regtest HRP + client + .get_new_address(None, Some(AddressType::Bech32)) + .expect_err("bech32 should fail!"); + + //Bech32m currently failing due to BitcoinAddress not supporting Regtest HRP + client + .get_new_address(None, Some(AddressType::Bech32m)) + .expect_err("bech32m should fail!"); + + //None defaults to bech32 so fails as well + client + .get_new_address(None, None) + .expect_err("default (bech32) should fail!"); +} + #[ignore] #[test] fn test_generate_to_address_and_list_unspent_ok() { @@ -255,13 +307,16 @@ fn test_generate_to_address_and_list_unspent_ok() { 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, None).expect("Should work!"); + let address = client + .get_new_address(None, Some(AddressType::Legacy)) + .expect("Should work!"); let utxos = client .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(0, utxos.len()); + let address = address.to_string(); let blocks = client.generate_to_address(102, &address).expect("OK"); assert_eq!(102, blocks.len()); @@ -296,10 +351,9 @@ fn test_generate_block_ok() { .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); let address = client - .get_new_address(None, Some("legacy")) + .get_new_address(None, Some(AddressType::Legacy)) .expect("get new address ok!"); - let address = BitcoinAddress::from_string(&address).expect("valid address!"); let block_hash = client .generate_block(&address, &[]) .expect("generate block ok!"); @@ -328,17 +382,17 @@ fn test_get_raw_transaction_ok() { .expect("create wallet ok!"); let address = client - .get_new_address(None, None) + .get_new_address(None, Some(AddressType::Legacy)) .expect("get new address ok!"); //Create 1 UTXO _ = client - .generate_to_address(101, &address) + .generate_to_address(101, &address.to_string()) .expect("generate to address ok!"); //Need `fallbackfee` arg let txid = client - .send_to_address(&address, 2.0) + .send_to_address(&address.to_string(), 2.0) .expect("send to address ok!"); let txid = Txid::from_hex(&txid).unwrap(); @@ -371,17 +425,17 @@ fn test_get_transaction_ok() { .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); let address = client - .get_new_address(None, None) + .get_new_address(None, Some(AddressType::Legacy)) .expect("get new address ok!"); //Create 1 UTXO _ = client - .generate_to_address(101, &address) + .generate_to_address(101, &address.to_string()) .expect("generate to address ok!"); //Need `fallbackfee` arg let txid = client - .send_to_address(&address, 2.0) + .send_to_address(&address.to_string(), 2.0) .expect("send to address ok!"); let resp = client.get_transaction(&txid).expect("get transaction ok!"); @@ -493,9 +547,8 @@ fn test_invalidate_block_ok() { .create_wallet("my_wallet", Some(false)) .expect("create wallet ok!"); let address = client - .get_new_address(None, Some("legacy")) + .get_new_address(None, Some(AddressType::Legacy)) .expect("get new address ok!"); - let address = BitcoinAddress::from_string(&address).expect("valid address!"); let block_hash = client .generate_block(&address, &[]) .expect("generate block ok!"); diff --git a/stackslib/src/burnchains/bitcoin/address.rs b/stackslib/src/burnchains/bitcoin/address.rs index 1b7a63cbcf..7734913982 100644 --- a/stackslib/src/burnchains/bitcoin/address.rs +++ b/stackslib/src/burnchains/bitcoin/address.rs @@ -533,7 +533,7 @@ impl BitcoinAddress { return false; } - #[cfg(test)] + #[cfg(any(test, feature = "testing"))] pub fn expect_legacy(self) -> LegacyBitcoinAddress { match self { BitcoinAddress::Legacy(addr) => addr, @@ -543,7 +543,7 @@ impl BitcoinAddress { } } - #[cfg(test)] + #[cfg(any(test, feature = "testing"))] pub fn expect_segwit(self) -> SegwitBitcoinAddress { match self { BitcoinAddress::Segwit(addr) => addr, From 513bd17a081edbeec6bd778ae4973fd685e3515b Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 6 Aug 2025 11:44:45 +0200 Subject: [PATCH 36/62] crc: update send_to_address api, #6250 --- .../rpc/bitcoin_rpc_client/test_utils.rs | 32 +++++++-- .../rpc/bitcoin_rpc_client/tests.rs | 65 ++++++++++++++++--- .../src/tests/bitcoin_rpc_integrations.rs | 11 ++-- 3 files changed, 86 insertions(+), 22 deletions(-) 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 index 31edc7991e..91411f805c 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -116,6 +116,21 @@ impl<'de> Deserialize<'de> for GetNewAddressResponse { } } +/// Response for `sendtoaddress` rpc, mainly used as deserialization wrapper for `Txid` +struct SendToAddressResponse(pub Txid); + +/// Deserializes a JSON string into [`Txid`] and wrap it into [`SendToAddressResponse`] +impl<'de> Deserialize<'de> for SendToAddressResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let hex_str: String = Deserialize::deserialize(deserializer)?; + let txid = Txid::from_hex(&hex_str).map_err(serde::de::Error::custom)?; + Ok(SendToAddressResponse(txid)) + } +} + impl BitcoinRpcClient { /// Retrieve general information about the current state of the blockchain. /// @@ -234,20 +249,25 @@ impl BitcoinRpcClient { /// Sends a specified amount of BTC to a given address. /// /// # Arguments - /// * `address` - The destination Bitcoin address. + /// * `address` - The destination Bitcoin address as a [`BitcoinAddress`]. /// * `amount` - Amount to send in BTC (not in satoshis). /// /// # Returns - /// The transaction ID as hex string + /// A [`Txid`] struct representing the transaction ID /// /// # Availability /// - **Since**: Bitcoin Core **v0.1.0**. - pub fn send_to_address(&self, address: &str, amount: f64) -> BitcoinRpcClientResult { - Ok(self.wallet_ep.send( + pub fn send_to_address( + &self, + address: &BitcoinAddress, + amount: f64, + ) -> BitcoinRpcClientResult { + let response = self.wallet_ep.send::( &self.client_id, "sendtoaddress", - vec![address.into(), amount.into()], - )?) + 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. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 17b4b47bd9..32b1609550 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -28,6 +28,10 @@ mod utils { use super::*; + pub const BITCOIN_ADDRESS_LEGACY_STR: &str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; + pub const BITCOIN_TXID_HEX: &str = + "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { let url = server.url(); let parsed = url::Url::parse(&url).unwrap(); @@ -293,7 +297,7 @@ fn test_get_transaction_ok() { #[test] fn test_get_raw_transaction_ok() { - let txid_hex = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + let txid_hex = utils::BITCOIN_TXID_HEX; let expected_tx_hex = "0100000001b1f2f67426d26301f0b20467e9fdd93557cb3cbbcb8d79f3a9c7b6c8ec7f69e8000000006a47304402206369d5eb2b7c99f540f4cf3ff2fd6f4b90f89c4328bfa0b6db0c30bb7f2c3d4c022015a1c0e5f6a0b08c271b2d218e6a7a29f5441dbe39d9a5cbcc223221ad5dbb59012103a34e84c8c7ebc8ecb7c2e59ff6672f392c792fc1c4f3c6fa2e7d3d314f1f38c9ffffffff0200e1f505000000001976a9144621d7f4ce0c956c80e6f0c1b9f78fe0c49cb82088ac80fae9c7000000001976a91488ac1f0f01c2a5c2e8f4b4f1a3b1a04d2f35b4c488ac00000000"; let expected_request = json!({ @@ -328,7 +332,7 @@ fn test_get_raw_transaction_ok() { #[test] fn test_generate_block_ok() { - let legacy_addr_str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; + let legacy_addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; let txid1 = "txid1"; let txid2 = "txid2"; let expected_block_hash = "0000000000000000011f5b3c4e7e9f4dc2c88f0b6c3a3b17e5a7d0dfeb3bb3cd"; @@ -368,7 +372,7 @@ fn test_generate_block_ok() { #[test] fn test_generate_block_fails_for_invalid_block_hash() { - let legacy_addr_str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; + let legacy_addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; let txid1 = "txid1"; let txid2 = "txid2"; let expected_block_hash = "invalid_block_hash"; @@ -594,7 +598,7 @@ fn test_stop_ok() { #[test] fn test_get_new_address_ok() { - let expected_address = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; + let expected_address = utils::BITCOIN_ADDRESS_LEGACY_STR; let expected_request = json!({ "jsonrpc": "2.0", @@ -663,20 +667,20 @@ fn test_get_new_address_fails_for_invalid_address() { #[test] fn test_send_to_address_ok() { - let address = "btc_addr_1"; + let address_str = utils::BITCOIN_ADDRESS_LEGACY_STR; let amount = 0.5; - let expected_txid = "txid_1"; + let expected_txid_str = utils::BITCOIN_TXID_HEX; let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", "method": "sendtoaddress", - "params": [address, amount] + "params": [address_str, amount] }); let mock_response = json!({ "id": "stacks", - "result": expected_txid, + "result": expected_txid_str, "error": null, }); @@ -691,10 +695,51 @@ fn test_send_to_address_ok() { let client = utils::setup_client(&server); + let address = BitcoinAddress::from_string(&address_str).unwrap(); let txid = client - .send_to_address(address, amount) + .send_to_address(&address, amount) .expect("Should be ok!"); - assert_eq!(expected_txid, txid); + assert_eq!(expected_txid_str, txid.to_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::Decode(_)) + )); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index c27bf4d36d..61a24dfa17 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -18,7 +18,6 @@ use std::env; use stacks::burnchains::bitcoin::address::LegacyBitcoinAddressType; -use stacks::burnchains::Txid; use stacks::core::BITCOIN_REGTEST_FIRST_BLOCK_HASH; use crate::burnchains::rpc::bitcoin_rpc_client::test_utils::AddressType; @@ -392,11 +391,9 @@ fn test_get_raw_transaction_ok() { //Need `fallbackfee` arg let txid = client - .send_to_address(&address.to_string(), 2.0) + .send_to_address(&address, 2.0) .expect("send to address ok!"); - let txid = Txid::from_hex(&txid).unwrap(); - let raw_tx = client .get_raw_transaction(&txid) .expect("get raw transaction ok!"); @@ -435,10 +432,12 @@ fn test_get_transaction_ok() { //Need `fallbackfee` arg let txid = client - .send_to_address(&address.to_string(), 2.0) + .send_to_address(&address, 2.0) .expect("send to address ok!"); - let resp = client.get_transaction(&txid).expect("get transaction ok!"); + let resp = client + .get_transaction(&txid.to_hex()) + .expect("get transaction ok!"); assert_eq!(0, resp.confirmations); } From bb11e0d7d7fbb72d93108eba8cd136782559de8a Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 6 Aug 2025 12:08:15 +0200 Subject: [PATCH 37/62] crc: update invalidate_block api, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/test_utils.rs | 11 +++++++---- .../src/burnchains/rpc/bitcoin_rpc_client/tests.rs | 10 +++++++--- stacks-node/src/tests/bitcoin_rpc_integrations.rs | 12 +++++++++--- 3 files changed, 23 insertions(+), 10 deletions(-) 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 index 91411f805c..aa3443360c 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -273,16 +273,19 @@ impl BitcoinRpcClient { /// Invalidate a block by its block hash, forcing the node to reconsider its chain state. /// /// # Arguments - /// * `hash` - The block hash (as a hex string) of the block to invalidate. + /// * `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: &str) -> BitcoinRpcClientResult<()> { - self.global_ep - .send::(&self.client_id, "invalidateblock", vec![hash.into()])?; + 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 index 32b1609550..970fa3d5bc 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -18,6 +18,7 @@ use serde_json::json; use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; +use stacks::types::chainstate::BurnchainHeaderHash; use stacks::types::Address; use stacks_common::deps_common::bech32; use stacks_common::deps_common::bitcoin::network::serialize::serialize_hex; @@ -31,6 +32,8 @@ mod utils { pub const BITCOIN_ADDRESS_LEGACY_STR: &str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; pub const BITCOIN_TXID_HEX: &str = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + pub const BITCOIN_BLOCK_HASH: &str = + "0000000000000000011f5b3c4e7e9f4dc2c88f0b6c3a3b17e5a7d0dfeb3bb3cd"; pub fn setup_client(server: &mockito::ServerGuard) -> BitcoinRpcClient { let url = server.url(); @@ -335,7 +338,7 @@ fn test_generate_block_ok() { let legacy_addr_str = utils::BITCOIN_ADDRESS_LEGACY_STR; let txid1 = "txid1"; let txid2 = "txid2"; - let expected_block_hash = "0000000000000000011f5b3c4e7e9f4dc2c88f0b6c3a3b17e5a7d0dfeb3bb3cd"; + let expected_block_hash = utils::BITCOIN_BLOCK_HASH; let expected_request = json!({ "jsonrpc": "2.0", @@ -744,7 +747,7 @@ fn test_send_to_address_fails_for_invalid_tx_id() { #[test] fn test_invalidate_block_ok() { - let hash = "0000"; + let hash = utils::BITCOIN_BLOCK_HASH; let expected_request = json!({ "jsonrpc": "2.0", @@ -770,7 +773,8 @@ fn test_invalidate_block_ok() { let client = utils::setup_client(&server); - client.invalidate_block(hash).expect("Should be ok!"); + let bhh = BurnchainHeaderHash::from_hex(&hash).unwrap(); + client.invalidate_block(&bhh).expect("Should be ok!"); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 61a24dfa17..8d05d38e10 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -19,6 +19,7 @@ use std::env; use stacks::burnchains::bitcoin::address::LegacyBitcoinAddressType; 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::{ @@ -553,11 +554,16 @@ fn test_invalidate_block_ok() { .expect("generate block ok!"); client - .invalidate_block(&block_hash.to_hex()) + .invalidate_block(&block_hash) .expect("Invalidate valid hash should be ok!"); + + let nonexistent_hash = BurnchainHeaderHash::from_hex( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); client - .invalidate_block("invalid_hash") - .expect_err("Invalidate invalid hash should fail!"); + .invalidate_block(&nonexistent_hash) + .expect_err("Invalidate nonexistent hash should fail!"); } #[ignore] From dfb4ec0abbfff3823c1aa06b31299589516a4914 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 6 Aug 2025 12:35:01 +0200 Subject: [PATCH 38/62] crc: update get_blockchain_info api, #6250 --- .../rpc/bitcoin_rpc_client/test_utils.rs | 29 +++- .../rpc/bitcoin_rpc_client/tests.rs | 137 +++++++++++++++++- .../src/tests/bitcoin_rpc_integrations.rs | 8 +- 3 files changed, 165 insertions(+), 9 deletions(-) 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 index aa3443360c..7d10132469 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -18,6 +18,7 @@ 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::types::Address; @@ -35,14 +36,36 @@ use crate::burnchains::rpc::bitcoin_rpc_client::{BitcoinRpcClient, BitcoinRpcCli #[derive(Debug, Clone, Deserialize)] pub struct GetBlockChainInfoResponse { /// the network name - pub chain: String, + #[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")] - pub best_block_hash: String, + #[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. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 970fa3d5bc..5c22fb973c 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -17,6 +17,7 @@ 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; @@ -53,7 +54,9 @@ mod utils { } #[test] -fn test_get_blockchain_info_ok() { +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", @@ -67,7 +70,133 @@ fn test_get_blockchain_info_ok() { "chain": "regtest", "blocks": 1, "headers": 2, - "bestblockhash": "00000" + "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::Decode(_)) + )); +} + +#[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 }); @@ -86,10 +215,10 @@ fn test_get_blockchain_info_ok() { .get_blockchain_info() .expect("get info should be ok!"); - assert_eq!("regtest", info.chain); + assert_eq!(BitcoinNetworkType::Mainnet, info.chain); assert_eq!(1, info.blocks); assert_eq!(2, info.headers); - assert_eq!("00000", info.best_block_hash); + assert_eq!(expected_block_hash, info.best_block_hash.to_hex()); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 8d05d38e10..ac75d194d6 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -18,6 +18,7 @@ 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; @@ -160,10 +161,13 @@ fn test_get_blockchain_info_ok() { let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); let info = client.get_blockchain_info().expect("Should be ok!"); - assert_eq!("regtest", info.chain); + 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); + assert_eq!( + BITCOIN_REGTEST_FIRST_BLOCK_HASH, + info.best_block_hash.to_hex() + ); } #[ignore] From 8e335b04cf48b3376833defa1682b5a61e007c2d Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 6 Aug 2025 13:15:35 +0200 Subject: [PATCH 39/62] crc: update get_transaction api, #6250 --- .../src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 13 ++++++++----- .../src/burnchains/rpc/bitcoin_rpc_client/tests.rs | 7 ++++--- stacks-node/src/tests/bitcoin_rpc_integrations.rs | 4 +--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index d7e09d3e8f..2fb3f96e90 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -27,6 +27,7 @@ use std::time::Duration; use serde::{Deserialize, Deserializer}; use serde_json::value::RawValue; use serde_json::{json, Value}; +use stacks::burnchains::Txid; use stacks::config::Config; use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; @@ -412,17 +413,19 @@ impl BitcoinRpcClient { /// hex-encoded transaction, and other metadata for a transaction tracked by the wallet. /// /// # Arguments - /// * `txid` - The transaction ID (txid) to query, as a hex-encoded string. + /// * `txid` - The transaction ID (as [`Txid`]) to query. /// /// # Returns /// A [`GetTransactionResponse`] containing detailed metadata for the specified transaction. /// /// # Availability /// - **Since**: Bitcoin Core **v0.10.0**. - pub fn get_transaction(&self, txid: &str) -> BitcoinRpcClientResult { - Ok(self - .wallet_ep - .send(&self.client_id, "gettransaction", vec![txid.into()])?) + pub fn get_transaction(&self, txid: &Txid) -> BitcoinRpcClientResult { + Ok(self.wallet_ep.send( + &self.client_id, + "gettransaction", + vec![txid.to_string().into()], + )?) } /// Broadcasts a raw transaction to the Bitcoin network. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 5c22fb973c..f5c2a34bc9 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -395,13 +395,13 @@ fn test_generate_to_address_ok() { #[test] fn test_get_transaction_ok() { - let txid = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + let txid_hex = utils::BITCOIN_TXID_HEX; let expected_request = json!({ "jsonrpc": "2.0", "id": "stacks", "method": "gettransaction", - "params": [txid] + "params": [txid_hex] }); let mock_response = json!({ @@ -423,7 +423,8 @@ fn test_get_transaction_ok() { let client = utils::setup_client(&server); - let info = client.get_transaction(txid).expect("Should be ok!"); + let txid = Txid::from_hex(&txid_hex).unwrap(); + let info = client.get_transaction(&txid).expect("Should be ok!"); assert_eq!(6, info.confirmations); } diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index ac75d194d6..b01c584790 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -440,9 +440,7 @@ fn test_get_transaction_ok() { .send_to_address(&address, 2.0) .expect("send to address ok!"); - let resp = client - .get_transaction(&txid.to_hex()) - .expect("get transaction ok!"); + let resp = client.get_transaction(&txid).expect("get transaction ok!"); assert_eq!(0, resp.confirmations); } From 3f0734478f3e42a355f8f95abe864700a5aed4d4 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 6 Aug 2025 13:43:53 +0200 Subject: [PATCH 40/62] crc: update generate_to_address api, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 42 ++++++++++--- .../rpc/bitcoin_rpc_client/tests.rs | 62 ++++++++++++++++--- .../src/tests/bitcoin_rpc_integrations.rs | 39 ++++++++++-- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 2fb3f96e90..ce8b82a538 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -27,8 +27,10 @@ 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 crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; @@ -166,6 +168,31 @@ pub struct ImportDescriptorsErrorMessage { 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)) + } +} + /// Client for interacting with a Bitcoin RPC service. #[derive(Debug)] pub struct BitcoinRpcClient { @@ -385,10 +412,10 @@ impl BitcoinRpcClient { /// /// # Arguments /// * `num_block` - The number of blocks to mine. - /// * `address` - The Bitcoin address to receive the block rewards. + /// * `address` - The [`BitcoinAddress`] to receive the block rewards. /// /// # Returns - /// A vector of block hashes corresponding to the newly generated blocks. + /// A vector of [`BurnchainHeaderHash`] corresponding to the newly generated blocks. /// /// # Availability /// - **Since**: Bitcoin Core **v0.17.0**. @@ -398,13 +425,14 @@ impl BitcoinRpcClient { pub fn generate_to_address( &self, num_block: u64, - address: &str, - ) -> BitcoinRpcClientResult> { - Ok(self.global_ep.send( + address: &BitcoinAddress, + ) -> BitcoinRpcClientResult> { + let response = self.global_ep.send::( &self.client_id, "generatetoaddress", - vec![num_block.into(), address.into()], - )?) + vec![num_block.into(), address.to_string().into()], + )?; + Ok(response.0) } /// Retrieves detailed information about an in-wallet transaction. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index f5c2a34bc9..c2de0a7d30 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -355,21 +355,21 @@ fn test_list_unspent_ok() { #[test] fn test_generate_to_address_ok() { - let num_blocks = 3; - let address = "00000000000000000000000000000000000000000000000000000"; + 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, address], + "params": [num_blocks, addr_str], }); let mock_response = json!({ "id": "stacks", "result": [ - "block_hash1", - "block_hash2", + expected_block_hash, ], "error": null }); @@ -385,12 +385,56 @@ fn test_generate_to_address_ok() { let client = utils::setup_client(&server); + let address = BitcoinAddress::from_string(addr_str).unwrap(); let result = client - .generate_to_address(num_blocks, address) + .generate_to_address(num_blocks, &address) .expect("Should work!"); - assert_eq!(2, result.len()); - assert_eq!("block_hash1", result[0]); - assert_eq!("block_hash2", result[1]); + 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::Decode(_)) + )); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index b01c584790..c677943533 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -296,7 +296,34 @@ fn test_get_new_address_for_each_address_type() { #[ignore] #[test] -fn test_generate_to_address_and_list_unspent_ok() { +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_ok() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -320,9 +347,9 @@ fn test_generate_to_address_and_list_unspent_ok() { .expect("list_unspent should be ok!"); assert_eq!(0, utxos.len()); - let address = address.to_string(); - let blocks = client.generate_to_address(102, &address).expect("OK"); - assert_eq!(102, blocks.len()); + _ = client + .generate_to_address(102, &address) + .expect("generate to address ok!"); let utxos = client .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) @@ -391,7 +418,7 @@ fn test_get_raw_transaction_ok() { //Create 1 UTXO _ = client - .generate_to_address(101, &address.to_string()) + .generate_to_address(101, &address) .expect("generate to address ok!"); //Need `fallbackfee` arg @@ -432,7 +459,7 @@ fn test_get_transaction_ok() { //Create 1 UTXO _ = client - .generate_to_address(101, &address.to_string()) + .generate_to_address(101, &address) .expect("generate to address ok!"); //Need `fallbackfee` arg From 625794ffae00d01f55e4a6fa741af6a1aa92d367 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 7 Aug 2025 16:08:47 +0200 Subject: [PATCH 41/62] crc: update send_raw_transaction api, #6250 --- .../deps_common/bitcoin/network/serialize.rs | 25 ++++++++- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 21 +++----- .../rpc/bitcoin_rpc_client/test_utils.rs | 7 +-- .../rpc/bitcoin_rpc_client/tests.rs | 51 ++++++++----------- 4 files changed, 54 insertions(+), 50 deletions(-) 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/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index ce8b82a538..89e0df0a45 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -31,6 +31,8 @@ use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; use stacks::config::Config; use stacks::types::chainstate::BurnchainHeaderHash; +use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; +use stacks_common::deps_common::bitcoin::network::serialize::serialize_hex; use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; @@ -213,8 +215,6 @@ pub enum BitcoinRpcClientError { Serialization(serde_json::Error), // Bitcoin serialization errors BitcoinSerialization(stacks_common::deps_common::bitcoin::network::serialize::Error), - // Hex conversion errors - Hex(stacks_common::util::HexError), } impl From for BitcoinRpcClientError { @@ -237,12 +237,6 @@ impl From } } -impl From for BitcoinRpcClientError { - fn from(err: stacks_common::util::HexError) -> Self { - BitcoinRpcClientError::Hex(err) - } -} - /// Alias for results returned from client operations. pub type BitcoinRpcClientResult = Result; @@ -463,7 +457,7 @@ impl BitcoinRpcClient { /// /// # Arguments /// - /// * `tx` - A hex-encoded string representing the raw transaction. + /// * `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. @@ -472,24 +466,25 @@ impl BitcoinRpcClient { /// - If `None`, defaults to `0`, meaning burning is not allowed. /// /// # Returns - /// A transaction ID as a `String`. + /// A [`Txid`] as a transaction ID. /// /// # Availability /// - **Since**: Bitcoin Core **v0.7.0**. /// - `maxburnamount` parameter is available starting from **v25.0**. pub fn send_raw_transaction( &self, - tx: &str, + tx: &Transaction, max_fee_rate: Option, max_burn_amount: Option, - ) -> BitcoinRpcClientResult { + ) -> BitcoinRpcClientResult { + let tx_hex = serialize_hex(tx)?; let max_fee_rate = max_fee_rate.unwrap_or(0.10); let max_burn_amount = max_burn_amount.unwrap_or(0); Ok(self.global_ep.send( &self.client_id, "sendrawtransaction", - vec![tx.into(), max_fee_rate.into(), max_burn_amount.into()], + vec![tx_hex.into(), max_fee_rate.into(), max_burn_amount.into()], )?) } 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 index 7d10132469..80cf24ae09 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -22,9 +22,8 @@ use stacks::burnchains::bitcoin::BitcoinNetworkType; use stacks::burnchains::Txid; use stacks::types::chainstate::BurnchainHeaderHash; use stacks::types::Address; -use stacks::util::hash::hex_bytes; use stacks_common::deps_common::bitcoin::blockdata::transaction::Transaction; -use stacks_common::deps_common::bitcoin::network::serialize::deserialize as btc_deserialize; +use stacks_common::deps_common::bitcoin::network::serialize::deserialize_hex; use crate::burnchains::rpc::bitcoin_rpc_client::{BitcoinRpcClient, BitcoinRpcClientResult}; @@ -184,9 +183,7 @@ impl BitcoinRpcClient { "getrawtransaction", vec![txid.to_string().into()], )?; - let raw_bytes = hex_bytes(&raw_hex)?; - let tx = btc_deserialize(&raw_bytes)?; - Ok(tx) + Ok(deserialize_hex(&raw_hex)?) } /// Mines a new block including the given transactions to a specified address. diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index c2de0a7d30..1f659d9af9 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -21,8 +21,7 @@ use stacks::burnchains::bitcoin::BitcoinNetworkType; use stacks::burnchains::Txid; use stacks::types::chainstate::BurnchainHeaderHash; use stacks::types::Address; -use stacks_common::deps_common::bech32; -use stacks_common::deps_common::bitcoin::network::serialize::serialize_hex; +use stacks_common::deps_common::bitcoin::network::serialize::{deserialize_hex, serialize_hex}; use super::*; @@ -31,8 +30,9 @@ mod utils { use super::*; pub const BITCOIN_ADDRESS_LEGACY_STR: &str = "mp7gy5VhHzBzk1tJUtP7Qwdrp87XEWnxd4"; - pub const BITCOIN_TXID_HEX: &str = + pub const BITCOIN_TX1_TXID_HEX: &str = "b9a0d01a3e21809e920fa022dfdd85368d56d1cacc5229f7a704c4d5fbccc6bd"; + pub const BITCOIN_TX1_RAW_HEX: &str = "0100000001b1f2f67426d26301f0b20467e9fdd93557cb3cbbcb8d79f3a9c7b6c8ec7f69e8000000006a47304402206369d5eb2b7c99f540f4cf3ff2fd6f4b90f89c4328bfa0b6db0c30bb7f2c3d4c022015a1c0e5f6a0b08c271b2d218e6a7a29f5441dbe39d9a5cbcc223221ad5dbb59012103a34e84c8c7ebc8ecb7c2e59ff6672f392c792fc1c4f3c6fa2e7d3d314f1f38c9ffffffff0200e1f505000000001976a9144621d7f4ce0c956c80e6f0c1b9f78fe0c49cb82088ac80fae9c7000000001976a91488ac1f0f01c2a5c2e8f4b4f1a3b1a04d2f35b4c488ac00000000"; pub const BITCOIN_BLOCK_HASH: &str = "0000000000000000011f5b3c4e7e9f4dc2c88f0b6c3a3b17e5a7d0dfeb3bb3cd"; @@ -439,7 +439,7 @@ fn test_generate_to_address_fails_for_invalid_block_hash() { #[test] fn test_get_transaction_ok() { - let txid_hex = utils::BITCOIN_TXID_HEX; + let txid_hex = utils::BITCOIN_TX1_TXID_HEX; let expected_request = json!({ "jsonrpc": "2.0", @@ -474,8 +474,8 @@ fn test_get_transaction_ok() { #[test] fn test_get_raw_transaction_ok() { - let txid_hex = utils::BITCOIN_TXID_HEX; - let expected_tx_hex = "0100000001b1f2f67426d26301f0b20467e9fdd93557cb3cbbcb8d79f3a9c7b6c8ec7f69e8000000006a47304402206369d5eb2b7c99f540f4cf3ff2fd6f4b90f89c4328bfa0b6db0c30bb7f2c3d4c022015a1c0e5f6a0b08c271b2d218e6a7a29f5441dbe39d9a5cbcc223221ad5dbb59012103a34e84c8c7ebc8ecb7c2e59ff6672f392c792fc1c4f3c6fa2e7d3d314f1f38c9ffffffff0200e1f505000000001976a9144621d7f4ce0c956c80e6f0c1b9f78fe0c49cb82088ac80fae9c7000000001976a91488ac1f0f01c2a5c2e8f4b4f1a3b1a04d2f35b4c488ac00000000"; + let txid_hex = utils::BITCOIN_TX1_TXID_HEX; + let expected_tx_hex = utils::BITCOIN_TX1_RAW_HEX; let expected_request = json!({ "jsonrpc": "2.0", @@ -592,14 +592,14 @@ fn test_generate_block_fails_for_invalid_block_hash() { #[test] fn test_send_raw_transaction_ok_with_defaults() { - let raw_tx = "raw_tx_hex"; - let expected_txid = "txid1"; + 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, 0.10, 0] + "params": [raw_tx_hex, 0.10, 0] }); let mock_response = json!({ @@ -618,22 +618,24 @@ fn test_send_raw_transaction_ok_with_defaults() { .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) + .send_raw_transaction(&raw_tx, None, None) .expect("Should work!"); - assert_eq!(txid, expected_txid); + assert_eq!(expected_txid, txid.to_hex()); } #[test] fn test_send_raw_transaction_ok_with_custom_params() { - let raw_tx = "raw_tx_hex"; - let expected_txid = "txid1"; + 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, 0.0, 5_000] + "params": [raw_tx_hex, 0.0, 5_000] }); let mock_response = json!({ @@ -652,10 +654,12 @@ fn test_send_raw_transaction_ok_with_custom_params() { .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)) + .send_raw_transaction(&raw_tx, Some(0.0), Some(5_000)) .expect("Should work!"); - assert_eq!(txid, expected_txid); + assert_eq!(expected_txid, txid.to_hex()); } #[test] @@ -846,7 +850,7 @@ fn test_get_new_address_fails_for_invalid_address() { fn test_send_to_address_ok() { let address_str = utils::BITCOIN_ADDRESS_LEGACY_STR; let amount = 0.5; - let expected_txid_str = utils::BITCOIN_TXID_HEX; + let expected_txid_str = utils::BITCOIN_TX1_TXID_HEX; let expected_request = json!({ "jsonrpc": "2.0", @@ -983,16 +987,3 @@ fn test_get_block_hash_ok() { let hash = client.get_block_hash(height).expect("Should be ok!"); assert_eq!(expected_hash, hash); } - -#[test] -fn test_addr() { - let regtest_hrp = "bcrt1qhzhzvy2v4ykg877s87nwuh37teqzxj95ecutjf"; - - let decoded = bech32::decode(regtest_hrp).unwrap(); - println!("WHAT: {decoded:?}"); - - let f = bech32::encode("tb", decoded.1, decoded.2).unwrap(); - println!("F===: {f:?}"); - - BitcoinAddress::from_string(&f).unwrap(); -} From 4114e6eba5fca723f099e942540b3304319c3ccc Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 8 Aug 2025 16:55:18 +0200 Subject: [PATCH 42/62] crc: update list_unspent api, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 127 +++++++++++++++--- .../rpc/bitcoin_rpc_client/tests.rs | 59 ++++++-- .../src/tests/bitcoin_rpc_integrations.rs | 6 +- stackslib/src/chainstate/stacks/mod.rs | 61 +++++++++ 4 files changed, 218 insertions(+), 35 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 89e0df0a45..c929e3ac03 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -31,6 +31,8 @@ use stacks::burnchains::bitcoin::address::BitcoinAddress; use stacks::burnchains::Txid; use stacks::config::Config; use stacks::types::chainstate::BurnchainHeaderHash; +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; @@ -134,31 +136,117 @@ pub struct ImportDescriptorsResponse { /// 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)] -#[serde(rename_all = "camelCase")] pub struct ListUnspentResponse { /// The transaction ID of the UTXO. - pub txid: String, + #[serde(deserialize_with = "deserialize_string_to_txid")] + pub txid: Txid, /// The index of the output in the transaction. pub vout: u32, /// The script associated with the output. - pub script_pub_key: String, + #[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 = "serde_raw_to_string")] - pub amount: String, + #[serde(deserialize_with = "deserialize_btc_string_to_sat")] + pub amount: u64, /// The number of confirmations for the transaction. pub confirmations: u32, } -/// Deserializes any raw JSON value into its unprocessed string representation. +/// 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 [`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`). /// -/// Useful when you need to defer parsing, preserve exact formatting (e.g., precision), -/// or handle heterogeneous value types dynamically. -fn serde_raw_to_string<'de, D>(deserializer: D) -> Result +/// 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)?; - Ok(raw.get().to_string()) + 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. @@ -361,7 +449,7 @@ impl BitcoinRpcClient { /// * `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 BTC. As String to preserve full precision) a UTXO must have to be included (Default: "0"). + /// * `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: 9.999.999). /// /// # Returns @@ -374,17 +462,20 @@ impl BitcoinRpcClient { &self, min_confirmations: Option, max_confirmations: Option, - addresses: Option<&[&str]>, + addresses: Option<&[&BitcoinAddress]>, include_unsafe: Option, - minimum_amount: Option<&str>, + minimum_amount: Option, maximum_count: Option, ) -> BitcoinRpcClientResult> { let min_confirmations = min_confirmations.unwrap_or(0); - let max_confirmations = max_confirmations.unwrap_or(9999999); + 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(9999999); + let minimum_amount = minimum_amount.unwrap_or(0); + let maximum_count = maximum_count.unwrap_or(9_999_999); + + 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, @@ -392,10 +483,10 @@ impl BitcoinRpcClient { vec![ min_confirmations.into(), max_confirmations.into(), - addresses.into(), + addr_as_strings.into(), include_unsafe.into(), json!({ - "minimumAmount": minimum_amount, + "minimumAmount": min_amount_btc_str, "maximumCount": maximum_count }), ], diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 1f659d9af9..d5490da3f2 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -35,6 +35,7 @@ mod utils { 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(); @@ -288,6 +289,9 @@ fn test_list_wallets_ok() { #[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_request = json!({ "jsonrpc": "2.0", "id": "stacks", @@ -295,7 +299,7 @@ fn test_list_unspent_ok() { "params": [ 1, 10, - ["BTC_ADDRESS_1"], + [utils::BITCOIN_ADDRESS_LEGACY_STR], true, { "minimumAmount": "0.00001000", @@ -307,9 +311,9 @@ fn test_list_unspent_ok() { let mock_response = json!({ "id": "stacks", "result": [{ - "txid": "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "txid": expected_txid_str, "vout": 0, - "scriptPubKey": "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", + "scriptPubKey": expected_script_hex, "amount": 0.00001, "confirmations": 6 }], @@ -327,30 +331,26 @@ fn test_list_unspent_ok() { 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(&["BTC_ADDRESS_1"]), + Some(&[&addr]), Some(true), - Some("0.00001000"), // 1000 sats = 0.00001000 BTC + 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!("0.00001", utxo.amount); + assert_eq!(1_000, utxo.amount); assert_eq!(0, utxo.vout); assert_eq!(6, utxo.confirmations); - assert_eq!( - "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", - utxo.txid, - ); - assert_eq!( - "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", - utxo.script_pub_key, - ); + assert_eq!(expected_txid_str, utxo.txid.to_bitcoin_hex(),); + assert_eq!(expected_script_hex, format!("{:x}", utxo.script_pub_key),); } #[test] @@ -987,3 +987,34 @@ fn test_get_block_hash_ok() { let hash = client.get_block_hash(height).expect("Should be ok!"); assert_eq!(expected_hash, hash); } + +#[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 1_000_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/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index c677943533..f5303caf70 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -343,7 +343,7 @@ fn test_list_unspent_ok() { .expect("Should work!"); let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) + .list_unspent(None, None, None, Some(false), Some(1), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(0, utxos.len()); @@ -352,12 +352,12 @@ fn test_list_unspent_ok() { .expect("generate to address ok!"); let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(10)) + .list_unspent(None, None, None, Some(false), Some(1), Some(10)) .expect("list_unspent should be ok!"); assert_eq!(2, utxos.len()); let utxos = client - .list_unspent(None, None, None, Some(false), Some("1"), Some(1)) + .list_unspent(None, None, None, Some(false), Some(1), Some(1)) .expect("list_unspent should be ok!"); assert_eq!(1, utxos.len()); } diff --git a/stackslib/src/chainstate/stacks/mod.rs b/stackslib/src/chainstate/stacks/mod.rs index 2fe9ccce41..b999dfb832 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); + } } From 5224137d37a96a6fdb3eedd7ffc6d7e504df255c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 8 Aug 2025 17:57:17 +0200 Subject: [PATCH 43/62] refactor: use Txid taking into account bitcoin endiness, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 36 ++++++++++++++----- .../rpc/bitcoin_rpc_client/test_utils.rs | 30 ++++++---------- .../rpc/bitcoin_rpc_client/tests.rs | 10 +++--- .../src/tests/bitcoin_rpc_integrations.rs | 2 +- 4 files changed, 43 insertions(+), 35 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index c929e3ac03..0402136de9 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -283,6 +283,21 @@ impl<'de> Deserialize<'de> for GenerateToAddressResponse { } } +/// 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)) + } +} + /// Client for interacting with a Bitcoin RPC service. #[derive(Debug)] pub struct BitcoinRpcClient { @@ -526,7 +541,9 @@ impl BitcoinRpcClient { /// hex-encoded transaction, and other metadata for a transaction tracked by the wallet. /// /// # Arguments - /// * `txid` - The transaction ID (as [`Txid`]) to query. + /// * `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. @@ -534,11 +551,11 @@ impl BitcoinRpcClient { /// # Availability /// - **Since**: Bitcoin Core **v0.10.0**. pub fn get_transaction(&self, txid: &Txid) -> BitcoinRpcClientResult { - Ok(self.wallet_ep.send( - &self.client_id, - "gettransaction", - vec![txid.to_string().into()], - )?) + 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. @@ -557,7 +574,7 @@ impl BitcoinRpcClient { /// - If `None`, defaults to `0`, meaning burning is not allowed. /// /// # Returns - /// A [`Txid`] as a transaction ID. + /// A [`Txid`] as a transaction ID (storing internally bytes in **little-endian** order) /// /// # Availability /// - **Since**: Bitcoin Core **v0.7.0**. @@ -572,11 +589,12 @@ impl BitcoinRpcClient { let max_fee_rate = max_fee_rate.unwrap_or(0.10); let max_burn_amount = max_burn_amount.unwrap_or(0); - Ok(self.global_ep.send( + 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. 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 index 80cf24ae09..5f825547e4 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -25,7 +25,9 @@ use stacks::types::Address; 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::{BitcoinRpcClient, BitcoinRpcClientResult}; +use crate::burnchains::rpc::bitcoin_rpc_client::{ + BitcoinRpcClient, BitcoinRpcClientResult, TxidWrapperResponse, +}; /// Represents the response returned by the `getblockchaininfo` RPC call. /// @@ -138,21 +140,6 @@ impl<'de> Deserialize<'de> for GetNewAddressResponse { } } -/// Response for `sendtoaddress` rpc, mainly used as deserialization wrapper for `Txid` -struct SendToAddressResponse(pub Txid); - -/// Deserializes a JSON string into [`Txid`] and wrap it into [`SendToAddressResponse`] -impl<'de> Deserialize<'de> for SendToAddressResponse { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let hex_str: String = Deserialize::deserialize(deserializer)?; - let txid = Txid::from_hex(&hex_str).map_err(serde::de::Error::custom)?; - Ok(SendToAddressResponse(txid)) - } -} - impl BitcoinRpcClient { /// Retrieve general information about the current state of the blockchain. /// @@ -170,7 +157,8 @@ impl BitcoinRpcClient { /// Retrieves and deserializes a raw Bitcoin transaction by its ID. /// /// # Arguments - /// * `txid` - Transaction ID to fetch. + /// * `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. @@ -178,10 +166,12 @@ impl BitcoinRpcClient { /// # 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![txid.to_string().into()], + vec![btc_txid.to_string().into()], )?; Ok(deserialize_hex(&raw_hex)?) } @@ -273,7 +263,7 @@ impl BitcoinRpcClient { /// * `amount` - Amount to send in BTC (not in satoshis). /// /// # Returns - /// A [`Txid`] struct representing the transaction ID + /// A [`Txid`] struct representing the transaction ID (storing internally bytes in **little-endian** order) /// /// # Availability /// - **Since**: Bitcoin Core **v0.1.0**. @@ -282,7 +272,7 @@ impl BitcoinRpcClient { address: &BitcoinAddress, amount: f64, ) -> BitcoinRpcClientResult { - let response = self.wallet_ep.send::( + let response = self.wallet_ep.send::( &self.client_id, "sendtoaddress", vec![address.to_string().into(), amount.into()], diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index d5490da3f2..6a621de4a9 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -467,7 +467,7 @@ fn test_get_transaction_ok() { let client = utils::setup_client(&server); - let txid = Txid::from_hex(&txid_hex).unwrap(); + let txid = Txid::from_bitcoin_hex(&txid_hex).unwrap(); let info = client.get_transaction(&txid).expect("Should be ok!"); assert_eq!(6, info.confirmations); } @@ -501,7 +501,7 @@ fn test_get_raw_transaction_ok() { let client = utils::setup_client(&server); - let txid = Txid::from_hex(txid_hex).unwrap(); + 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()); @@ -623,7 +623,7 @@ fn test_send_raw_transaction_ok_with_defaults() { let txid = client .send_raw_transaction(&raw_tx, None, None) .expect("Should work!"); - assert_eq!(expected_txid, txid.to_hex()); + assert_eq!(expected_txid, txid.to_bitcoin_hex()); } #[test] @@ -659,7 +659,7 @@ fn test_send_raw_transaction_ok_with_custom_params() { let txid = client .send_raw_transaction(&raw_tx, Some(0.0), Some(5_000)) .expect("Should work!"); - assert_eq!(expected_txid, txid.to_hex()); + assert_eq!(expected_txid, txid.to_bitcoin_hex()); } #[test] @@ -880,7 +880,7 @@ fn test_send_to_address_ok() { let txid = client .send_to_address(&address, amount) .expect("Should be ok!"); - assert_eq!(expected_txid_str, txid.to_hex()); + assert_eq!(expected_txid_str, txid.to_bitcoin_hex()); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index f5303caf70..1a6868ec39 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -430,7 +430,7 @@ fn test_get_raw_transaction_ok() { .get_raw_transaction(&txid) .expect("get raw transaction ok!"); - assert_eq!(txid.to_string(), raw_tx.txid().to_string()); + assert_eq!(txid.to_bitcoin_hex(), raw_tx.txid().to_string()); } #[ignore] From f42264a7819a8ad00bdaf80ce3279694cfd8a442 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 09:26:49 +0200 Subject: [PATCH 44/62] test: add send_raw_transaction rebroadcast test, #6250 --- .../src/tests/bitcoin_rpc_integrations.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 1a6868ec39..bbf2a93023 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -617,3 +617,49 @@ fn test_get_block_hash_ok() { .expect("Should return regtest genesis block hash!"); assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); } + +#[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()); +} From 2834f8c09132c4af436f858499a7225abb96ed6d Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 10:12:12 +0200 Subject: [PATCH 45/62] crc: update get_block_hash api, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 29 +++++++++++++++---- .../rpc/bitcoin_rpc_client/tests.rs | 6 ++-- .../src/tests/bitcoin_rpc_integrations.rs | 4 +-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 0402136de9..12a59e2a89 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -298,6 +298,22 @@ impl<'de> Deserialize<'de> for TxidWrapperResponse { } } +/// 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 { @@ -651,13 +667,16 @@ impl BitcoinRpcClient { /// * `height` - The height (block number) of the block whose hash is requested. /// /// # Returns - /// A `String` representing the block hash in hexadecimal format. + /// A [`BurnchainHeaderHash`] representing the block hash. /// /// # Availability /// - **Since**: Bitcoin Core **v0.9.0**. - pub fn get_block_hash(&self, height: u64) -> BitcoinRpcClientResult { - Ok(self - .global_ep - .send(&self.client_id, "getblockhash", vec![height.into()])?) + 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/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 6a621de4a9..81a74a2f96 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -958,7 +958,7 @@ fn test_invalidate_block_ok() { #[test] fn test_get_block_hash_ok() { let height = 1; - let expected_hash = "0000"; + let expected_hash = utils::BITCOIN_BLOCK_HASH; let expected_request = json!({ "jsonrpc": "2.0", @@ -984,8 +984,8 @@ fn test_get_block_hash_ok() { let client = utils::setup_client(&server); - let hash = client.get_block_hash(height).expect("Should be ok!"); - assert_eq!(expected_hash, hash); + let bhh = client.get_block_hash(height).expect("Should be ok!"); + assert_eq!(expected_hash, bhh.to_hex()); } #[test] diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index bbf2a93023..2850cbf7fa 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -612,10 +612,10 @@ fn test_get_block_hash_ok() { let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); - let hash = client + let bhh = client .get_block_hash(0) .expect("Should return regtest genesis block hash!"); - assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, hash); + assert_eq!(BITCOIN_REGTEST_FIRST_BLOCK_HASH, bhh.to_hex()); } #[ignore] From 6e4d6f5f23f5f05d14029ae818bf44021eff6f2f Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 14:11:24 +0200 Subject: [PATCH 46/62] crc: RpcError expose punctual error types, #6250 --- .../rpc/bitcoin_rpc_client/tests.rs | 10 +- .../src/burnchains/rpc/rpc_transport/mod.rs | 103 ++++++++++-------- .../src/burnchains/rpc/rpc_transport/tests.rs | 60 ++++++---- .../src/tests/bitcoin_rpc_integrations.rs | 8 +- 4 files changed, 102 insertions(+), 79 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 81a74a2f96..bcc3d1b030 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -176,7 +176,7 @@ fn test_get_blockchain_info_fails_for_unknown_network() { assert!(matches!( error, - BitcoinRpcClientError::Rpc(RpcError::Decode(_)) + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) )); } @@ -433,7 +433,7 @@ fn test_generate_to_address_fails_for_invalid_block_hash() { .expect_err("Should fail!"); assert!(matches!( error, - BitcoinRpcClientError::Rpc(RpcError::Decode(_)) + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) )); } @@ -586,7 +586,7 @@ fn test_generate_block_fails_for_invalid_block_hash() { .expect_err("Should fail!"); assert!(matches!( error, - BitcoinRpcClientError::Rpc(RpcError::Decode(_)) + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) )); } @@ -842,7 +842,7 @@ fn test_get_new_address_fails_for_invalid_address() { .expect_err("Should fail!"); assert!(matches!( error, - BitcoinRpcClientError::Rpc(RpcError::Decode(_)) + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) )) } @@ -919,7 +919,7 @@ fn test_send_to_address_fails_for_invalid_tx_id() { .expect_err("Should fail!"); assert!(matches!( error, - BitcoinRpcClientError::Rpc(RpcError::Decode(_)) + BitcoinRpcClientError::Rpc(RpcError::DecodeJson(_)) )); } diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs index bf08098715..d3bebd5a6e 100644 --- a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs @@ -58,43 +58,62 @@ struct JsonRpcResponse { /// Result returned from the RPC method, if successful. result: Option, /// Error object returned by the RPC server, if the call failed. - error: Option, + 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, Clone, Deserialize, Serialize)] +#[derive(Debug, thiserror::Error)] pub enum RpcError { - /// Represents a network-level error, such as connection failures or timeouts. - Network(String), - /// Indicates that the request could not be encoded properly - Encode(String), - /// Indicates that the response could not be decoded properly. - Decode(String), + // 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. - Service(String), + #[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; -impl From for RpcError { - fn from(e: url::ParseError) -> Self { - Self::Network(format!("Url Error: {e:?}")) - } -} - -impl From for RpcError { - fn from(e: stacks_common::types::net::Error) -> Self { - Self::Network(format!("Net Error: {e:?}")) - } -} - -impl From for RpcError { - fn from(e: io::Error) -> Self { - Self::Network(format!("IO Error: {e:?}")) - } -} - /// Represents supported authentication mechanisms for RPC requests. #[derive(Debug, Clone)] pub enum RpcAuth { @@ -136,10 +155,10 @@ impl RpcTransport { let url_obj = Url::parse(&url)?; let host = url_obj .host_str() - .ok_or(RpcError::Network(format!("Missing host in url: {url}")))?; + .ok_or(RpcError::UrlMissingHost(url_obj.clone()))?; let port = url_obj .port_or_known_default() - .ok_or(RpcError::Network(format!("Missing port in url: {url}")))?; + .ok_or(RpcError::UrlMissingHost(url_obj.clone()))?; let peer: PeerHost = format!("{host}:{port}").parse()?; let path = url_obj.path().to_string(); @@ -177,20 +196,15 @@ impl RpcTransport { params: Value::Array(params), }; - let json_payload = serde_json::to_value(payload) - .map_err(|e| RpcError::Encode(format!("Failed to encode request as JSON: {e:?}")))?; + 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), - ) - .map_err(|e| { - RpcError::Encode(format!( - "Failed to encode infallible data as HTTP request {e:?}" - )) - })?; + )?; + request.add_header("Connection".into(), "close".into()); if let Some(auth_header) = self.auth_header() { @@ -203,27 +217,24 @@ impl RpcTransport { let response = send_http_request(&host, port, request, self.timeout)?; let json_response = match response.destruct().1 { HttpResponsePayload::JSON(js) => Ok(js), - _ => Err(RpcError::Decode("Did not get a JSON response".to_string())), + _ => Err(RpcError::InvalidJsonPayload), }?; - let parsed_response: JsonRpcResponse = serde_json::from_value(json_response) - .map_err(|e| RpcError::Decode(format!("Json Parse Error: {e:?}")))?; + let parsed_response: JsonRpcResponse = + serde_json::from_value(json_response).map_err(RpcError::DecodeJson)?; if id != parsed_response.id { - return Err(RpcError::Decode(format!( - "Invalid response: mismatched 'id': expected '{}', got '{}'", - 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(format!("{:#}", error))); + return Err(RpcError::Service(error)); } if let Some(result) = parsed_response.result { Ok(result) } else { - Ok(serde_json::from_value(Value::Null).unwrap()) + Ok(serde_json::from_value(Value::Null).map_err(RpcError::DecodeJson)?) } } diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs b/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs index 4590cc988b..30d8b7f77f 100644 --- a/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport/tests.rs @@ -110,13 +110,19 @@ fn test_send_with_string_result_with_basic_auth_ok() { } #[test] -fn test_send_fails_with_network_error() { - let transport = RpcTransport::new("http://127.0.0.1:65535".to_string(), RpcAuth::None, None) +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()); - assert!(matches!(result.unwrap_err(), RpcError::Network(_))); + + let err = result.unwrap_err(); + assert!( + matches!(err, RpcError::NetworkIO(_)), + "Expected NetworkIO error, got: {err:?}" + ); } #[test] @@ -133,10 +139,11 @@ fn test_send_fails_with_http_500() { assert!(result.is_err()); match result { - Err(RpcError::Network(msg)) => { - assert!(msg.contains("500")) + Err(RpcError::NetworkIO(e)) => { + let msg = e.to_string(); + assert!(msg.contains("500"), "Should contain error 500!"); } - _ => panic!("Expected error 500"), + other => panic!("Expected NetworkIO error, got: {other:?}"), } } @@ -155,10 +162,14 @@ fn test_send_fails_with_invalid_json() { assert!(result.is_err()); match result { - Err(RpcError::Network(msg)) => { - assert!(msg.contains("invalid message")) + Err(RpcError::NetworkIO(e)) => { + let msg = e.to_string(); + assert!( + msg.contains("invalid message"), + "Should contain 'invalid message'!" + ) } - _ => panic!("Expected network error"), + other => panic!("Expected NetworkIO error, got: {other:?}"), } } @@ -184,7 +195,7 @@ fn test_send_ok_if_missing_both_result_and_error() { #[test] fn test_send_fails_with_invalid_id() { let response_body = json!({ - "id": "wrong_client_id", + "id": "res_client_id_wrong", "result": true, }); @@ -197,14 +208,14 @@ fn test_send_fails_with_invalid_id() { .create(); let transport = utils::rpc_no_auth(&server); - let result: RpcResult = transport.send("client_id", "dummy", vec![]); + let result: RpcResult = transport.send("req_client_id", "dummy", vec![]); match result { - Err(RpcError::Decode(msg)) => assert_eq!( - "Invalid response: mismatched 'id': expected 'client_id', got 'wrong_client_id'", - msg - ), - _ => panic!("Expected missing result/error error"), + 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:?}"), } } @@ -231,11 +242,11 @@ fn test_send_fails_with_service_error() { let result: RpcResult = transport.send("client_id", "unknown_method", vec![]); match result { - Err(RpcError::Service(msg)) => assert_eq!( - "{\n \"code\": -32601,\n \"message\": \"Method not found\"\n}", - msg - ), - _ => panic!("Expected service error"), + Err(RpcError::Service(err)) => { + assert_eq!(-32601, err.code); + assert_eq!("Method not found", err.message); + } + other => panic!("Expected Service error, got {other:?}"), } } @@ -275,9 +286,10 @@ fn test_send_fails_due_to_timeout() { assert!(result.is_err()); match result.unwrap_err() { - RpcError::Network(msg) => { - assert!(msg.contains("Timed out")); + RpcError::NetworkIO(e) => { + let msg = e.to_string(); + assert!(msg.contains("Timed out"), "Should contain 'Timed out'!"); } - err => panic!("Expected network error, got: {:?}", err), + other => panic!("Expected NetworkIO error, got: {other:?}"), } } diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 2850cbf7fa..e81066ebdd 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -97,7 +97,7 @@ fn test_rpc_call_fails_when_bitcond_with_auth_but_rpc_no_auth() { let err = client.get_blockchain_info().expect_err("Should fail!"); assert!( - matches!(err, BitcoinRpcClientError::Rpc(RpcError::Network(_))), + matches!(err, BitcoinRpcClientError::Rpc(RpcError::NetworkIO(_))), "Expected RpcError::Network, got: {err:?}" ); } @@ -123,7 +123,7 @@ fn test_rpc_call_fails_when_bitcond_no_auth_and_rpc_no_auth() { let err = client.get_blockchain_info().expect_err("Should fail!"); assert!( - matches!(err, BitcoinRpcClientError::Rpc(RpcError::Network(_))), + matches!(err, BitcoinRpcClientError::Rpc(RpcError::NetworkIO(_))), "Expected RpcError::Network, got: {err:?}" ); } @@ -232,8 +232,8 @@ fn test_wallet_creation_fails_if_already_exists() { .expect_err("mywallet1 creation should fail now!"); match &err { - BitcoinRpcClientError::Rpc(RpcError::Network(msg)) => { - assert!(msg.contains("500"), "Bitcoind v25 returns HTTP 500)"); + BitcoinRpcClientError::Rpc(RpcError::NetworkIO(_)) => { + assert!(true, "Bitcoind v25 returns HTTP 500)"); } BitcoinRpcClientError::Rpc(RpcError::Service(_)) => { assert!(true, "Bitcoind v26+ returns HTTP 200"); From 4426fe6a970889b3c0088a508dd3bdab306baf76 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 14:25:05 +0200 Subject: [PATCH 47/62] crc: error shortcut + thiserror, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 12a59e2a89..7fdfd269ff 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -34,7 +34,9 @@ use stacks::types::chainstate::BurnchainHeaderHash; 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; +use stacks_common::deps_common::bitcoin::network::serialize::{ + serialize_hex, Error as bitcoin_serialize_error, +}; use crate::burnchains::rpc::rpc_transport::{RpcAuth, RpcError, RpcTransport}; @@ -326,34 +328,17 @@ pub struct BitcoinRpcClient { } /// Represents errors that can occur when using [`BitcoinRpcClient`]. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum BitcoinRpcClientError { // RPC Transport errors - Rpc(RpcError), + #[error("Rcp error: {0}")] + Rpc(#[from] RpcError), // JSON serialization errors - Serialization(serde_json::Error), + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), // Bitcoin serialization errors - BitcoinSerialization(stacks_common::deps_common::bitcoin::network::serialize::Error), -} - -impl From for BitcoinRpcClientError { - fn from(err: RpcError) -> Self { - BitcoinRpcClientError::Rpc(err) - } -} - -impl From for BitcoinRpcClientError { - fn from(err: serde_json::Error) -> Self { - BitcoinRpcClientError::Serialization(err) - } -} - -impl From - for BitcoinRpcClientError -{ - fn from(err: stacks_common::deps_common::bitcoin::network::serialize::Error) -> Self { - BitcoinRpcClientError::BitcoinSerialization(err) - } + #[error("Bitcoin Serialization error: {0}")] + BitcoinSerialization(#[from] bitcoin_serialize_error), } /// Alias for results returned from client operations. From 791bd3986178fd2a03175da818a8826766b60c4e Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 14:33:38 +0200 Subject: [PATCH 48/62] crc: use const for default max_fee_rate, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 7fdfd269ff..4128b6bc38 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -586,8 +586,9 @@ impl BitcoinRpcClient { 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(0.10); + 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::( From 59a247fdea38dcae9bd525d3f346274048295423 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 15:08:30 +0200 Subject: [PATCH 49/62] crc: remove ssl argument + improve BitcoinRpcClientError, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 23 ++++++++----------- .../rpc/bitcoin_rpc_client/tests.rs | 1 - .../src/tests/bitcoin_rpc_integrations.rs | 3 +-- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 4128b6bc38..90d1eb70e0 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -330,6 +330,9 @@ pub struct BitcoinRpcClient { /// 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), @@ -346,10 +349,9 @@ pub type BitcoinRpcClientResult = Result; impl BitcoinRpcClient { /// Create a [`BitcoinRpcClient`] from Stacks Configuration, mainly using [`stacks::config::BurnchainConfig`] - pub fn from_stx_config(config: &Config) -> Result { + pub fn from_stx_config(config: &Config) -> BitcoinRpcClientResult { let host = config.burnchain.peer_host.clone(); let port = config.burnchain.rpc_port; - let ssl = config.burnchain.rpc_ssl; let username_opt = &config.burnchain.username; let password_opt = &config.burnchain.password; let wallet_name = config.burnchain.wallet_name.clone(); @@ -361,10 +363,10 @@ impl BitcoinRpcClient { username: username.clone(), password: password.clone(), }, - _ => return Err("Missing RPC credentials!".to_string()), + _ => return Err(BitcoinRpcClientError::MissingCredentials), }; - Self::new(host, port, ssl, rpc_auth, wallet_name, timeout, client_id) + 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. @@ -373,7 +375,6 @@ impl BitcoinRpcClient { /// /// * `host` - Hostname or IP address of the Bitcoin RPC server (e.g., `localhost`). /// * `port` - Port number the RPC server is listening on. - /// * `ssl` - If `true`, uses HTTPS for communication; otherwise, uses HTTP. /// * `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. @@ -386,24 +387,20 @@ impl BitcoinRpcClient { pub fn new( host: String, port: u16, - ssl: bool, auth: RpcAuth, wallet_name: String, timeout: u32, client_id: String, - ) -> Result { - let protocol = if ssl { "https" } else { "http" }; - let rpc_global_path = format!("{protocol}://{host}:{port}"); + ) -> 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())) - .map_err(|e| format!("Failed to create global RpcTransport: {e:?}"))?; - let wallet_ep = RpcTransport::new(rpc_wallet_path, rpc_auth, Some(rpc_timeout)) - .map_err(|e| format!("Failed to create wallet RpcTransport: {e:?}"))?; + 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, diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index bcc3d1b030..24891af668 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -44,7 +44,6 @@ mod utils { BitcoinRpcClient::new( parsed.host_str().unwrap().to_string(), parsed.port_or_known_default().unwrap(), - parsed.scheme() == "https", RpcAuth::None, "mywallet".into(), 30, diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index e81066ebdd..d43440d511 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -68,7 +68,6 @@ mod utils { BitcoinRpcClient::new( config.burnchain.peer_host, config.burnchain.rpc_port, - config.burnchain.rpc_ssl, RpcAuth::None, config.burnchain.wallet_name, config.burnchain.timeout, @@ -141,7 +140,7 @@ fn test_client_creation_fails_due_to_stx_config_missing_auth() { let err = BitcoinRpcClient::from_stx_config(&config_no_auth).expect_err("Client should fail!"); - assert_eq!("Missing RPC credentials!", err); + assert!(matches!(err, BitcoinRpcClientError::MissingCredentials)); } #[ignore] From 881e5fd5ce9e4853a49f8f525b27fea4b6529c21 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 11 Aug 2025 16:42:49 +0200 Subject: [PATCH 50/62] crc: add utxo address field and improve test scenarios, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 28 +++++ .../rpc/bitcoin_rpc_client/tests.rs | 3 + .../src/tests/bitcoin_rpc_integrations.rs | 100 ++++++++++++++++-- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 90d1eb70e0..44b4a29fd8 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -31,6 +31,7 @@ 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; @@ -144,6 +145,9 @@ pub struct ListUnspentResponse { 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", @@ -168,6 +172,30 @@ where 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)?; + if addr_str.starts_with("bcrt") { + //Currently BitcoinAddress doesn't manage Regtest HRP + return Err(serde::de::Error::custom( + "BitcoinAddress cannot manage Regtest HRP ('bcrt')", + )); + } + + if let Some(addr) = BitcoinAddress::from_string(&addr_str) { + Ok(addr) + } else { + Err(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 diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 24891af668..9f566ab484 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -290,6 +290,7 @@ fn test_list_wallets_ok() { 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", @@ -312,6 +313,7 @@ fn test_list_unspent_ok() { "result": [{ "txid": expected_txid_str, "vout": 0, + "address": expected_address, "scriptPubKey": expected_script_hex, "amount": 0.00001, "confirmations": 6 @@ -347,6 +349,7 @@ fn test_list_unspent_ok() { 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),); diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index d43440d511..5265a3ae3f 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -322,7 +322,7 @@ fn test_generate_to_address_ok() { #[ignore] #[test] -fn test_list_unspent_ok() { +fn test_list_unspent_one_address_ok() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -341,24 +341,104 @@ fn test_list_unspent_ok() { .get_new_address(None, Some(AddressType::Legacy)) .expect("Should work!"); - let utxos = client + let no_utxos = client .list_unspent(None, None, None, Some(false), Some(1), Some(10)) - .expect("list_unspent should be ok!"); - assert_eq!(0, utxos.len()); + .expect("list_unspent empty should be ok!"); + assert_eq!(0, no_utxos.len()); _ = client .generate_to_address(102, &address) .expect("generate to address ok!"); - let utxos = client + let all_utxos = client .list_unspent(None, None, None, Some(false), Some(1), Some(10)) - .expect("list_unspent should be ok!"); - assert_eq!(2, utxos.len()); + .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 utxos = client + let max1_utxos = client .list_unspent(None, None, None, Some(false), Some(1), Some(1)) - .expect("list_unspent should be ok!"); - assert_eq!(1, utxos.len()); + .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] From 16ee5fdcdf524d90cc1b39a518ac099f41481199 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 12 Aug 2025 08:45:53 +0200 Subject: [PATCH 51/62] crc: fix typo in test assertion, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs index 9f566ab484..4c3cc691b7 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/tests.rs @@ -1016,7 +1016,7 @@ pub fn test_convert_btc_to_sat() { 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 1_000_000_000 ok!"); + 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!"); } From ebf676b8dd074b12077fb773ca20577f4661aba7 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 12 Aug 2025 09:12:07 +0200 Subject: [PATCH 52/62] crc: fix typo in RPC_VERSION const, #6250 --- stacks-node/src/burnchains/rpc/rpc_transport/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs index d3bebd5a6e..839ab668f5 100644 --- a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs @@ -35,7 +35,7 @@ mod tests; /// The JSON-RPC protocol version used in all requests. /// Latest specification is `2.0` -const RCP_VERSION: &str = "2.0"; +const RPC_VERSION: &str = "2.0"; /// Represents a JSON-RPC request payload sent to the server. #[derive(Serialize)] @@ -190,7 +190,7 @@ impl RpcTransport { params: Vec, ) -> RpcResult { let payload = JsonRpcRequest { - jsonrpc: RCP_VERSION.to_string(), + jsonrpc: RPC_VERSION.to_string(), id: id.to_string(), method: method.to_string(), params: Value::Array(params), From 68f3044857fcdbc1454dbfcac200488c053b09b8 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Tue, 12 Aug 2025 18:07:48 +0200 Subject: [PATCH 53/62] crc: fix list_unspent maximum_count unlimited default, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 4 +- .../src/tests/bitcoin_rpc_integrations.rs | 63 +++++++++++++++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 44b4a29fd8..258662d6e1 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -491,7 +491,7 @@ impl BitcoinRpcClient { /// * `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: 9.999.999). + /// * `maximum_count` - Maximum number of UTXOs to return. Use `None` for effectively 'unlimited' (Default: 0). /// /// # Returns /// A Vec<[`ListUnspentResponse`]> containing the matching UTXOs. @@ -513,7 +513,7 @@ impl BitcoinRpcClient { 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(9_999_999); + 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); diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index 5265a3ae3f..dfd03f5eab 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -322,7 +322,31 @@ fn test_generate_to_address_ok() { #[ignore] #[test] -fn test_list_unspent_one_address_ok() { +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; } @@ -337,14 +361,41 @@ fn test_list_unspent_one_address_ok() { 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 no_utxos = client - .list_unspent(None, None, None, Some(false), Some(1), Some(10)) - .expect("list_unspent empty should be ok!"); - assert_eq!(0, no_utxos.len()); + _ = 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) @@ -395,10 +446,10 @@ fn test_list_unspent_two_addresses_ok() { 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!"); From 62260f6a24423e37390e43f71857d8d2e903808d Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 13 Aug 2025 16:15:02 +0200 Subject: [PATCH 54/62] crc: rpc error tag in doc, #6250 --- stacks-node/src/burnchains/rpc/rpc_transport/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs index 839ab668f5..016d94ceb9 100644 --- a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs @@ -182,7 +182,7 @@ impl RpcTransport { /// # Returns /// /// Returns `RpcResult`, which is a result containing either the successfully deserialized response of type `T` - /// or an `RpcError` otherwise + /// or an [`RpcError`] otherwise pub fn send Deserialize<'de>>( &self, id: &str, From 98a66f4a8eb51dabf4670cae84303660fb45ad22 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 13 Aug 2025 16:15:44 +0200 Subject: [PATCH 55/62] crc: rpc transport tag in doc, #6250 --- stacks-node/src/burnchains/rpc/rpc_transport/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs index 016d94ceb9..e8462b189f 100644 --- a/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs +++ b/stacks-node/src/burnchains/rpc/rpc_transport/mod.rs @@ -111,7 +111,7 @@ pub enum RpcError { UrlParse(#[from] url::ParseError), } -/// Alias for results returned from RPC operations using `RpcTransport`. +/// Alias for results returned from RPC operations using [`RpcTransport`]. pub type RpcResult = Result; /// Represents supported authentication mechanisms for RPC requests. From 284577e6b5d85a7df83755bcfdd02970986198cf Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 13 Aug 2025 16:19:24 +0200 Subject: [PATCH 56/62] crc: BitcoinRcpClient::new comment, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 258662d6e1..7fdd700ba7 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -410,8 +410,7 @@ impl BitcoinRpcClient { /// /// # Returns /// - /// Returns `Ok(Self)` if both global and wallet RPC transports are successfully created, - /// or `Err(String)` if the underlying HTTP client setup fails.Stacks Configuration, mainly using `BurnchainConfig` + /// A [`BitcoinRpcClient`] on success, or a [`BitcoinRpcClientError`] otherwise. pub fn new( host: String, port: u16, From 062f83ab0ef05f4d4b4feecaf16f04288f023ce4 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 13 Aug 2025 16:21:45 +0200 Subject: [PATCH 57/62] crc: list_unspent availability doc, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 7fdd700ba7..347bc68e98 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -495,6 +495,9 @@ impl BitcoinRpcClient { /// # 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. From 46ce0e4a69bd29663d60d4396952e065ec4a2875 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 13 Aug 2025 16:26:21 +0200 Subject: [PATCH 58/62] crc: get_descriptor_info doc nit, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 347bc68e98..87b9db43bc 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -632,7 +632,7 @@ impl BitcoinRpcClient { /// * `descriptor` - The descriptor string to analyze. /// /// # Returns - /// A `DescriptorInfoResponse` containing parsed descriptor information such as the checksum. + /// A [`DescriptorInfoResponse`] containing parsed descriptor information such as the checksum. /// /// # Availability /// - **Since**: Bitcoin Core **v0.18.0**. From ec2db9059c57c50393f1f9c0e3dc9097425046ad Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 13 Aug 2025 16:27:52 +0200 Subject: [PATCH 59/62] crc: import_descriptors doc nit, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 87b9db43bc..7181a54621 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -650,7 +650,7 @@ impl BitcoinRpcClient { /// Imports one or more descriptors into the currently loaded wallet. /// /// # Arguments - /// * `descriptors` – A slice of `ImportDescriptorsRequest` items. Each item defines a single + /// * `descriptors` – A slice of [`ImportDescriptorsRequest`] items. Each item defines a single /// descriptor and optional metadata for how it should be imported. /// /// # Returns From 2a95955d215798a1373cfd47ff41d288db64020a Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 14 Aug 2025 12:28:15 +0200 Subject: [PATCH 60/62] crc: import_descriptors response doc nit, #6250 --- stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 7181a54621..4d62549d0e 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -654,7 +654,7 @@ impl BitcoinRpcClient { /// descriptor and optional metadata for how it should be imported. /// /// # Returns - /// A vector of `ImportDescriptorsResponse` results, one for each descriptor import attempt. + /// A vector of [`ImportDescriptorsResponse`] results, one for each descriptor import attempt. /// /// # Availability /// - **Since**: Bitcoin Core **v0.21.0**. From 2fd677b6dcb78c7e0661f701e81376d3451bd35a Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 14 Aug 2025 12:55:17 +0200 Subject: [PATCH 61/62] refactor: using new BitcoinAddress::from_string supporting regtest hrp, #6250 --- .../burnchains/rpc/bitcoin_rpc_client/mod.rs | 7 ----- .../rpc/bitcoin_rpc_client/test_utils.rs | 7 ----- .../src/tests/bitcoin_rpc_integrations.rs | 31 ++++++++++--------- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 4d62549d0e..8df58e0f50 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -180,13 +180,6 @@ where D: Deserializer<'de>, { let addr_str: String = Deserialize::deserialize(deserializer)?; - if addr_str.starts_with("bcrt") { - //Currently BitcoinAddress doesn't manage Regtest HRP - return Err(serde::de::Error::custom( - "BitcoinAddress cannot manage Regtest HRP ('bcrt')", - )); - } - if let Some(addr) = BitcoinAddress::from_string(&addr_str) { Ok(addr) } else { 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 index 5f825547e4..e324abebbd 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -123,13 +123,6 @@ impl<'de> Deserialize<'de> for GetNewAddressResponse { D: Deserializer<'de>, { let addr_str: String = Deserialize::deserialize(deserializer)?; - if addr_str.starts_with("bcrt") { - //Currently BitcoinAddress doesn't manage Regtest HRP - return Err(serde::de::Error::custom( - "BitcoinAddress cannot manage Regtest HRP ('bcrt')", - )); - } - if let Some(addr) = BitcoinAddress::from_string(&addr_str) { Ok(GetNewAddressResponse(addr)) } else { diff --git a/stacks-node/src/tests/bitcoin_rpc_integrations.rs b/stacks-node/src/tests/bitcoin_rpc_integrations.rs index dfd03f5eab..2f6801b53d 100644 --- a/stacks-node/src/tests/bitcoin_rpc_integrations.rs +++ b/stacks-node/src/tests/bitcoin_rpc_integrations.rs @@ -259,16 +259,16 @@ fn test_get_new_address_for_each_address_type() { let client = BitcoinRpcClient::from_stx_config(&config).expect("Client creation ok!"); client.create_wallet("my_wallet", Some(false)).expect("OK"); - //Check Legacy type OK - let legacy = client + // Check Legacy p2pkh type OK + let p2pkh = client .get_new_address(None, Some(AddressType::Legacy)) - .expect("legacy address ok!"); + .expect("p2pkh address ok!"); assert_eq!( LegacyBitcoinAddressType::PublicKeyHash, - legacy.expect_legacy().addrtype + p2pkh.expect_legacy().addrtype ); - //Check Legacy p2sh type OK + // Check Legacy p2sh type OK let p2sh = client .get_new_address(None, Some(AddressType::P2shSegwit)) .expect("p2sh address ok!"); @@ -277,20 +277,23 @@ fn test_get_new_address_for_each_address_type() { p2sh.expect_legacy().addrtype ); - //Bech32 currently failing due to BitcoinAddress not supporting Regtest HRP - client + // Check Bech32 p2wpkh OK + let p2wpkh = client .get_new_address(None, Some(AddressType::Bech32)) - .expect_err("bech32 should fail!"); + .expect("p2wpkh address ok!"); + assert!(p2wpkh.expect_segwit().is_p2wpkh()); - //Bech32m currently failing due to BitcoinAddress not supporting Regtest HRP - client + // Check Bech32m p2tr OK + let p2tr = client .get_new_address(None, Some(AddressType::Bech32m)) - .expect_err("bech32m should fail!"); + .expect("p2tr address ok!"); + assert!(p2tr.expect_segwit().is_p2tr()); - //None defaults to bech32 so fails as well - client + // Check default to be bech32 p2wpkh + let default = client .get_new_address(None, None) - .expect_err("default (bech32) should fail!"); + .expect("default address ok!"); + assert!(default.expect_segwit().is_p2wpkh()); } #[ignore] From cff3c9bd5f60df3458e76addcfaa1bd2b3fedf85 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Thu, 14 Aug 2025 13:06:27 +0200 Subject: [PATCH 62/62] refactor: remove duplication related to BitcoinAddress deserialization, #6250 --- .../src/burnchains/rpc/bitcoin_rpc_client/mod.rs | 10 +++------- .../burnchains/rpc/bitcoin_rpc_client/test_utils.rs | 13 +++---------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs index 8df58e0f50..e4c9e644b1 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/mod.rs @@ -180,13 +180,9 @@ where D: Deserializer<'de>, { let addr_str: String = Deserialize::deserialize(deserializer)?; - if let Some(addr) = BitcoinAddress::from_string(&addr_str) { - Ok(addr) - } else { - Err(serde::de::Error::custom( - "BitcoinAddress failed to create from string", - )) - } + BitcoinAddress::from_string(&addr_str).ok_or(serde::de::Error::custom( + "BitcoinAddress failed to create from string", + )) } /// Deserializes a JSON string into [`Script`] 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 index e324abebbd..af9cca9c66 100644 --- a/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs +++ b/stacks-node/src/burnchains/rpc/bitcoin_rpc_client/test_utils.rs @@ -21,12 +21,12 @@ 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::blockdata::transaction::Transaction; use stacks_common::deps_common::bitcoin::network::serialize::deserialize_hex; use crate::burnchains::rpc::bitcoin_rpc_client::{ - BitcoinRpcClient, BitcoinRpcClientResult, TxidWrapperResponse, + deserialize_string_to_bitcoin_address, BitcoinRpcClient, BitcoinRpcClientResult, + TxidWrapperResponse, }; /// Represents the response returned by the `getblockchaininfo` RPC call. @@ -122,14 +122,7 @@ impl<'de> Deserialize<'de> for GetNewAddressResponse { where D: Deserializer<'de>, { - let addr_str: String = Deserialize::deserialize(deserializer)?; - if let Some(addr) = BitcoinAddress::from_string(&addr_str) { - Ok(GetNewAddressResponse(addr)) - } else { - Err(serde::de::Error::custom( - "BitcoinAddress failed to create from string", - )) - } + deserialize_string_to_bitcoin_address(deserializer).map(GetNewAddressResponse) } }