From 090a6dac83f868dcda5a29de785d52812fc2fda9 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 10 Sep 2025 11:11:18 +0200 Subject: [PATCH 01/15] chore: move BitcoinRPCRequest::list_unspent in BitcoinRegtestController, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 118 +++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 1e28b1ff1bd..64ce5e828d5 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -794,7 +794,7 @@ impl BitcoinRegtestController { let filter_addresses = vec![address.to_string()]; let mut utxos = loop { - let result = BitcoinRPCRequest::list_unspent( + let result = Self::list_unspent( &self.config, filter_addresses.clone(), false, @@ -831,7 +831,7 @@ impl BitcoinRegtestController { sleep_ms(1000); } - let result = BitcoinRPCRequest::list_unspent( + let result = Self::list_unspent( &self.config, filter_addresses.clone(), false, @@ -2249,6 +2249,120 @@ impl BitcoinRegtestController { } Ok(()) } + + pub fn list_unspent( + config: &Config, + addresses: Vec, + include_unsafe: bool, + minimum_sum_amount: u64, + utxos_to_exclude: &Option, + block_height: u64, + ) -> RPCResult { + let payload = BitcoinRPCRequest { + method: "getblockhash".to_string(), + params: vec![block_height.into()], + id: "stacks".to_string(), + jsonrpc: "2.0".to_string(), + }; + + let mut res = BitcoinRPCRequest::send(config, payload)?; + let Some(res) = res.as_object_mut() else { + return Err(RPCError::Parsing("Failed to get UTXOs".to_string())); + }; + let res = res + .get("result") + .ok_or(RPCError::Parsing("Failed to get bestblockhash".to_string()))?; + let bhh_string: String = serde_json::from_value(res.to_owned()) + .map_err(|_| RPCError::Parsing("Failed to get bestblockhash".to_string()))?; + let bhh = BurnchainHeaderHash::from_hex(&bhh_string) + .map_err(|_| RPCError::Parsing("Failed to get bestblockhash".to_string()))?; + let min_conf = 0i64; + let max_conf = 9999999i64; + let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_sum_amount); + + let payload = BitcoinRPCRequest { + method: "listunspent".to_string(), + params: vec![ + min_conf.into(), + max_conf.into(), + addresses.into(), + include_unsafe.into(), + json!({ "minimumAmount": minimum_amount, "maximumCount": config.burnchain.max_unspent_utxos }), + ], + id: "stacks".to_string(), + jsonrpc: "2.0".to_string(), + }; + + let mut res = BitcoinRPCRequest::send(config, payload)?; + let txids_to_filter = if let Some(utxos_to_exclude) = utxos_to_exclude { + utxos_to_exclude + .utxos + .iter() + .map(|utxo| utxo.txid.clone()) + .collect::>() + } else { + vec![] + }; + + let mut utxos = vec![]; + + match res.as_object_mut() { + Some(ref mut object) => match object.get_mut("result") { + Some(serde_json::Value::Array(entries)) => { + while let Some(entry) = entries.pop() { + let parsed_utxo: ParsedUTXO = match serde_json::from_value(entry) { + Ok(utxo) => utxo, + Err(err) => { + warn!("Failed parsing UTXO: {err}"); + continue; + } + }; + let amount = match parsed_utxo.get_sat_amount() { + Some(amount) => amount, + None => continue, + }; + + if amount < minimum_sum_amount { + continue; + } + + let script_pub_key = match parsed_utxo.get_script_pub_key() { + Some(script_pub_key) => script_pub_key, + None => { + continue; + } + }; + + let txid = match parsed_utxo.get_txid() { + Some(amount) => amount, + None => continue, + }; + + // Exclude UTXOs that we want to filter + if txids_to_filter.contains(&txid) { + continue; + } + + utxos.push(UTXO { + txid, + vout: parsed_utxo.vout, + script_pub_key, + amount, + confirmations: parsed_utxo.confirmations, + }); + } + } + _ => { + warn!("Failed to get UTXOs"); + } + }, + _ => { + warn!("Failed to get UTXOs"); + } + }; + + Ok(UTXOSet { bhh, utxos }) + } } impl BurnchainController for BitcoinRegtestController { From 45f97fd48cf5c95ac68be0625d7001e079681286 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 10 Sep 2025 12:00:10 +0200 Subject: [PATCH 02/15] chore: move listunspent_max_utxos test from neon_integration to BitcoinRegtestController tests, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 55 ++++++++++++++++--- stacks-node/src/tests/neon_integrations.rs | 50 +---------------- 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 64ce5e828d5..b26c592a1e6 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -794,8 +794,7 @@ impl BitcoinRegtestController { let filter_addresses = vec![address.to_string()]; let mut utxos = loop { - let result = Self::list_unspent( - &self.config, + let result = self.list_unspent( filter_addresses.clone(), false, total_required, @@ -831,8 +830,7 @@ impl BitcoinRegtestController { sleep_ms(1000); } - let result = Self::list_unspent( - &self.config, + let result = self.list_unspent( filter_addresses.clone(), false, total_required, @@ -2251,7 +2249,7 @@ impl BitcoinRegtestController { } pub fn list_unspent( - config: &Config, + &self, addresses: Vec, include_unsafe: bool, minimum_sum_amount: u64, @@ -2265,7 +2263,7 @@ impl BitcoinRegtestController { jsonrpc: "2.0".to_string(), }; - let mut res = BitcoinRPCRequest::send(config, payload)?; + let mut res = BitcoinRPCRequest::send(&self.config, payload)?; let Some(res) = res.as_object_mut() else { return Err(RPCError::Parsing("Failed to get UTXOs".to_string())); }; @@ -2287,13 +2285,13 @@ impl BitcoinRegtestController { max_conf.into(), addresses.into(), include_unsafe.into(), - json!({ "minimumAmount": minimum_amount, "maximumCount": config.burnchain.max_unspent_utxos }), + json!({ "minimumAmount": minimum_amount, "maximumCount": &self.config.burnchain.max_unspent_utxos }), ], id: "stacks".to_string(), jsonrpc: "2.0".to_string(), }; - let mut res = BitcoinRPCRequest::send(config, payload)?; + let mut res = BitcoinRPCRequest::send(&self.config, payload)?; let txids_to_filter = if let Some(utxos_to_exclude) = utxos_to_exclude { utxos_to_exclude .utxos @@ -2816,6 +2814,7 @@ mod tests { use super::*; use crate::burnchains::bitcoin::core_controller::BitcoinCoreController; + use crate::burnchains::bitcoin_regtest_controller::tests::utils::to_address_legacy; use crate::Keychain; mod utils { @@ -2873,6 +2872,16 @@ mod tests { create_keychain_with_seed(2).get_pub_key() } + pub fn to_address_legacy(pub_key: &Secp256k1PublicKey) -> BitcoinAddress { + let hash160 = Hash160::from_data(&pub_key.to_bytes()); + BitcoinAddress::from_bytes_legacy( + BitcoinNetworkType::Regtest, + LegacyBitcoinAddressType::PublicKeyHash, + &hash160.0, + ) + .expect("Public key incorrect") + } + pub fn mine_tx(btc_controller: &BitcoinRegtestController, tx: &Transaction) { btc_controller .send_transaction(tx) @@ -3273,6 +3282,36 @@ mod tests { assert_eq!("mywallet".to_owned(), wallets[0]); } + #[test] + #[ignore] + fn test_list_unspent_with_max_utxos_config() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let miner_pubkey = utils::create_miner1_pubkey(); + + let mut config = utils::create_config(); + config.burnchain.local_mining_public_key = Some(miner_pubkey.to_hex()); + + config.burnchain.max_rbf = 1000000; + config.burnchain.max_unspent_utxos = Some(10); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(&config); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + btc_controller.bootstrap_chain(201); //produces 100 spendable utxos + + let address = to_address_legacy(&miner_pubkey); + let filter_addresses = vec![address.to_string()]; + let utxos = btc_controller.list_unspent(filter_addresses, false, 1, &None, 0) + .expect("Failed to get utxos"); + assert_eq!(10, utxos.num_utxos()); + } + #[test] #[ignore] fn test_get_all_utxos_with_confirmation() { diff --git a/stacks-node/src/tests/neon_integrations.rs b/stacks-node/src/tests/neon_integrations.rs index 2236df72a9d..6dc7ef768cb 100644 --- a/stacks-node/src/tests/neon_integrations.rs +++ b/stacks-node/src/tests/neon_integrations.rs @@ -67,7 +67,6 @@ use stacks::net::atlas::{ AtlasConfig, AtlasDB, GetAttachmentResponse, GetAttachmentsInvResponse, MAX_ATTACHMENT_INV_PAGES_PER_REQUEST, }; -use stacks::types::PublicKey; use stacks::util_lib::boot::{boot_code_addr, boot_code_id}; use stacks::util_lib::db::{query_row_columns, query_rows, u64_to_sql}; use stacks::util_lib::signed_structured_data::pox4::{ @@ -87,7 +86,7 @@ use tokio::net::{TcpListener, TcpStream}; use super::{ADDR_4, SK_1, SK_2, SK_3}; use crate::burnchains::bitcoin::core_controller::BitcoinCoreController; -use crate::burnchains::bitcoin_regtest_controller::{self, BitcoinRPCRequest, UTXO}; +use crate::burnchains::bitcoin_regtest_controller::{self, UTXO}; use crate::neon_node::RelayerThread; use crate::operations::BurnchainOpSigner; use crate::stacks_common::types::PrivateKey; @@ -9965,50 +9964,3 @@ fn mock_miner_replay() { miner_channel.stop_chains_coordinator(); follower_channel.stop_chains_coordinator(); } - -#[test] -#[ignore] -/// Verify that the config option, `burnchain.max_unspent_utxos`, is respected. -fn listunspent_max_utxos() { - if env::var("BITCOIND_TEST") != Ok("1".into()) { - return; - } - - let (mut conf, _miner_account) = neon_integration_test_conf(); - let prom_port = gen_random_port(); - let localhost = "127.0.0.1"; - let prom_bind = format!("{localhost}:{prom_port}"); - conf.node.prometheus_bind = Some(prom_bind); - - conf.burnchain.max_rbf = 1000000; - conf.burnchain.max_unspent_utxos = Some(10); - - let mut btcd_controller = BitcoinCoreController::from_stx_config(&conf); - btcd_controller - .start_bitcoind() - .expect("Failed starting bitcoind"); - - let btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None); - - btc_regtest_controller.bootstrap_chain(201); - - eprintln!("Chain bootstrapped..."); - - let keychain = Keychain::default(conf.node.seed.clone()); - let mut op_signer = keychain.generate_op_signer(); - - let (_, network_id) = conf.burnchain.get_bitcoin_network(); - let hash160 = Hash160::from_data(&op_signer.get_public_key().to_bytes()); - let address = BitcoinAddress::from_bytes_legacy( - network_id, - LegacyBitcoinAddressType::PublicKeyHash, - &hash160.0, - ) - .expect("Public key incorrect"); - - let filter_addresses = vec![address.to_string()]; - - let res = BitcoinRPCRequest::list_unspent(&conf, filter_addresses, false, 1, &None, 0); - let utxos = res.expect("Failed to get utxos"); - assert_eq!(utxos.num_utxos(), 10); -} From 7b6fbc629c0d3c1656a726a8c81823f7bccc127c Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 10 Sep 2025 12:03:22 +0200 Subject: [PATCH 03/15] chore: remove unused BitcoinRPCRequest::list_unspent, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 117 +----------------- 1 file changed, 2 insertions(+), 115 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index b26c592a1e6..c95e90ceef0 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -2669,120 +2669,6 @@ impl BitcoinRPCRequest { request } - pub fn list_unspent( - config: &Config, - addresses: Vec, - include_unsafe: bool, - minimum_sum_amount: u64, - utxos_to_exclude: &Option, - block_height: u64, - ) -> RPCResult { - let payload = BitcoinRPCRequest { - method: "getblockhash".to_string(), - params: vec![block_height.into()], - id: "stacks".to_string(), - jsonrpc: "2.0".to_string(), - }; - - let mut res = BitcoinRPCRequest::send(config, payload)?; - let Some(res) = res.as_object_mut() else { - return Err(RPCError::Parsing("Failed to get UTXOs".to_string())); - }; - let res = res - .get("result") - .ok_or(RPCError::Parsing("Failed to get bestblockhash".to_string()))?; - let bhh_string: String = serde_json::from_value(res.to_owned()) - .map_err(|_| RPCError::Parsing("Failed to get bestblockhash".to_string()))?; - let bhh = BurnchainHeaderHash::from_hex(&bhh_string) - .map_err(|_| RPCError::Parsing("Failed to get bestblockhash".to_string()))?; - let min_conf = 0i64; - let max_conf = 9999999i64; - let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_sum_amount); - - let payload = BitcoinRPCRequest { - method: "listunspent".to_string(), - params: vec![ - min_conf.into(), - max_conf.into(), - addresses.into(), - include_unsafe.into(), - json!({ "minimumAmount": minimum_amount, "maximumCount": config.burnchain.max_unspent_utxos }), - ], - id: "stacks".to_string(), - jsonrpc: "2.0".to_string(), - }; - - let mut res = BitcoinRPCRequest::send(config, payload)?; - let txids_to_filter = if let Some(utxos_to_exclude) = utxos_to_exclude { - utxos_to_exclude - .utxos - .iter() - .map(|utxo| utxo.txid.clone()) - .collect::>() - } else { - vec![] - }; - - let mut utxos = vec![]; - - match res.as_object_mut() { - Some(ref mut object) => match object.get_mut("result") { - Some(serde_json::Value::Array(entries)) => { - while let Some(entry) = entries.pop() { - let parsed_utxo: ParsedUTXO = match serde_json::from_value(entry) { - Ok(utxo) => utxo, - Err(err) => { - warn!("Failed parsing UTXO: {err}"); - continue; - } - }; - let amount = match parsed_utxo.get_sat_amount() { - Some(amount) => amount, - None => continue, - }; - - if amount < minimum_sum_amount { - continue; - } - - let script_pub_key = match parsed_utxo.get_script_pub_key() { - Some(script_pub_key) => script_pub_key, - None => { - continue; - } - }; - - let txid = match parsed_utxo.get_txid() { - Some(amount) => amount, - None => continue, - }; - - // Exclude UTXOs that we want to filter - if txids_to_filter.contains(&txid) { - continue; - } - - utxos.push(UTXO { - txid, - vout: parsed_utxo.vout, - script_pub_key, - amount, - confirmations: parsed_utxo.confirmations, - }); - } - } - _ => { - warn!("Failed to get UTXOs"); - } - }, - _ => { - warn!("Failed to get UTXOs"); - } - }; - - Ok(UTXOSet { bhh, utxos }) - } - pub fn send(config: &Config, payload: BitcoinRPCRequest) -> RPCResult { let request = BitcoinRPCRequest::build_rpc_request(config, &payload); let timeout = Duration::from_secs(u64::from(config.burnchain.timeout)); @@ -3307,7 +3193,8 @@ mod tests { let address = to_address_legacy(&miner_pubkey); let filter_addresses = vec![address.to_string()]; - let utxos = btc_controller.list_unspent(filter_addresses, false, 1, &None, 0) + let utxos = btc_controller + .list_unspent(filter_addresses, false, 1, &None, 0) .expect("Failed to get utxos"); assert_eq!(10, utxos.num_utxos()); } From 71a86382307b1a9505ea6cb9c6c175cae11b53e8 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 10 Sep 2025 17:15:24 +0200 Subject: [PATCH 04/15] refactor: convert list_unspent to use new rpc, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 210 +++++++++--------- 1 file changed, 108 insertions(+), 102 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index c95e90ceef0..3f3dc2d32d2 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -791,11 +791,10 @@ impl BitcoinRegtestController { // Configure UTXO filter let address = self.get_miner_address(epoch_id, &pubk); test_debug!("Get UTXOs for {} ({address})", pubk.to_hex()); - let filter_addresses = vec![address.to_string()]; let mut utxos = loop { let result = self.list_unspent( - filter_addresses.clone(), + &address, false, total_required, &utxos_to_exclude, @@ -831,7 +830,7 @@ impl BitcoinRegtestController { } let result = self.list_unspent( - filter_addresses.clone(), + &address, false, total_required, &utxos_to_exclude, @@ -847,7 +846,7 @@ impl BitcoinRegtestController { } }; - test_debug!("Unspent for {filter_addresses:?}: {utxos:?}"); + test_debug!("Unspent for {address:?}: {utxos:?}"); if utxos.is_empty() { return None; @@ -856,7 +855,7 @@ impl BitcoinRegtestController { } } } else { - debug!("Got {} UTXOs for {filter_addresses:?}", utxos.utxos.len(),); + debug!("Got {} UTXOs for {address:?}", utxos.utxos.len(),); utxos }; @@ -2248,50 +2247,41 @@ impl BitcoinRegtestController { Ok(()) } + pub fn to_bitcoin_tx_hash(txid: &Txid) -> Sha256dHash { + let mut txid_bytes = txid.0; + txid_bytes.reverse(); + Sha256dHash(txid_bytes) + } + pub fn list_unspent( &self, - addresses: Vec, + addresses: &BitcoinAddress, include_unsafe: bool, minimum_sum_amount: u64, utxos_to_exclude: &Option, block_height: u64, ) -> RPCResult { - let payload = BitcoinRPCRequest { - method: "getblockhash".to_string(), - params: vec![block_height.into()], - id: "stacks".to_string(), - jsonrpc: "2.0".to_string(), - }; - - let mut res = BitcoinRPCRequest::send(&self.config, payload)?; - let Some(res) = res.as_object_mut() else { - return Err(RPCError::Parsing("Failed to get UTXOs".to_string())); - }; - let res = res - .get("result") - .ok_or(RPCError::Parsing("Failed to get bestblockhash".to_string()))?; - let bhh_string: String = serde_json::from_value(res.to_owned()) - .map_err(|_| RPCError::Parsing("Failed to get bestblockhash".to_string()))?; - let bhh = BurnchainHeaderHash::from_hex(&bhh_string) - .map_err(|_| RPCError::Parsing("Failed to get bestblockhash".to_string()))?; - let min_conf = 0i64; - let max_conf = 9999999i64; - let minimum_amount = ParsedUTXO::sat_to_serialized_btc(minimum_sum_amount); + let bhh = self + .rpc_client + .get_block_hash(block_height) + .expect("TEMPORARY: Failed to get bestblockhash"); + //todo: add error log?! + let min_conf = 0; + let max_conf = 9999999; - let payload = BitcoinRPCRequest { - method: "listunspent".to_string(), - params: vec![ - min_conf.into(), - max_conf.into(), - addresses.into(), - include_unsafe.into(), - json!({ "minimumAmount": minimum_amount, "maximumCount": &self.config.burnchain.max_unspent_utxos }), - ], - id: "stacks".to_string(), - jsonrpc: "2.0".to_string(), - }; + let result = self + .rpc_client + .list_unspent( + &self.get_wallet_name(), + Some(min_conf), + Some(max_conf), + Some(&[addresses]), + Some(include_unsafe), + Some(minimum_sum_amount), + self.config.burnchain.max_unspent_utxos.clone(), + ) + .expect("TEMPORARY: failed list_unspent!"); - let mut res = BitcoinRPCRequest::send(&self.config, payload)?; let txids_to_filter = if let Some(utxos_to_exclude) = utxos_to_exclude { utxos_to_exclude .utxos @@ -2302,63 +2292,18 @@ impl BitcoinRegtestController { vec![] }; - let mut utxos = vec![]; - - match res.as_object_mut() { - Some(ref mut object) => match object.get_mut("result") { - Some(serde_json::Value::Array(entries)) => { - while let Some(entry) = entries.pop() { - let parsed_utxo: ParsedUTXO = match serde_json::from_value(entry) { - Ok(utxo) => utxo, - Err(err) => { - warn!("Failed parsing UTXO: {err}"); - continue; - } - }; - let amount = match parsed_utxo.get_sat_amount() { - Some(amount) => amount, - None => continue, - }; - - if amount < minimum_sum_amount { - continue; - } - - let script_pub_key = match parsed_utxo.get_script_pub_key() { - Some(script_pub_key) => script_pub_key, - None => { - continue; - } - }; - - let txid = match parsed_utxo.get_txid() { - Some(amount) => amount, - None => continue, - }; - - // Exclude UTXOs that we want to filter - if txids_to_filter.contains(&txid) { - continue; - } - - utxos.push(UTXO { - txid, - vout: parsed_utxo.vout, - script_pub_key, - amount, - confirmations: parsed_utxo.confirmations, - }); - } - } - _ => { - warn!("Failed to get UTXOs"); - } - }, - _ => { - warn!("Failed to get UTXOs"); - } - }; - + let utxos = result + .into_iter() + .filter(|each| !txids_to_filter.contains(&Self::to_bitcoin_tx_hash(&each.txid))) + .filter(|each| each.amount >= minimum_sum_amount) + .map(|each| UTXO { + txid: Self::to_bitcoin_tx_hash(&each.txid), + vout: each.vout, + script_pub_key: each.script_pub_key, + amount: each.amount, + confirmations: each.confirmations, + }) + .collect::>(); Ok(UTXOSet { bhh, utxos }) } } @@ -3170,7 +3115,7 @@ mod tests { #[test] #[ignore] - fn test_list_unspent_with_max_utxos_config() { + fn test_list_unspent_all() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -3180,7 +3125,69 @@ mod tests { let mut config = utils::create_config(); config.burnchain.local_mining_public_key = Some(miner_pubkey.to_hex()); - config.burnchain.max_rbf = 1000000; + let mut btcd_controller = BitcoinCoreController::from_stx_config(&config); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + btc_controller.bootstrap_chain(150); //produces 50 spendable utxos + + let address = to_address_legacy(&miner_pubkey); + let utxos = btc_controller + .list_unspent(&address, false, 0, &None, 0) + .expect("Failed to get utxos"); + assert_eq!(50, utxos.num_utxos()); + } + + #[test] + #[ignore] + fn test_list_unspent_excluding_some_utxo() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let miner_pubkey = utils::create_miner1_pubkey(); + + let mut config = utils::create_config(); + config.burnchain.local_mining_public_key = Some(miner_pubkey.to_hex()); + + let mut btcd_controller = BitcoinCoreController::from_stx_config(&config); + btcd_controller + .start_bitcoind() + .expect("Failed starting bitcoind"); + + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + btc_controller.bootstrap_chain(150); //produces 50 spendable utxos + + let address = to_address_legacy(&miner_pubkey); + let mut all_utxos = btc_controller + .list_unspent(&address, false, 0, &None, 0) + .expect("Failed to get utxos (50)"); + + let filtered_utxos = btc_controller + .list_unspent(&address, false, 0, &Some(all_utxos.clone()), 0) + .expect("Failed to get utxos"); + assert_eq!(0, filtered_utxos.num_utxos(), "all utxos filtered out!"); + + all_utxos.utxos.drain(0..10); + let filtered_utxos = btc_controller + .list_unspent(&address, false, 0, &Some(all_utxos), 0) + .expect("Failed to get utxos"); + assert_eq!(10, filtered_utxos.num_utxos(), "40 utxos filtered out!"); + } + + #[test] + #[ignore] + fn test_list_unspent_with_max_utxos_config() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let miner_pubkey = utils::create_miner1_pubkey(); + + let mut config = utils::create_config(); + config.burnchain.local_mining_public_key = Some(miner_pubkey.to_hex()); config.burnchain.max_unspent_utxos = Some(10); let mut btcd_controller = BitcoinCoreController::from_stx_config(&config); @@ -3189,12 +3196,11 @@ mod tests { .expect("Failed starting bitcoind"); let btc_controller = BitcoinRegtestController::new(config.clone(), None); - btc_controller.bootstrap_chain(201); //produces 100 spendable utxos + btc_controller.bootstrap_chain(150); //produces 50 spendable utxos let address = to_address_legacy(&miner_pubkey); - let filter_addresses = vec![address.to_string()]; let utxos = btc_controller - .list_unspent(filter_addresses, false, 1, &None, 0) + .list_unspent(&address, false, 1, &None, 0) .expect("Failed to get utxos"); assert_eq!(10, utxos.num_utxos()); } From 42bcf093f41687fa79dffc9b86587cef412775c8 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Wed, 10 Sep 2025 17:28:39 +0200 Subject: [PATCH 05/15] refactor: replace list_unspent RPCResult with BitcoinRegtestControllerResult --- .../burnchains/bitcoin_regtest_controller.rs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 3f3dc2d32d2..8c5a947fc41 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -2260,27 +2260,24 @@ impl BitcoinRegtestController { minimum_sum_amount: u64, utxos_to_exclude: &Option, block_height: u64, - ) -> RPCResult { + ) -> BitcoinRegtestControllerResult { let bhh = self .rpc_client - .get_block_hash(block_height) - .expect("TEMPORARY: Failed to get bestblockhash"); - //todo: add error log?! - let min_conf = 0; - let max_conf = 9999999; + .get_block_hash(block_height)?; - let result = self + const MIN_CONFIRMATIONS: u64 = 0; + const MAX_CONFIRMATIONS: u64 = 9_999_999; + let unspents = self .rpc_client .list_unspent( &self.get_wallet_name(), - Some(min_conf), - Some(max_conf), + Some(MIN_CONFIRMATIONS), + Some(MAX_CONFIRMATIONS), Some(&[addresses]), Some(include_unsafe), Some(minimum_sum_amount), self.config.burnchain.max_unspent_utxos.clone(), - ) - .expect("TEMPORARY: failed list_unspent!"); + )?; let txids_to_filter = if let Some(utxos_to_exclude) = utxos_to_exclude { utxos_to_exclude @@ -2292,7 +2289,7 @@ impl BitcoinRegtestController { vec![] }; - let utxos = result + let utxos = unspents .into_iter() .filter(|each| !txids_to_filter.contains(&Self::to_bitcoin_tx_hash(&each.txid))) .filter(|each| each.amount >= minimum_sum_amount) From 0063ae2493cb5926a6e8c8efe306f7632b2e577b Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 12 Sep 2025 12:52:06 +0200 Subject: [PATCH 06/15] refactor: make get_all_utxos reuse list_unspent, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 142 +++++------------- 1 file changed, 35 insertions(+), 107 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 8c5a947fc41..f941aeb9955 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -21,7 +21,6 @@ use std::{cmp, io}; use base64::encode; use serde::Serialize; -use serde_json::json; use serde_json::value::RawValue; use stacks::burnchains::bitcoin::address::{ BitcoinAddress, LegacyBitcoinAddress, LegacyBitcoinAddressType, SegwitBitcoinAddress, @@ -77,7 +76,8 @@ use super::super::operations::BurnchainOpSigner; use super::super::Config; use super::{BurnchainController, BurnchainTip, Error as BurnchainControllerError}; use crate::burnchains::rpc::bitcoin_rpc_client::{ - BitcoinRpcClient, BitcoinRpcClientError, ImportDescriptorsRequest, Timestamp, + BitcoinRpcClient, BitcoinRpcClientError, BitcoinRpcClientResult, ImportDescriptorsRequest, + Timestamp, }; /// The number of bitcoin blocks that can have @@ -663,97 +663,28 @@ impl BitcoinRegtestController { } } + /// Retrieves all UTXOs associated with the given public key. + /// + /// The address to query is computed from the public key, + /// disregard the epoch we're in and currently set to [`StacksEpochId::Epoch21`]. + /// + /// # Notes + /// Before calling this method, you must initialize the chain with either: + /// - [`BitcoinRegtestController::bootstrap_chain()`], or + /// - [`BitcoinRegtestController::bootstrap_chain_to_pks()`] + /// + /// These methods are responsible for importing the necessary descriptors into the wallet. #[cfg(test)] pub fn get_all_utxos(&self, public_key: &Secp256k1PublicKey) -> Vec { - // Configure UTXO filter, disregard what epoch we're in - let address = self.get_miner_address(StacksEpochId::Epoch21, public_key); - let filter_addresses = vec![address.to_string()]; - - let pubk = if self.config.miner.segwit { - let mut p = public_key.clone(); - p.set_compressed(true); - p - } else { - public_key.clone() - }; - - test_debug!("Import public key '{}'", &pubk.to_hex()); - let result = self.import_public_key(&pubk); - if let Err(error) = result { - warn!("Import public key '{}' failed: {error:?}", &pubk.to_hex()); - } - - sleep_ms(1000); - - let min_conf = 0i64; - let max_conf = 9999999i64; - let minimum_amount = ParsedUTXO::sat_to_serialized_btc(1); - - test_debug!("List unspent for '{address}' ('{}')", pubk.to_hex()); - let payload = BitcoinRPCRequest { - method: "listunspent".to_string(), - params: vec![ - min_conf.into(), - max_conf.into(), - filter_addresses.into(), - true.into(), - json!({ "minimumAmount": minimum_amount, "maximumCount": self.config.burnchain.max_unspent_utxos }), - ], - id: "stacks".to_string(), - jsonrpc: "2.0".to_string(), - }; - - let mut res = BitcoinRPCRequest::send(&self.config, payload).unwrap(); - let mut result_vec = vec![]; - - if let Some(ref mut object) = res.as_object_mut() { - match object.get_mut("result") { - Some(serde_json::Value::Array(entries)) => { - while let Some(entry) = entries.pop() { - let parsed_utxo: ParsedUTXO = match serde_json::from_value(entry) { - Ok(utxo) => utxo, - Err(err) => { - warn!("Failed parsing UTXO: {err}"); - continue; - } - }; - let amount = match parsed_utxo.get_sat_amount() { - Some(amount) => amount, - None => continue, - }; - - if amount < 1 { - continue; - } - - let script_pub_key = match parsed_utxo.get_script_pub_key() { - Some(script_pub_key) => script_pub_key, - None => { - continue; - } - }; - - let txid = match parsed_utxo.get_txid() { - Some(amount) => amount, - None => continue, - }; - - result_vec.push(UTXO { - txid, - vout: parsed_utxo.vout, - script_pub_key, - amount, - confirmations: parsed_utxo.confirmations, - }); - } - } - _ => { - warn!("Failed to get UTXOs"); - } - } - } - - result_vec + self.list_unspent( + &self.get_miner_address(StacksEpochId::Epoch21, public_key), + true, + 1, + &None, + 0, + ) + .unwrap_or_log_panic("retrieve all utxos") + .utxos } /// Retrieve all loaded wallets. @@ -2253,31 +2184,28 @@ impl BitcoinRegtestController { Sha256dHash(txid_bytes) } + /// retrieve utxo set pub fn list_unspent( &self, - addresses: &BitcoinAddress, + address: &BitcoinAddress, include_unsafe: bool, minimum_sum_amount: u64, utxos_to_exclude: &Option, block_height: u64, - ) -> BitcoinRegtestControllerResult { - let bhh = self - .rpc_client - .get_block_hash(block_height)?; + ) -> BitcoinRpcClientResult { + let bhh = self.rpc_client.get_block_hash(block_height)?; const MIN_CONFIRMATIONS: u64 = 0; const MAX_CONFIRMATIONS: u64 = 9_999_999; - let unspents = self - .rpc_client - .list_unspent( - &self.get_wallet_name(), - Some(MIN_CONFIRMATIONS), - Some(MAX_CONFIRMATIONS), - Some(&[addresses]), - Some(include_unsafe), - Some(minimum_sum_amount), - self.config.burnchain.max_unspent_utxos.clone(), - )?; + let unspents = self.rpc_client.list_unspent( + &self.get_wallet_name(), + Some(MIN_CONFIRMATIONS), + Some(MAX_CONFIRMATIONS), + Some(&[address]), + Some(include_unsafe), + Some(minimum_sum_amount), + self.config.burnchain.max_unspent_utxos.clone(), + )?; let txids_to_filter = if let Some(utxos_to_exclude) = utxos_to_exclude { utxos_to_exclude From b8cd9053850fe5b6aff42532004d031803a9a00d Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Fri, 12 Sep 2025 13:29:05 +0200 Subject: [PATCH 07/15] chore: rename list_unspent method and add documentation, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index f941aeb9955..a5ca07ccd78 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -676,7 +676,7 @@ impl BitcoinRegtestController { /// These methods are responsible for importing the necessary descriptors into the wallet. #[cfg(test)] pub fn get_all_utxos(&self, public_key: &Secp256k1PublicKey) -> Vec { - self.list_unspent( + self.retrieve_utxo_set( &self.get_miner_address(StacksEpochId::Epoch21, public_key), true, 1, @@ -724,7 +724,7 @@ impl BitcoinRegtestController { test_debug!("Get UTXOs for {} ({address})", pubk.to_hex()); let mut utxos = loop { - let result = self.list_unspent( + let result = self.retrieve_utxo_set( &address, false, total_required, @@ -760,7 +760,7 @@ impl BitcoinRegtestController { sleep_ms(1000); } - let result = self.list_unspent( + let result = self.retrieve_utxo_set( &address, false, total_required, @@ -2184,8 +2184,27 @@ impl BitcoinRegtestController { Sha256dHash(txid_bytes) } - /// retrieve utxo set - pub fn list_unspent( + /// Retrieves the set of UTXOs for a given address at a specific block height. + /// + /// This method queries all unspent outputs belonging to the provided address: + /// 1. Using a confirmation window of `0..=9_999_999` for the RPC call. + /// 2. Filtering out UTXOs that: + /// - Are present in the optional exclusion set (matched by transaction ID). + /// - Have an amount below the specified `minimum_sum_amount`. + /// + /// Note: The `block_height` is only used to retrieve the corresponding block hash + /// and does not affect which UTXOs are included in the result. + /// + /// # Arguments + /// - `address`: The Bitcoin address whose UTXOs should be retrieved. + /// - `include_unsafe`: Whether to include unsafe UTXOs. + /// - `minimum_sum_amount`: Minimum amount (in satoshis) that a UTXO must have to be included in the final set. + /// - `utxos_to_exclude`: Optional set of UTXOs to exclude from the final result. + /// - `block_height`: The block height at which to resolve the block hash used in the result. + /// + /// # Returns + /// A [`UTXOSet`] containing the filtered UTXOs and the block hash corresponding to `block_height`. + fn retrieve_utxo_set( &self, address: &BitcoinAddress, include_unsafe: bool, @@ -3040,7 +3059,7 @@ mod tests { #[test] #[ignore] - fn test_list_unspent_all() { + fn test_retrieve_utxo_set_with_all_utxos() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -3059,15 +3078,16 @@ mod tests { btc_controller.bootstrap_chain(150); //produces 50 spendable utxos let address = to_address_legacy(&miner_pubkey); - let utxos = btc_controller - .list_unspent(&address, false, 0, &None, 0) + let utxo_set = btc_controller + .retrieve_utxo_set(&address, false, 0, &None, 0) .expect("Failed to get utxos"); - assert_eq!(50, utxos.num_utxos()); + assert_eq!(btc_controller.get_block_hash(0), utxo_set.bhh); + assert_eq!(50, utxo_set.num_utxos()); } #[test] #[ignore] - fn test_list_unspent_excluding_some_utxo() { + fn test_retrive_utxo_set_excluding_some_utxo() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } @@ -3087,17 +3107,17 @@ mod tests { let address = to_address_legacy(&miner_pubkey); let mut all_utxos = btc_controller - .list_unspent(&address, false, 0, &None, 0) + .retrieve_utxo_set(&address, false, 0, &None, 0) .expect("Failed to get utxos (50)"); let filtered_utxos = btc_controller - .list_unspent(&address, false, 0, &Some(all_utxos.clone()), 0) + .retrieve_utxo_set(&address, false, 0, &Some(all_utxos.clone()), 0) .expect("Failed to get utxos"); assert_eq!(0, filtered_utxos.num_utxos(), "all utxos filtered out!"); all_utxos.utxos.drain(0..10); let filtered_utxos = btc_controller - .list_unspent(&address, false, 0, &Some(all_utxos), 0) + .retrieve_utxo_set(&address, false, 0, &Some(all_utxos), 0) .expect("Failed to get utxos"); assert_eq!(10, filtered_utxos.num_utxos(), "40 utxos filtered out!"); } @@ -3125,7 +3145,7 @@ mod tests { let address = to_address_legacy(&miner_pubkey); let utxos = btc_controller - .list_unspent(&address, false, 1, &None, 0) + .retrieve_utxo_set(&address, false, 1, &None, 0) .expect("Failed to get utxos"); assert_eq!(10, utxos.num_utxos()); } From 85e836700a4d37f0cb80204f99b9c46c0afbf2a7 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 15 Sep 2025 11:16:04 +0200 Subject: [PATCH 08/15] test: improve get_all_utxo, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index a5ca07ccd78..d76fc4f5136 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -676,15 +676,30 @@ impl BitcoinRegtestController { /// These methods are responsible for importing the necessary descriptors into the wallet. #[cfg(test)] pub fn get_all_utxos(&self, public_key: &Secp256k1PublicKey) -> Vec { - self.retrieve_utxo_set( - &self.get_miner_address(StacksEpochId::Epoch21, public_key), - true, - 1, - &None, - 0, - ) - .unwrap_or_log_panic("retrieve all utxos") - .utxos + let address = self.get_miner_address(StacksEpochId::Epoch21, public_key); + + let public_key_reviewed = if self.config.miner.segwit { + let mut p = public_key.clone(); + p.set_compressed(true); + p + } else { + public_key.clone() + }; + + test_debug!("Import public key '{}'", &public_key_reviewed.to_hex()); + self.import_public_key(&public_key_reviewed) + .unwrap_or_else(|error| { + panic!( + "Import public key '{}' failed: {error:?}", + public_key_reviewed.to_hex() + ) + }); + + sleep_ms(1000); + + self.retrieve_utxo_set(&address, true, 1, &None, 0) + .unwrap_or_log_panic("retrieve all utxos") + .utxos } /// Retrieve all loaded wallets. @@ -3192,27 +3207,41 @@ mod tests { #[test] #[ignore] - fn test_get_all_utxos_empty_for_other_pubkey() { + fn test_get_all_utxos_for_other_pubkey() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } - let miner_pubkey = utils::create_miner1_pubkey(); - let other_pubkey = utils::create_miner2_pubkey(); + let miner1_pubkey = utils::create_miner1_pubkey(); + let miner2_pubkey = utils::create_miner2_pubkey(); let mut config = utils::create_config(); - config.burnchain.local_mining_public_key = Some(miner_pubkey.to_hex()); + config.burnchain.local_mining_public_key = Some(miner1_pubkey.to_hex()); let mut btcd_controller = BitcoinCoreController::from_stx_config(&config); btcd_controller .start_bitcoind() .expect("bitcoind should be started!"); - let btc_controller = BitcoinRegtestController::new(config.clone(), None); - btc_controller.bootstrap_chain(101); // one utxo exists + let miner1_btc_controller = BitcoinRegtestController::new(config.clone(), None); + miner1_btc_controller.bootstrap_chain(1); // one utxo for miner_pubkey related address - let utxos = btc_controller.get_all_utxos(&other_pubkey); - assert_eq!(0, utxos.len()); + config.burnchain.local_mining_public_key = Some(miner2_pubkey.to_hex()); + config.burnchain.wallet_name = "miner2_wallet".to_string(); + let miner2_btc_controller = BitcoinRegtestController::new(config, None); + miner2_btc_controller.bootstrap_chain(102); // two utxo for other_pubkeys related address + + let utxos = miner1_btc_controller.get_all_utxos(&miner1_pubkey); + assert_eq!(1, utxos.len(), "miner1 see its own utxos"); + + let utxos = miner1_btc_controller.get_all_utxos(&miner2_pubkey); + assert_eq!(2, utxos.len(), "miner1 see miner2 utxos"); + + let utxos = miner2_btc_controller.get_all_utxos(&miner2_pubkey); + assert_eq!(2, utxos.len(), "miner2 see its own utxos"); + + let utxos = miner2_btc_controller.get_all_utxos(&miner1_pubkey); + assert_eq!(1, utxos.len(), "miner2 see miner1 own utxos"); } #[test] From 08e639ceaaacf0db0eeea68637ab02da67961578 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 15 Sep 2025 11:16:04 +0200 Subject: [PATCH 09/15] test: improve get_all_utxo, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index a5ca07ccd78..70912e32a32 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -668,23 +668,33 @@ impl BitcoinRegtestController { /// The address to query is computed from the public key, /// disregard the epoch we're in and currently set to [`StacksEpochId::Epoch21`]. /// - /// # Notes - /// Before calling this method, you must initialize the chain with either: - /// - [`BitcoinRegtestController::bootstrap_chain()`], or - /// - [`BitcoinRegtestController::bootstrap_chain_to_pks()`] - /// - /// These methods are responsible for importing the necessary descriptors into the wallet. + /// Automatically imports descriptors into the wallet for the public_key #[cfg(test)] pub fn get_all_utxos(&self, public_key: &Secp256k1PublicKey) -> Vec { - self.retrieve_utxo_set( - &self.get_miner_address(StacksEpochId::Epoch21, public_key), - true, - 1, - &None, - 0, - ) - .unwrap_or_log_panic("retrieve all utxos") - .utxos + let address = self.get_miner_address(StacksEpochId::Epoch21, public_key); + + let public_key_reviewed = if self.config.miner.segwit { + let mut p = public_key.clone(); + p.set_compressed(true); + p + } else { + public_key.clone() + }; + + test_debug!("Import public key '{}'", &public_key_reviewed.to_hex()); + self.import_public_key(&public_key_reviewed) + .unwrap_or_else(|error| { + panic!( + "Import public key '{}' failed: {error:?}", + public_key_reviewed.to_hex() + ) + }); + + sleep_ms(1000); + + self.retrieve_utxo_set(&address, true, 1, &None, 0) + .unwrap_or_log_panic("retrieve all utxos") + .utxos } /// Retrieve all loaded wallets. @@ -3192,27 +3202,41 @@ mod tests { #[test] #[ignore] - fn test_get_all_utxos_empty_for_other_pubkey() { + fn test_get_all_utxos_for_other_pubkey() { if env::var("BITCOIND_TEST") != Ok("1".into()) { return; } - let miner_pubkey = utils::create_miner1_pubkey(); - let other_pubkey = utils::create_miner2_pubkey(); + let miner1_pubkey = utils::create_miner1_pubkey(); + let miner2_pubkey = utils::create_miner2_pubkey(); let mut config = utils::create_config(); - config.burnchain.local_mining_public_key = Some(miner_pubkey.to_hex()); + config.burnchain.local_mining_public_key = Some(miner1_pubkey.to_hex()); let mut btcd_controller = BitcoinCoreController::from_stx_config(&config); btcd_controller .start_bitcoind() .expect("bitcoind should be started!"); - let btc_controller = BitcoinRegtestController::new(config.clone(), None); - btc_controller.bootstrap_chain(101); // one utxo exists + let miner1_btc_controller = BitcoinRegtestController::new(config.clone(), None); + miner1_btc_controller.bootstrap_chain(1); // one utxo for miner_pubkey related address - let utxos = btc_controller.get_all_utxos(&other_pubkey); - assert_eq!(0, utxos.len()); + config.burnchain.local_mining_public_key = Some(miner2_pubkey.to_hex()); + config.burnchain.wallet_name = "miner2_wallet".to_string(); + let miner2_btc_controller = BitcoinRegtestController::new(config, None); + miner2_btc_controller.bootstrap_chain(102); // two utxo for other_pubkeys related address + + let utxos = miner1_btc_controller.get_all_utxos(&miner1_pubkey); + assert_eq!(1, utxos.len(), "miner1 see its own utxos"); + + let utxos = miner1_btc_controller.get_all_utxos(&miner2_pubkey); + assert_eq!(2, utxos.len(), "miner1 see miner2 utxos"); + + let utxos = miner2_btc_controller.get_all_utxos(&miner2_pubkey); + assert_eq!(2, utxos.len(), "miner2 see its own utxos"); + + let utxos = miner2_btc_controller.get_all_utxos(&miner1_pubkey); + assert_eq!(1, utxos.len(), "miner2 see miner1 own utxos"); } #[test] From 624d3e04e05e6bca691d99c8677cd61d5d415f45 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 15 Sep 2025 13:59:15 +0200 Subject: [PATCH 10/15] refactor: introduce to_epoch_aware_pubkey, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 70912e32a32..6c370bf8af5 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -671,22 +671,16 @@ impl BitcoinRegtestController { /// Automatically imports descriptors into the wallet for the public_key #[cfg(test)] pub fn get_all_utxos(&self, public_key: &Secp256k1PublicKey) -> Vec { - let address = self.get_miner_address(StacksEpochId::Epoch21, public_key); + const EPOCH: StacksEpochId = StacksEpochId::Epoch21; + let address = self.get_miner_address(EPOCH, public_key); + let pub_key_rev = self.to_epoch_aware_pubkey(EPOCH, public_key); - let public_key_reviewed = if self.config.miner.segwit { - let mut p = public_key.clone(); - p.set_compressed(true); - p - } else { - public_key.clone() - }; - - test_debug!("Import public key '{}'", &public_key_reviewed.to_hex()); - self.import_public_key(&public_key_reviewed) + test_debug!("Import public key '{}'", &pub_key_rev.to_hex()); + self.import_public_key(&pub_key_rev) .unwrap_or_else(|error| { panic!( "Import public key '{}' failed: {error:?}", - public_key_reviewed.to_hex() + pub_key_rev.to_hex() ) }); @@ -721,17 +715,11 @@ impl BitcoinRegtestController { utxos_to_exclude: Option, block_height: u64, ) -> Option { - let pubk = if self.config.miner.segwit && epoch_id >= StacksEpochId::Epoch21 { - let mut p = public_key.clone(); - p.set_compressed(true); - p - } else { - public_key.clone() - }; + let pub_key_rev = self.to_epoch_aware_pubkey(epoch_id, public_key); // Configure UTXO filter - let address = self.get_miner_address(epoch_id, &pubk); - test_debug!("Get UTXOs for {} ({address})", pubk.to_hex()); + let address = self.get_miner_address(epoch_id, &pub_key_rev); + test_debug!("Get UTXOs for {} ({address})", pub_key_rev.to_hex()); let mut utxos = loop { let result = self.retrieve_utxo_set( @@ -763,9 +751,9 @@ impl BitcoinRegtestController { // Assuming that miners are in charge of correctly operating their bitcoind nodes sounds // reasonable to me. // $ bitcoin-cli importaddress mxVFsFW5N4mu1HPkxPttorvocvzeZ7KZyk - let result = self.import_public_key(&pubk); + let result = self.import_public_key(&pub_key_rev); if let Err(error) = result { - warn!("Import public key '{}' failed: {error:?}", &pubk.to_hex()); + warn!("Import public key '{}' failed: {error:?}", &pub_key_rev.to_hex()); } sleep_ms(1000); } @@ -804,7 +792,7 @@ impl BitcoinRegtestController { if total_unspent < total_required { warn!( "Total unspent {total_unspent} < {total_required} for {:?}", - &pubk.to_hex() + &pub_key_rev.to_hex() ); return None; } @@ -2194,6 +2182,30 @@ impl BitcoinRegtestController { Sha256dHash(txid_bytes) } + /// Returns a copy of the given public key adjusted to the current epoch rules. + /// + /// In particular: + /// - For epochs **before** [`StacksEpochId::Epoch21`], the public key is returned + /// unchanged. + /// - Starting with [`StacksEpochId::Epoch21`], if **SegWit** is enabled in the miner + /// configuration, the key is forced into compressed form. + /// + /// # Arguments + /// * `epoch_id` — The epoch identifier to check against protocol upgrade rules. + /// * `public_key` — The original public key to adjust. + /// + /// # Returns + /// A [`Secp256k1PublicKey`] that is either the same as the input or compressed, + /// depending on the epoch and miner configuration. + fn to_epoch_aware_pubkey(&self, epoch_id: StacksEpochId, public_key: &Secp256k1PublicKey) -> Secp256k1PublicKey + { + let mut reviewed = public_key.clone(); + if self.config.miner.segwit && epoch_id >= StacksEpochId::Epoch21 { + reviewed.set_compressed(true); + } + return reviewed; + } + /// Retrieves the set of UTXOs for a given address at a specific block height. /// /// This method queries all unspent outputs belonging to the provided address: @@ -3017,6 +3029,28 @@ mod tests { assert_eq!(serialize_hex(&block_commit).unwrap(), "0100000002eeda098987728e4a2e21b34b74000dcb0bd0e4d20e55735492ec3cba3afbead3030000006a4730440220558286e20e10ce31537f0625dae5cc62fac7961b9d2cf272c990de96323d7e2502202255adbea3d2e0509b80c5d8a3a4fe6397a87bcf18da1852740d5267d89a0cb20121035379aa40c02890d253cfa577964116eb5295570ae9f7287cbae5f2585f5b2c7cfdffffff243b0b329a5889ab8801b315eea19810848d4c2133e0245671cc984a2d2f1301000000006a47304402206d9f8de107f9e1eb15aafac66c2bb34331a7523260b30e18779257e367048d34022013c7dabb32a5c281aa00d405e2ccbd00f34f03a65b2336553a4acd6c52c251ef0121035379aa40c02890d253cfa577964116eb5295570ae9f7287cbae5f2585f5b2c7cfdffffff040000000000000000536a4c5054335be88c3d30cb59a142f83de3b27f897a43bbb0f13316911bb98a3229973dae32afd5b9f21bc1f40f24e2c101ecd13c55b8619e5e03dad81de2c62a1cc1d8c1b375000008a300010000059800015a10270000000000001976a914000000000000000000000000000000000000000088ac10270000000000001976a914000000000000000000000000000000000000000088acb3ef0400000000001976a9141dc27eba0247f8cc9575e7d45e50a0bc7e72427d88ac00000000"); } + #[test] + fn test_to_epoch_aware_pubkey() { + let mut config = utils::create_config(); + let pubkey = utils::create_miner1_pubkey(); + + config.miner.segwit = false; + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + + let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch20, &pubkey); + assert_eq!(false, reviewed.compressed(), "Segwit disabled with Epoch < 2.1: not compressed"); + let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch21, &pubkey); + assert_eq!(false, reviewed.compressed(), "Segwit disabled with Epoch >= 2.1: not compressed"); + + config.miner.segwit = true; + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + + let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch20, &pubkey); + assert_eq!(false, reviewed.compressed(), "Segwit enabled with Epoch < 2.1: not compressed"); + let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch21, &pubkey); + assert_eq!(true, reviewed.compressed(), "Segwit enabled with Epoch > 2.1: compressed"); + } + #[test] #[ignore] fn test_create_wallet_from_default_empty_name() { From 94774d2f1a6d029e15cf86dd0869c8c2a6f88588 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 15 Sep 2025 14:24:20 +0200 Subject: [PATCH 11/15] test: add unit tests for get_miner_address, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 89 ++++++++++++++++--- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 6c370bf8af5..efd6f023dcd 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -753,7 +753,10 @@ impl BitcoinRegtestController { // $ bitcoin-cli importaddress mxVFsFW5N4mu1HPkxPttorvocvzeZ7KZyk let result = self.import_public_key(&pub_key_rev); if let Err(error) = result { - warn!("Import public key '{}' failed: {error:?}", &pub_key_rev.to_hex()); + warn!( + "Import public key '{}' failed: {error:?}", + &pub_key_rev.to_hex() + ); } sleep_ms(1000); } @@ -2197,8 +2200,11 @@ impl BitcoinRegtestController { /// # Returns /// A [`Secp256k1PublicKey`] that is either the same as the input or compressed, /// depending on the epoch and miner configuration. - fn to_epoch_aware_pubkey(&self, epoch_id: StacksEpochId, public_key: &Secp256k1PublicKey) -> Secp256k1PublicKey - { + fn to_epoch_aware_pubkey( + &self, + epoch_id: StacksEpochId, + public_key: &Secp256k1PublicKey, + ) -> Secp256k1PublicKey { let mut reviewed = public_key.clone(); if self.config.miner.segwit && epoch_id >= StacksEpochId::Epoch21 { reviewed.set_compressed(true); @@ -2679,6 +2685,13 @@ mod tests { .expect("Public key incorrect") } + pub fn to_address_segwit_p2wpkh(pub_key: &Secp256k1PublicKey) -> BitcoinAddress { + // pub_key.to_byte_compressed() equivalent to pub_key.set_compressed(true) + pub_key.to_bytes() + let hash160 = Hash160::from_data(&pub_key.to_bytes_compressed()); + BitcoinAddress::from_bytes_segwit_p2wpkh(BitcoinNetworkType::Regtest, &hash160.0) + .expect("Public key incorrect") + } + pub fn mine_tx(btc_controller: &BitcoinRegtestController, tx: &Transaction) { btc_controller .send_transaction(tx) @@ -3033,22 +3046,78 @@ mod tests { fn test_to_epoch_aware_pubkey() { let mut config = utils::create_config(); let pubkey = utils::create_miner1_pubkey(); - + config.miner.segwit = false; let btc_controller = BitcoinRegtestController::new(config.clone(), None); - + let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch20, &pubkey); - assert_eq!(false, reviewed.compressed(), "Segwit disabled with Epoch < 2.1: not compressed"); + assert_eq!( + false, + reviewed.compressed(), + "Segwit disabled with Epoch < 2.1: not compressed" + ); let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch21, &pubkey); - assert_eq!(false, reviewed.compressed(), "Segwit disabled with Epoch >= 2.1: not compressed"); + assert_eq!( + false, + reviewed.compressed(), + "Segwit disabled with Epoch >= 2.1: not compressed" + ); config.miner.segwit = true; let btc_controller = BitcoinRegtestController::new(config.clone(), None); - + let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch20, &pubkey); - assert_eq!(false, reviewed.compressed(), "Segwit enabled with Epoch < 2.1: not compressed"); + assert_eq!( + false, + reviewed.compressed(), + "Segwit enabled with Epoch < 2.1: not compressed" + ); let reviewed = btc_controller.to_epoch_aware_pubkey(StacksEpochId::Epoch21, &pubkey); - assert_eq!(true, reviewed.compressed(), "Segwit enabled with Epoch > 2.1: compressed"); + assert_eq!( + true, + reviewed.compressed(), + "Segwit enabled with Epoch >= 2.1: compressed" + ); + } + + #[test] + fn test_get_miner_address() { + let mut config = utils::create_config(); + let pub_key = utils::create_miner1_pubkey(); + + config.miner.segwit = false; + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + + let expected = utils::to_address_legacy(&pub_key); + let address = btc_controller.get_miner_address(StacksEpochId::Epoch20, &pub_key); + assert_eq!( + expected, address, + "Segwit disabled with Epoch < 2.1: legacy addr" + ); + + let expected = utils::to_address_legacy(&pub_key); + let address = btc_controller.get_miner_address(StacksEpochId::Epoch21, &pub_key); + assert_eq!( + expected, address, + "Segwit disabled with Epoch >= 2.1: legacy addr" + ); + + config.miner.segwit = true; + let btc_controller = BitcoinRegtestController::new(config.clone(), None); + + let expected = utils::to_address_legacy(&pub_key); + let address = btc_controller.get_miner_address(StacksEpochId::Epoch20, &pub_key); + assert_eq!( + expected, address, + "Segwit enabled with Epoch < 2.1: legacy addr" + ); + + let expected = utils::to_address_segwit_p2wpkh(&pub_key); + let address = btc_controller.get_miner_address(StacksEpochId::Epoch21, &pub_key); + assert_eq!( + expected, address, + "Segwit enabled with Epoch >= 2.1: segwit addr" + ); } #[test] From b8e86f06a5ee16a06b9ec41e1ebaf39dc5b34ec8 Mon Sep 17 00:00:00 2001 From: Federico De Felici Date: Mon, 15 Sep 2025 14:49:36 +0200 Subject: [PATCH 12/15] chore: remove unused BitcoinRPCRequest and ParsedUTXO, #6387 --- .../burnchains/bitcoin_regtest_controller.rs | 183 +----------------- .../rpc/bitcoin_rpc_client/tests.rs | 1 + stacks-node/src/tests/mod.rs | 29 --- 3 files changed, 3 insertions(+), 210 deletions(-) diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index efd6f023dcd..0b56a1c32cd 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -16,12 +16,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; -use std::{cmp, io}; +use std::time::Instant; +use std::cmp; -use base64::encode; -use serde::Serialize; -use serde_json::value::RawValue; use stacks::burnchains::bitcoin::address::{ BitcoinAddress, LegacyBitcoinAddress, LegacyBitcoinAddressType, SegwitBitcoinAddress, }; @@ -54,9 +51,6 @@ use stacks::config::{ }; use stacks::core::{EpochList, StacksEpochId}; use stacks::monitoring::{increment_btc_blocks_received_counter, increment_btc_ops_sent_counter}; -use stacks::net::http::{HttpRequestContents, HttpResponsePayload}; -use stacks::net::httpcore::{send_http_request, StacksHttpRequest}; -use stacks::net::Error as NetError; use stacks_common::codec::StacksMessageCodec; use stacks_common::deps_common::bitcoin::blockdata::opcodes; use stacks_common::deps_common::bitcoin::blockdata::script::{Builder, Script}; @@ -66,11 +60,9 @@ use stacks_common::deps_common::bitcoin::blockdata::transaction::{ use stacks_common::deps_common::bitcoin::network::serialize::{serialize, serialize_hex}; use stacks_common::deps_common::bitcoin::util::hash::Sha256dHash; use stacks_common::types::chainstate::BurnchainHeaderHash; -use stacks_common::types::net::PeerHost; use stacks_common::util::hash::{hex_bytes, Hash160}; use stacks_common::util::secp256k1::Secp256k1PublicKey; use stacks_common::util::sleep_ms; -use url::Url; use super::super::operations::BurnchainOpSigner; use super::super::Config; @@ -2422,17 +2414,6 @@ impl UTXOSet { } } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub struct ParsedUTXO { - txid: String, - vout: u32, - script_pub_key: String, - amount: Box, - confirmations: u32, -} - #[derive(Clone, Debug, PartialEq)] pub struct UTXO { pub txid: Sha256dHash, @@ -2442,166 +2423,6 @@ pub struct UTXO { pub confirmations: u32, } -impl ParsedUTXO { - pub fn get_txid(&self) -> Option { - match hex_bytes(&self.txid) { - Ok(ref mut txid) => { - txid.reverse(); - Some(Sha256dHash::from(&txid[..])) - } - Err(err) => { - warn!("Unable to get txid from UTXO {err}"); - None - } - } - } - - pub fn get_sat_amount(&self) -> Option { - ParsedUTXO::serialized_btc_to_sat(self.amount.get()) - } - - pub fn serialized_btc_to_sat(amount: &str) -> Option { - let comps: Vec<&str> = amount.split('.').collect(); - match comps[..] { - [lhs, rhs] => { - if rhs.len() > 8 { - warn!("Unexpected amount of decimals"); - return None; - } - - match (lhs.parse::(), rhs.parse::()) { - (Ok(btc), Ok(frac_part)) => { - let base: u64 = 10; - let btc_to_sat = base.pow(8); - let mut amount = btc * btc_to_sat; - let sat = frac_part * base.pow(8 - rhs.len() as u32); - amount += sat; - Some(amount) - } - (lhs, rhs) => { - warn!("Error while converting BTC to sat {lhs:?} - {rhs:?}"); - None - } - } - } - _ => None, - } - } - - pub fn sat_to_serialized_btc(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 - } - - pub fn get_script_pub_key(&self) -> Option