From 86335e8467572ee38c4877a7c6e5a1e9b4490ba1 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Wed, 25 Mar 2026 22:28:50 -0300 Subject: [PATCH] refactor(bitcoincore): convert bitcoincore into gateway adapter Removed from crates/stealth-bitcoincore and moved to bitcoincore as a standalone package. This change is part of the refactor to create separate packages for each component of the stealth project, allowing for better modularity and separation of concerns. --- Cargo.toml | 1 + bitcoincore/Cargo.toml | 19 ++ bitcoincore/README.md | 138 ++++++++ bitcoincore/src/lib.rs | 735 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 893 insertions(+) create mode 100644 bitcoincore/Cargo.toml create mode 100644 bitcoincore/README.md create mode 100644 bitcoincore/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 6a6bea4..bc025ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "model", + "bitcoincore", ] resolver = "2" diff --git a/bitcoincore/Cargo.toml b/bitcoincore/Cargo.toml new file mode 100644 index 0000000..0795131 --- /dev/null +++ b/bitcoincore/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "stealth-bitcoincore" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Bitcoin Core RPC gateway for stealth-engine" + +[dependencies] +bitcoin = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +ini = { package = "rust-ini", version = "0.21" } +serde = { workspace = true } +serde_json = { workspace = true } +stealth-model = { workspace = true } +thiserror = { workspace = true } +urlencoding = "2.1" diff --git a/bitcoincore/README.md b/bitcoincore/README.md new file mode 100644 index 0000000..f69b266 --- /dev/null +++ b/bitcoincore/README.md @@ -0,0 +1,138 @@ +# stealth-bitcoincore + +`stealth-bitcoincore` is the Bitcoin Core JSON-RPC gateway implementation for +[`stealth-engine`](../core/README.md). + +It implements `stealth_engine::gateway::BlockchainGateway` and is used by both: + +- `stealth-cli` (terminal scans) +- `stealth-api` (HTTP scans) + +## What it does + +This crate wraps the Bitcoin Core RPC surface needed by the analysis engine: + +- descriptor normalization and derivation +- descriptor import into temporary watch-only wallets +- wallet history + UTXO retrieval +- raw transaction expansion (including ancestry walk) + +The output is converted into `stealth-engine` gateway types (`WalletHistory`, +`DecodedTransaction`, `Utxo`, etc), so the engine can run detectors without +knowing anything about RPC transport details. + +## Authentication and configuration + +The gateway supports: + +- RPC user/password +- Bitcoin Core cookie auth + +`BitcoinCoreRpc::from_url(...)` does not auto-discover cookie files. If you +want internal cookie lookup, construct with `BitcoinCoreConfig` and set +`datadir`. + +### 1) Build from URL (used by API/CLI) + +```rust +use stealth_bitcoincore::BitcoinCoreRpc; + +let gateway = BitcoinCoreRpc::from_url( + "http://127.0.0.1:18443", + Some("rpcuser".to_owned()), + Some("rpcpassword".to_owned()), +)?; +# let _ = gateway; +# Ok::<(), stealth_model::error::AnalysisError>(()) +``` + +Pass explicit credentials (or parse a cookie file yourself and pass those +values here). + +### 2) Build from INI file + +```ini +[bitcoin] +network=regtest +datadir=/home/user/.bitcoin +rpchost=127.0.0.1 +rpcport=18443 +rpcuser=rpcuser +rpcpassword=rpcpassword +``` + +```rust +use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc}; + +let config = BitcoinCoreConfig::from_ini_file("stealth.ini")?; +let gateway = BitcoinCoreRpc::new(config)?; +# let _ = gateway; +# Ok::<(), stealth_model::error::AnalysisError>(()) +``` + +Config defaults: + +- `network`: `regtest` +- `rpchost`: `127.0.0.1` +- `rpcport`: inferred from network (`8332` mainnet, `18332` testnet, `38332` signet, `18443` regtest) +- `datadir`: optional (required for cookie fallback) + +Cookie lookup: + +- mainnet: `/.cookie` +- other networks: `//.cookie`, then `/.cookie` + +## Using with the analysis engine + +```rust,ignore +use stealth_bitcoincore::BitcoinCoreRpc; +use stealth_engine::{AnalysisEngine, EngineSettings, ScanTarget}; + +let gateway = BitcoinCoreRpc::from_url( + "http://127.0.0.1:18443", + Some("rpcuser".to_owned()), + Some("rpcpassword".to_owned()), +)?; + +let engine = AnalysisEngine::new(&gateway, EngineSettings::default()); +let report = engine.analyze(ScanTarget::Descriptor( + "wpkh([f23f9fd2/84h/1h/0h]tpub.../0/*)".to_owned(), +))?; + +println!("clean: {}", report.summary.clean); +# Ok::<(), stealth_model::error::AnalysisError>(()) +``` + +## RPC methods used + +| Gateway behavior | Bitcoin Core RPC | +| --- | --- | +| Descriptor normalization | `getdescriptorinfo` | +| Address derivation | `deriveaddresses` | +| Temporary wallet creation | `createwallet` | +| Descriptor import | `importdescriptors` | +| Wallet tx list | `listtransactions` | +| UTXO list | `listunspent` | +| Raw tx decode/history expansion | `getrawtransaction` (verbose) | +| Wallet descriptor listing | `listdescriptors` | +| Wallet cleanup | `unloadwallet` | + +## Notes + +- Descriptor scans create a temporary wallet named `_stealth_scan_` + and unload it after collection. +- Most gateway failures are surfaced as + `AnalysisError::EnvironmentUnavailable(...)` with the underlying RPC/context + message. +- For robust transaction lookups beyond wallet-only data, running `bitcoind` + with `txindex=1` is recommended. + +## Development + +```bash +cargo test -p stealth-bitcoincore +``` + +## License + +[MIT](../LICENSE) diff --git a/bitcoincore/src/lib.rs b/bitcoincore/src/lib.rs new file mode 100644 index 0000000..b3ca0e5 --- /dev/null +++ b/bitcoincore/src/lib.rs @@ -0,0 +1,735 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Txid}; +use ini::Ini; +use reqwest::blocking::Client; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use stealth_model::error::AnalysisError; +use stealth_model::gateway::{ + BlockchainGateway, DecodedTransaction, DescriptorType, ResolvedDescriptor, TxInputRef, + TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry, +}; +use stealth_model::types::btc_to_amount; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BitcoinCoreConfig { + pub network: String, + pub datadir: Option, + pub rpchost: String, + pub rpcport: u16, + pub rpcuser: Option, + pub rpcpassword: Option, +} + +impl BitcoinCoreConfig { + pub fn from_ini_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let ini = Ini::load_from_file(path) + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + let section = ini.section(Some("bitcoin")).ok_or_else(|| { + AnalysisError::EnvironmentUnavailable("missing [bitcoin] section".into()) + })?; + + let network = section + .get("network") + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "regtest".into()); + let datadir = section.get("datadir").and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else if Path::new(trimmed).is_absolute() { + Some(PathBuf::from(trimmed)) + } else { + Some( + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(trimmed), + ) + } + }); + + Ok(Self { + rpcport: section + .get("rpcport") + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| default_rpc_port(&network)), + rpchost: section + .get("rpchost") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "127.0.0.1".into()), + rpcuser: section + .get("rpcuser") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + rpcpassword: section + .get("rpcpassword") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + network, + datadir, + }) + } + + fn cookie_credentials(&self) -> Result<(String, String), AnalysisError> { + let datadir = self.datadir.as_ref().ok_or_else(|| { + AnalysisError::EnvironmentUnavailable("missing datadir for cookie auth".into()) + })?; + let mut candidates = Vec::new(); + if self.network == "mainnet" { + candidates.push(datadir.join(".cookie")); + } else { + candidates.push(datadir.join(&self.network).join(".cookie")); + candidates.push(datadir.join(".cookie")); + } + + for candidate in candidates { + if !candidate.exists() { + continue; + } + if let Ok(creds) = read_cookie_file(&candidate) { + return Ok(creds); + } + } + + Err(AnalysisError::EnvironmentUnavailable( + "could not locate a readable Bitcoin Core cookie file".into(), + )) + } +} + +/// Read a Bitcoin Core `.cookie` file, returning `(user, password)`. +/// +/// The cookie format is a single line of `__cookie__:hex_password`. +pub fn read_cookie_file(path: &Path) -> Result<(String, String), AnalysisError> { + let contents = fs::read_to_string(path).map_err(|e| { + AnalysisError::EnvironmentUnavailable(format!( + "cannot read cookie file {}: {e}", + path.display() + )) + })?; + let mut parts = contents.trim().splitn(2, ':'); + let user = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display())) + })? + .to_string(); + let pass = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display())) + })? + .to_string(); + Ok((user, pass)) +} + +pub struct BitcoinCoreRpc { + config: BitcoinCoreConfig, + client: Client, +} + +impl BitcoinCoreRpc { + pub fn new(config: BitcoinCoreConfig) -> Result { + let client = Client::builder() + .build() + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + Ok(Self { config, client }) + } + + /// Construct a gateway from a URL and optional credentials. + /// + /// This mirrors the env-var based configuration used by the HTTP + /// API (`STEALTH_RPC_URL`, `STEALTH_RPC_USER`, `STEALTH_RPC_PASS`). + pub fn from_url( + url: &str, + user: Option, + password: Option, + ) -> Result { + let (host, port) = parse_host_port_from_url(url); + let config = BitcoinCoreConfig { + network: infer_network_from_port(port), + datadir: None, + rpchost: host, + rpcport: port, + rpcuser: user, + rpcpassword: password, + }; + Self::new(config) + } + + fn rpc_url(&self, wallet: Option<&str>) -> String { + let base = format!("http://{}:{}", self.config.rpchost, self.config.rpcport); + wallet + .map(|wallet_name| format!("{base}/wallet/{}", urlencoding::encode(wallet_name))) + .unwrap_or(base) + } + + fn credentials(&self) -> Result<(String, String), AnalysisError> { + if let (Some(user), Some(password)) = + (self.config.rpcuser.clone(), self.config.rpcpassword.clone()) + { + Ok((user, password)) + } else { + self.config.cookie_credentials() + } + } + + fn call( + &self, + wallet: Option<&str>, + method: &str, + params: Vec, + ) -> Result { + let (user, password) = self.credentials()?; + let response = self + .client + .post(self.rpc_url(wallet)) + .basic_auth(user, Some(password)) + .json(&json!({ + "jsonrpc": "1.0", + "id": "stealth-rust", + "method": method, + "params": params, + })) + .send() + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + + if !response.status().is_success() { + return Err(AnalysisError::EnvironmentUnavailable(format!( + "rpc transport error: {}", + response.status() + ))); + } + + let envelope = response + .json::>() + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + match (envelope.result, envelope.error) { + (Some(result), None) => Ok(result), + (_, Some(error)) => Err(AnalysisError::EnvironmentUnavailable(error.message)), + _ => Err(AnalysisError::EnvironmentUnavailable( + "rpc returned neither result nor error".into(), + )), + } + } + + fn load_history_for_wallet(&self, wallet_name: &str) -> Result { + let wallet_txs = self.list_transactions(wallet_name)?; + let utxos = self.list_unspent(wallet_name)?; + let mut txids = wallet_txs + .iter() + .map(|entry| entry.txid) + .collect::>(); + txids.extend(utxos.iter().map(|utxo| utxo.txid)); + + let mut transactions = HashMap::new(); + let mut queue = txids.into_iter().collect::>(); + while let Some(txid) = queue.pop() { + if transactions.contains_key(&txid) { + continue; + } + let tx = self.decode_transaction(txid)?; + for input in &tx.vin { + if !input.coinbase && !transactions.contains_key(&input.previous_txid) { + queue.push(input.previous_txid); + } + } + transactions.insert(txid, tx); + } + + Ok(WalletHistory { + wallet_txs, + utxos, + transactions, + internal_addresses: HashSet::new(), + }) + } + + fn list_transactions(&self, wallet_name: &str) -> Result, AnalysisError> { + let entries = self.call::>( + Some(wallet_name), + "listtransactions", + vec![json!("*"), json!(10000), json!(0), json!(true)], + )?; + entries + .into_iter() + .map(|entry| { + let address: Option> = + entry.address.as_deref().and_then(|s| s.parse().ok()); + Ok(WalletTxEntry { + txid: parse_txid(&entry.txid)?, + address, + category: match entry.category.as_deref() { + Some("send") => WalletTxCategory::Send, + Some("receive") => WalletTxCategory::Receive, + _ => WalletTxCategory::Unknown, + }, + amount: btc_to_amount(entry.amount.abs()), + confirmations: entry.confirmations.unwrap_or_default(), + blockheight: entry.blockheight.unwrap_or_default(), + }) + }) + .collect() + } + + fn list_unspent(&self, wallet_name: &str) -> Result, AnalysisError> { + let utxos = self.call::>( + Some(wallet_name), + "listunspent", + vec![json!(0), json!(9_999_999)], + )?; + utxos + .into_iter() + .map(|utxo| { + let address: Option> = + utxo.address.as_deref().and_then(|s| s.parse().ok()); + Ok(Utxo { + txid: parse_txid(&utxo.txid)?, + vout: utxo.vout, + script_type: address + .as_ref() + .map(DescriptorType::infer_from_address) + .unwrap_or(DescriptorType::Unknown), + address, + amount: btc_to_amount(utxo.amount), + confirmations: utxo.confirmations.unwrap_or_default(), + }) + }) + .collect() + } + + fn decode_transaction(&self, txid: Txid) -> Result { + let tx = self.call::( + None, + "getrawtransaction", + vec![json!(txid.to_string()), json!(true)], + )?; + + Ok(DecodedTransaction { + txid: parse_txid(&tx.txid)?, + vin: tx + .vin + .into_iter() + .map(|input| { + Ok(TxInputRef { + previous_txid: match &input.txid { + Some(s) => parse_txid(s)?, + // Bitcoin protocol: coinbase inputs reference all-zeros. + None => parse_txid( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .expect("zero txid is always valid"), + }, + previous_vout: input.vout.unwrap_or_default(), + sequence: input.sequence.unwrap_or(0xffff_ffff), + coinbase: input.coinbase.is_some(), + }) + }) + .collect::, AnalysisError>>()?, + vout: tx + .vout + .into_iter() + .map(|output| { + let address: Option> = output + .script_pub_key + .address + .or_else(|| { + output + .script_pub_key + .addresses + .and_then(|mut items| items.pop()) + }) + .and_then(|s| s.parse().ok()); + TxOutput { + n: output.n, + script_type: address + .as_ref() + .map(DescriptorType::infer_from_address) + .or_else(|| { + output + .script_pub_key + .script_type + .as_deref() + .map(descriptor_type_from_script_pub_key) + }) + .unwrap_or(DescriptorType::Unknown), + address, + value: btc_to_amount(output.value), + } + }) + .collect(), + version: tx.version.unwrap_or(2), + locktime: tx.locktime.unwrap_or_default(), + vsize: tx.vsize.unwrap_or_default(), + confirmations: tx.confirmations.unwrap_or_default(), + }) + } + + fn create_watch_only_wallet(&self, wallet_name: &str) -> Result<(), AnalysisError> { + let _ = self.call::( + None, + "createwallet", + vec![ + json!(wallet_name), + json!(true), + json!(true), + json!(""), + json!(false), + json!(true), + ], + )?; + Ok(()) + } + + fn unload_wallet(&self, wallet_name: &str) { + let _ = self.call::(None, "unloadwallet", vec![json!(wallet_name)]); + } +} + +impl BlockchainGateway for BitcoinCoreRpc { + fn normalize_descriptor(&self, descriptor: &str) -> Result { + let response = + self.call::(None, "getdescriptorinfo", vec![json!(descriptor)])?; + Ok(response.descriptor) + } + + fn derive_addresses( + &self, + descriptor: &ResolvedDescriptor, + ) -> Result>, AnalysisError> { + let strings: Vec = self.call( + None, + "deriveaddresses", + vec![json!(descriptor.desc), json!([0, descriptor.range_end])], + )?; + strings + .into_iter() + .map(|s| { + s.parse::>().map_err(|e| { + AnalysisError::EnvironmentUnavailable(format!("invalid address '{s}': {e}")) + }) + }) + .collect() + } + + fn scan_descriptors( + &self, + descriptors: &[ResolvedDescriptor], + ) -> Result { + let wallet_name = format!( + "_stealth_scan_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))? + .as_millis() + ); + self.create_watch_only_wallet(&wallet_name)?; + + // RAII guard: ensure the temporary wallet is always unloaded, + // even if the body below returns an early error via `?`. + let _guard = WalletGuard { + rpc: self, + name: &wallet_name, + }; + + let imports = descriptors + .iter() + .map(|descriptor| { + let is_ranged = descriptor.desc.contains('*'); + let mut entry = json!({ + "desc": descriptor.desc, + "timestamp": 0, + "internal": descriptor.internal, + "active": is_ranged && descriptor.active, + }); + if is_ranged { + entry["range"] = json!([0, descriptor.range_end]); + } + entry + }) + .collect::>(); + + let import_results = self.call::>( + Some(&wallet_name), + "importdescriptors", + vec![json!(imports)], + )?; + if import_results.iter().any(|result| !result.success) { + let errors: Vec<_> = import_results + .iter() + .filter(|r| !r.success) + .filter_map(|r| r.error.as_ref().map(|e| e.message.as_str())) + .collect(); + return Err(AnalysisError::EnvironmentUnavailable(format!( + "descriptor import failed: {}", + errors.join("; ") + ))); + } + + let mut history = self.load_history_for_wallet(&wallet_name)?; + + // Derive internal (change) addresses so TxGraph can correctly + // mark them as internal in AddressInfo. + let mut internal_addresses = HashSet::new(); + for desc in descriptors { + if desc.internal { + if let Ok(addrs) = self.derive_addresses(desc) { + internal_addresses.extend(addrs); + } + } + } + history.internal_addresses = internal_addresses; + + Ok(history) + } + + fn list_wallet_descriptors( + &self, + wallet_name: &str, + ) -> Result, AnalysisError> { + let response = + self.call::(Some(wallet_name), "listdescriptors", Vec::new())?; + Ok(response + .descriptors + .into_iter() + .map(|descriptor| ResolvedDescriptor { + desc: descriptor.desc, + internal: descriptor.internal.unwrap_or(false), + active: descriptor.active.unwrap_or(true), + range_end: descriptor + .range + .map(|range| match range { + DescriptorRange::Single(value) => value, + DescriptorRange::Pair([_, end]) => end, + }) + .unwrap_or(999), + }) + .collect()) + } + + fn scan_wallet(&self, wallet_name: &str) -> Result { + let mut history = self.load_history_for_wallet(wallet_name)?; + + // Derive internal (change) addresses so TxGraph can correctly + // mark them in AddressInfo, mirroring the scan_descriptors path. + if let Ok(descriptors) = self.list_wallet_descriptors(wallet_name) { + let mut internal_addresses = HashSet::new(); + for desc in &descriptors { + if desc.internal { + if let Ok(addrs) = self.derive_addresses(desc) { + internal_addresses.extend(addrs); + } + } + } + history.internal_addresses = internal_addresses; + } + + Ok(history) + } + + fn known_wallet_txids(&self, wallet_names: &[String]) -> Result, AnalysisError> { + let mut txids = HashSet::new(); + for wallet_name in wallet_names { + txids.extend( + self.list_transactions(wallet_name)? + .into_iter() + .map(|entry| entry.txid), + ); + } + Ok(txids) + } + + fn get_transaction(&self, txid: Txid) -> Result { + self.decode_transaction(txid) + } +} + +/// RAII guard that calls `unloadwallet` when dropped, ensuring cleanup +/// even when an early `?` return skips the normal unload path. +struct WalletGuard<'a> { + rpc: &'a BitcoinCoreRpc, + name: &'a str, +} + +impl Drop for WalletGuard<'_> { + fn drop(&mut self) { + self.rpc.unload_wallet(self.name); + } +} + +fn parse_txid(s: &str) -> Result { + s.parse::() + .map_err(|e| AnalysisError::EnvironmentUnavailable(format!("invalid txid '{s}': {e}"))) +} + +fn default_rpc_port(network: &str) -> u16 { + match network { + "mainnet" => 8332, + "testnet" => 18332, + "signet" => 38332, + _ => 18443, + } +} + +fn descriptor_type_from_script_pub_key(script_type: &str) -> DescriptorType { + match script_type { + "witness_v0_keyhash" => DescriptorType::P2wpkh, + "witness_v1_taproot" => DescriptorType::P2tr, + "scripthash" => DescriptorType::P2sh, + "pubkeyhash" => DescriptorType::P2pkh, + _ => DescriptorType::Unknown, + } +} + +fn parse_host_port_from_url(url: &str) -> (String, u16) { + let without_scheme = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .unwrap_or(url); + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + match authority.rsplit_once(':') { + Some((host, port_str)) => { + let port = port_str.parse::().unwrap_or(8332); + (host.to_owned(), port) + } + None => (authority.to_owned(), 8332), + } +} + +fn infer_network_from_port(port: u16) -> String { + match port { + 8332 => "mainnet", + 18332 => "testnet", + 38332 => "signet", + 18443 => "regtest", + _ => "regtest", + } + .to_owned() +} + +#[derive(Debug, Deserialize)] +struct JsonRpcEnvelope { + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcError { + message: String, +} + +#[derive(Debug, Deserialize)] +struct DescriptorInfo { + descriptor: String, +} + +#[derive(Debug, Deserialize)] +struct ImportResult { + success: bool, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +struct ImportError { + #[serde(default)] + message: String, +} + +#[derive(Debug, Deserialize)] +struct ListDescriptorsResponse { + descriptors: Vec, +} + +#[derive(Debug, Deserialize)] +struct DescriptorRecord { + desc: String, + internal: Option, + active: Option, + range: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum DescriptorRange { + Single(u32), + Pair([u32; 2]), +} + +#[derive(Debug, Deserialize)] +struct ListTransactionEntry { + txid: String, + address: Option, + category: Option, + amount: f64, + confirmations: Option, + blockheight: Option, +} + +#[derive(Debug, Deserialize)] +struct ListUnspentEntry { + txid: String, + vout: u32, + address: Option, + amount: f64, + confirmations: Option, +} + +#[derive(Debug, Deserialize)] +struct RawTransaction { + txid: String, + vin: Vec, + vout: Vec, + version: Option, + locktime: Option, + vsize: Option, + confirmations: Option, +} + +#[derive(Debug, Deserialize)] +struct RawVin { + txid: Option, + vout: Option, + coinbase: Option, + sequence: Option, +} + +#[derive(Debug, Deserialize)] +struct RawVout { + value: f64, + n: u32, + #[serde(rename = "scriptPubKey")] + script_pub_key: RawScriptPubKey, +} + +#[derive(Debug, Deserialize)] +struct RawScriptPubKey { + address: Option, + addresses: Option>, + #[serde(rename = "type")] + script_type: Option, +} + +#[cfg(test)] +mod tests { + use super::default_rpc_port; + + #[test] + fn network_defaults_match_bitcoin_core_ports() { + assert_eq!(default_rpc_port("regtest"), 18443); + assert_eq!(default_rpc_port("testnet"), 18332); + assert_eq!(default_rpc_port("signet"), 38332); + assert_eq!(default_rpc_port("mainnet"), 8332); + } +}