diff --git a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs index 1e28b1ff1bd..5a54d6121c0 100644 --- a/stacks-node/src/burnchains/bitcoin_regtest_controller.rs +++ b/stacks-node/src/burnchains/bitcoin_regtest_controller.rs @@ -14,15 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::cmp; +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; -use std::{cmp, io}; +use std::time::Instant; -use base64::encode; -use serde::Serialize; -use serde_json::json; -use serde_json::value::RawValue; use stacks::burnchains::bitcoin::address::{ BitcoinAddress, LegacyBitcoinAddress, LegacyBitcoinAddressType, SegwitBitcoinAddress, }; @@ -55,9 +52,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}; @@ -67,17 +61,16 @@ 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; 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 +656,32 @@ 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`]. + /// + /// Automatically imports descriptors into the wallet for the public_key #[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()); - } + 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); + + 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:?}", + pub_key_rev.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.retrieve_utxo_set(&address, true, 1, &None, 0) + .unwrap_or_log_panic("retrieve all utxos") + .utxos } /// Retrieve all loaded wallets. @@ -780,23 +708,15 @@ 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 filter_addresses = vec![address.to_string()]; + 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 = BitcoinRPCRequest::list_unspent( - &self.config, - filter_addresses.clone(), + let result = self.retrieve_utxo_set( + &address, false, total_required, &utxos_to_exclude, @@ -824,16 +744,18 @@ 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); } - let result = BitcoinRPCRequest::list_unspent( - &self.config, - filter_addresses.clone(), + let result = self.retrieve_utxo_set( + &address, false, total_required, &utxos_to_exclude, @@ -849,7 +771,7 @@ impl BitcoinRegtestController { } }; - test_debug!("Unspent for {filter_addresses:?}: {utxos:?}"); + test_debug!("Unspent for {address:?}: {utxos:?}"); if utxos.is_empty() { return None; @@ -858,7 +780,7 @@ impl BitcoinRegtestController { } } } else { - debug!("Got {} UTXOs for {filter_addresses:?}", utxos.utxos.len(),); + debug!("Got {} UTXOs for {address:?}", utxos.utxos.len(),); utxos }; @@ -866,7 +788,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; } @@ -2249,6 +2171,97 @@ impl BitcoinRegtestController { } Ok(()) } + + /// 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: + /// 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, + minimum_sum_amount: u64, + utxos_to_exclude: &Option, + block_height: u64, + ) -> 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(&[address]), + Some(include_unsafe), + Some(minimum_sum_amount), + self.config.burnchain.max_unspent_utxos.clone(), + )?; + + let txids_to_exclude = utxos_to_exclude.as_ref().map_or_else(HashSet::new, |set| { + set.utxos + .iter() + .map(|utxo| Txid::from_bitcoin_tx_hash(&utxo.txid)) + .collect() + }); + + let utxos = unspents + .into_iter() + .filter(|each| !txids_to_exclude.contains(&each.txid)) + .filter(|each| each.amount >= minimum_sum_amount) + .map(|each| UTXO { + txid: Txid::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 }) + } } impl BurnchainController for BitcoinRegtestController { @@ -2393,17 +2406,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, @@ -2413,280 +2415,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