mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-12 15:23:30 -07:00
86335e8467
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.
736 lines
24 KiB
Rust
736 lines
24 KiB
Rust
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<PathBuf>,
|
|
pub rpchost: String,
|
|
pub rpcport: u16,
|
|
pub rpcuser: Option<String>,
|
|
pub rpcpassword: Option<String>,
|
|
}
|
|
|
|
impl BitcoinCoreConfig {
|
|
pub fn from_ini_file(path: impl AsRef<Path>) -> Result<Self, AnalysisError> {
|
|
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::<u16>().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<Self, AnalysisError> {
|
|
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<String>,
|
|
password: Option<String>,
|
|
) -> Result<Self, AnalysisError> {
|
|
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<T: DeserializeOwned>(
|
|
&self,
|
|
wallet: Option<&str>,
|
|
method: &str,
|
|
params: Vec<Value>,
|
|
) -> Result<T, AnalysisError> {
|
|
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::<JsonRpcEnvelope<T>>()
|
|
.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<WalletHistory, AnalysisError> {
|
|
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::<HashSet<_>>();
|
|
txids.extend(utxos.iter().map(|utxo| utxo.txid));
|
|
|
|
let mut transactions = HashMap::new();
|
|
let mut queue = txids.into_iter().collect::<Vec<_>>();
|
|
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<Vec<WalletTxEntry>, AnalysisError> {
|
|
let entries = self.call::<Vec<ListTransactionEntry>>(
|
|
Some(wallet_name),
|
|
"listtransactions",
|
|
vec![json!("*"), json!(10000), json!(0), json!(true)],
|
|
)?;
|
|
entries
|
|
.into_iter()
|
|
.map(|entry| {
|
|
let address: Option<Address<NetworkUnchecked>> =
|
|
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<Vec<Utxo>, AnalysisError> {
|
|
let utxos = self.call::<Vec<ListUnspentEntry>>(
|
|
Some(wallet_name),
|
|
"listunspent",
|
|
vec![json!(0), json!(9_999_999)],
|
|
)?;
|
|
utxos
|
|
.into_iter()
|
|
.map(|utxo| {
|
|
let address: Option<Address<NetworkUnchecked>> =
|
|
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<DecodedTransaction, AnalysisError> {
|
|
let tx = self.call::<RawTransaction>(
|
|
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::<Result<Vec<_>, AnalysisError>>()?,
|
|
vout: tx
|
|
.vout
|
|
.into_iter()
|
|
.map(|output| {
|
|
let address: Option<Address<NetworkUnchecked>> = 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::<Value>(
|
|
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::<Value>(None, "unloadwallet", vec![json!(wallet_name)]);
|
|
}
|
|
}
|
|
|
|
impl BlockchainGateway for BitcoinCoreRpc {
|
|
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError> {
|
|
let response =
|
|
self.call::<DescriptorInfo>(None, "getdescriptorinfo", vec![json!(descriptor)])?;
|
|
Ok(response.descriptor)
|
|
}
|
|
|
|
fn derive_addresses(
|
|
&self,
|
|
descriptor: &ResolvedDescriptor,
|
|
) -> Result<Vec<Address<NetworkUnchecked>>, AnalysisError> {
|
|
let strings: Vec<String> = self.call(
|
|
None,
|
|
"deriveaddresses",
|
|
vec![json!(descriptor.desc), json!([0, descriptor.range_end])],
|
|
)?;
|
|
strings
|
|
.into_iter()
|
|
.map(|s| {
|
|
s.parse::<Address<NetworkUnchecked>>().map_err(|e| {
|
|
AnalysisError::EnvironmentUnavailable(format!("invalid address '{s}': {e}"))
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn scan_descriptors(
|
|
&self,
|
|
descriptors: &[ResolvedDescriptor],
|
|
) -> Result<WalletHistory, AnalysisError> {
|
|
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::<Vec<_>>();
|
|
|
|
let import_results = self.call::<Vec<ImportResult>>(
|
|
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<Vec<ResolvedDescriptor>, AnalysisError> {
|
|
let response =
|
|
self.call::<ListDescriptorsResponse>(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<WalletHistory, AnalysisError> {
|
|
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<HashSet<Txid>, 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<DecodedTransaction, AnalysisError> {
|
|
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<Txid, AnalysisError> {
|
|
s.parse::<Txid>()
|
|
.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::<u16>().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<T> {
|
|
result: Option<T>,
|
|
error: Option<JsonRpcError>,
|
|
}
|
|
|
|
#[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<ImportError>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ImportError {
|
|
#[serde(default)]
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ListDescriptorsResponse {
|
|
descriptors: Vec<DescriptorRecord>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DescriptorRecord {
|
|
desc: String,
|
|
internal: Option<bool>,
|
|
active: Option<bool>,
|
|
range: Option<DescriptorRange>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum DescriptorRange {
|
|
Single(u32),
|
|
Pair([u32; 2]),
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ListTransactionEntry {
|
|
txid: String,
|
|
address: Option<String>,
|
|
category: Option<String>,
|
|
amount: f64,
|
|
confirmations: Option<u32>,
|
|
blockheight: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ListUnspentEntry {
|
|
txid: String,
|
|
vout: u32,
|
|
address: Option<String>,
|
|
amount: f64,
|
|
confirmations: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawTransaction {
|
|
txid: String,
|
|
vin: Vec<RawVin>,
|
|
vout: Vec<RawVout>,
|
|
version: Option<i32>,
|
|
locktime: Option<u32>,
|
|
vsize: Option<u32>,
|
|
confirmations: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawVin {
|
|
txid: Option<String>,
|
|
vout: Option<u32>,
|
|
coinbase: Option<String>,
|
|
sequence: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawVout {
|
|
value: f64,
|
|
n: u32,
|
|
#[serde(rename = "scriptPubKey")]
|
|
script_pub_key: RawScriptPubKey,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawScriptPubKey {
|
|
address: Option<String>,
|
|
addresses: Option<Vec<String>>,
|
|
#[serde(rename = "type")]
|
|
script_type: Option<String>,
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|