refactor(model): convert core crate into model for types and interfaces

This commit is contained in:
Renato Britto
2026-03-25 22:26:52 -03:00
parent 0c511bf5bb
commit 0931bd3272
11 changed files with 773 additions and 1747 deletions
+1 -1
View File
@@ -1,4 +1,3 @@
backend/script/bitcoin-data/
node_modules/
dist/
.env
@@ -13,3 +12,4 @@ dist/
.qwen
**/__pycache__/
target/
Cargo.lock
Generated
-1735
View File
File diff suppressed because it is too large Load Diff
+23 -11
View File
@@ -1,20 +1,32 @@
[workspace]
members = [
"crates/stealth-app",
"crates/stealth-bitcoincore",
"crates/stealth-core",
"model",
]
resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
version = "0.1.0"
edition = "2021"
authors = [
"Breno Brito (brenorb)",
"Herberson Miranda (hsmiranda)",
"LORDBABUINO <lordbabuino@protonmail.com>",
"Renato Britto (satsfy) <0xsatsfy@gmail.com>",
]
license = "MIT"
repository = "https://github.com/stealth-bitcoin/stealth"
rust-version = "1.93.1"
[workspace.dependencies]
axum = "0.8"
clap = { version = "4.5", features = ["derive", "env"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal"] }
bitcoin = { version = "0.32.0", default-features = false, features = ["serde", "base64", "secp-recovery"] }
corepc-node = { version = "0.10.1", features = ["29_0"] }
serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
serde_json = "1.0.145"
thiserror = "2.0.17"
stealth-engine = { path = "engine" }
stealth-model = { path = "model" }
axum = "0.8.6"
tokio = { version = "1.48.0", features = ["macros", "net", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] }
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "stealth-model"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Domain model types for Stealth wallet privacy analysis"
categories = ["cryptography::cryptocurrencies"]
keywords = ["bitcoin", "privacy", "utxo"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
bitcoin = { workspace = true }
+89
View File
@@ -0,0 +1,89 @@
use std::collections::HashSet;
use bitcoin::Amount;
/// Identifies a specific detector for enable/disable configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectorId {
AddressReuse,
Cioh,
Dust,
DustSpending,
ChangeDetection,
Consolidation,
ScriptTypeMixing,
ClusterMerge,
UtxoAgeSpread,
ExchangeOrigin,
TaintedUtxoMerge,
BehavioralFingerprint,
}
/// Numeric thresholds used by the detectors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectorThresholds {
pub dust: Amount,
pub strict_dust: Amount,
pub normal_input_min: Amount,
pub consolidation_min_inputs: usize,
pub consolidation_max_outputs: usize,
pub utxo_age_spread_blocks: u32,
pub dormant_utxo_blocks: u32,
pub exchange_batch_min_outputs: usize,
pub dust_attack_min_outputs: usize,
pub dust_attack_min_dust_outputs: usize,
pub toxic_change_upper: Amount,
}
impl Default for DetectorThresholds {
fn default() -> Self {
Self {
dust: Amount::from_sat(1_000),
strict_dust: Amount::from_sat(546),
normal_input_min: Amount::from_sat(10_000),
consolidation_min_inputs: 3,
consolidation_max_outputs: 2,
utxo_age_spread_blocks: 10,
dormant_utxo_blocks: 100,
exchange_batch_min_outputs: 5,
dust_attack_min_outputs: 10,
dust_attack_min_dust_outputs: 5,
toxic_change_upper: Amount::from_sat(10_000),
}
}
}
/// Top-level analysis configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalysisConfig {
pub derivation_range_end: u32,
pub thresholds: DetectorThresholds,
pub enabled_detectors: HashSet<DetectorId>,
/// Maximum ancestor-fetch depth when resolving UTXO history.
/// `0` means only UTXO's own tx; `2` (the default)
pub max_ancestor_depth: u32,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
derivation_range_end: 999,
thresholds: DetectorThresholds::default(),
enabled_detectors: HashSet::from([
DetectorId::AddressReuse,
DetectorId::Cioh,
DetectorId::Dust,
DetectorId::DustSpending,
DetectorId::ChangeDetection,
DetectorId::Consolidation,
DetectorId::ScriptTypeMixing,
DetectorId::ClusterMerge,
DetectorId::UtxoAgeSpread,
DetectorId::ExchangeOrigin,
DetectorId::TaintedUtxoMerge,
DetectorId::BehavioralFingerprint,
]),
max_ancestor_depth: 2,
}
}
}
+116
View File
@@ -0,0 +1,116 @@
use crate::error::AnalysisError;
use crate::gateway::ResolvedDescriptor;
/// Trait for normalizing a raw descriptor string (e.g. via `getdescriptorinfo`).
pub trait DescriptorNormalizer {
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError>;
}
/// Normalize raw descriptor strings: strip checksums, infer receive/change
/// pairs (`/0/*` ↔ `/1/*`), deduplicate.
///
/// When a `normalizer` is provided (typically a [`BlockchainGateway`]),
/// each candidate is passed through `getdescriptorinfo` for canonical
/// checksumming.
pub fn normalize_descriptors(
raw_descriptors: &[String],
derivation_range_end: u32,
normalizer: &dyn DescriptorNormalizer,
) -> Result<Vec<ResolvedDescriptor>, AnalysisError> {
let mut resolved = Vec::new();
for raw in raw_descriptors {
let without_checksum = raw
.split('#')
.next()
.map(str::trim)
.unwrap_or_default()
.to_string();
if without_checksum.is_empty() {
return Err(AnalysisError::EmptyDescriptor);
}
let candidates = if without_checksum.contains("/0/*") {
vec![
(without_checksum.clone(), false),
(without_checksum.replace("/0/*", "/1/*"), true),
]
} else if without_checksum.contains("/1/*") {
vec![
(without_checksum.replace("/1/*", "/0/*"), false),
(without_checksum.clone(), true),
]
} else {
vec![(without_checksum.clone(), false)]
};
for (candidate, internal) in candidates {
let normalized = normalizer
.normalize(&candidate)
.map_err(|error| match error {
AnalysisError::DescriptorNormalization { .. } => error,
other => AnalysisError::DescriptorNormalization {
descriptor: candidate.clone(),
message: other.to_string(),
},
})?;
let descriptor = ResolvedDescriptor {
desc: normalized,
internal,
active: true,
range_end: derivation_range_end,
};
if !resolved.iter().any(|item| item == &descriptor) {
resolved.push(descriptor);
}
}
}
Ok(resolved)
}
/// Lightweight descriptor normalization that strips checksums and infers
/// receive/change pairs without calling an RPC normalizer.
///
/// Returns `(descriptor_string, is_internal)` pairs.
pub fn normalize_descriptors_raw(raw_descriptors: &[String]) -> Vec<(String, bool)> {
let mut result = Vec::new();
for raw in raw_descriptors {
let without_checksum = raw
.split('#')
.next()
.map(str::trim)
.unwrap_or_default()
.to_string();
if without_checksum.is_empty() {
continue;
}
let candidates = if without_checksum.contains("/0/*") {
vec![
(without_checksum.clone(), false),
(without_checksum.replace("/0/*", "/1/*"), true),
]
} else if without_checksum.contains("/1/*") {
vec![
(without_checksum.replace("/1/*", "/0/*"), false),
(without_checksum.clone(), true),
]
} else {
vec![(without_checksum, false)]
};
for pair in candidates {
if !result.contains(&pair) {
result.push(pair);
}
}
}
result
}
+14
View File
@@ -0,0 +1,14 @@
use thiserror::Error;
/// Errors from the analysis pipeline.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum AnalysisError {
#[error("descriptor input cannot be empty")]
EmptyDescriptor,
#[error("descriptor `{descriptor}` failed normalization: {message}")]
DescriptorNormalization { descriptor: String, message: String },
#[error("environment unavailable: {0}")]
EnvironmentUnavailable(String),
#[error("analysis execution failed: {0}")]
Execution(String),
}
+218
View File
@@ -0,0 +1,218 @@
use std::collections::{HashMap, HashSet};
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use serde::{Deserialize, Serialize};
use crate::descriptor::DescriptorNormalizer;
use crate::error::AnalysisError;
use crate::types::{serde_addr, serde_addr_opt, serde_addr_set};
/// Abstraction over a blockchain data source (e.g. Bitcoin Core RPC).
///
/// Implementations provide descriptor normalization, address derivation,
/// wallet scanning, and transaction history retrieval. This trait decouples
/// domain logic from the concrete RPC transport, making it possible to
/// test with mocks.
pub trait BlockchainGateway {
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError>;
fn derive_addresses(
&self,
descriptor: &ResolvedDescriptor,
) -> Result<Vec<Address<NetworkUnchecked>>, AnalysisError>;
fn scan_descriptors(
&self,
descriptors: &[ResolvedDescriptor],
) -> Result<WalletHistory, AnalysisError>;
fn list_wallet_descriptors(
&self,
wallet_name: &str,
) -> Result<Vec<ResolvedDescriptor>, AnalysisError>;
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError>;
fn known_wallet_txids(&self, wallet_names: &[String]) -> Result<HashSet<Txid>, AnalysisError>;
fn get_transaction(&self, txid: Txid) -> Result<DecodedTransaction, AnalysisError>;
}
/// Blanket implementation: any `BlockchainGateway` is also a
/// `DescriptorNormalizer`.
impl<T> DescriptorNormalizer for T
where
T: BlockchainGateway + ?Sized,
{
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError> {
self.normalize_descriptor(descriptor)
}
}
// ── Gateway model types ─────────────────────────────────────────────────────
/// A descriptor that has been normalized and resolved for import.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedDescriptor {
pub desc: String,
pub internal: bool,
pub active: bool,
pub range_end: u32,
}
/// Role of a descriptor chain (external receive vs internal change).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorChainRole {
External,
Internal,
}
/// Script/address type derived from a descriptor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorType {
P2wpkh,
P2tr,
P2shP2wpkh,
P2sh,
P2pkh,
Unknown,
}
impl DescriptorType {
pub fn from_descriptor(descriptor: &str) -> Self {
if descriptor.starts_with("wpkh(") {
Self::P2wpkh
} else if descriptor.starts_with("tr(") {
Self::P2tr
} else if descriptor.starts_with("sh(wpkh(") {
Self::P2shP2wpkh
} else if descriptor.starts_with("pkh(") {
Self::P2pkh
} else {
Self::Unknown
}
}
pub fn infer_from_address(address: &Address<NetworkUnchecked>) -> Self {
let script = address.clone().assume_checked().script_pubkey();
if script.is_p2wpkh() {
Self::P2wpkh
} else if script.is_p2tr() {
Self::P2tr
} else if script.is_p2sh() {
Self::P2sh
} else if script.is_p2pkh() {
Self::P2pkh
} else {
Self::Unknown
}
}
pub fn as_script_name(self) -> &'static str {
match self {
Self::P2wpkh => "witness_v0_keyhash",
Self::P2tr => "witness_v1_taproot",
Self::P2shP2wpkh | Self::P2sh => "scripthash",
Self::P2pkh => "pubkeyhash",
Self::Unknown => "unknown",
}
}
}
/// A derived address with metadata about its origin descriptor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DerivedAddress {
#[serde(with = "serde_addr")]
pub address: Address<NetworkUnchecked>,
pub descriptor_type: DescriptorType,
pub chain_role: DescriptorChainRole,
pub derivation_index: u32,
}
/// Wallet transaction category.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WalletTxCategory {
Send,
Receive,
Unknown,
}
/// A wallet transaction entry (from `listtransactions`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalletTxEntry {
pub txid: Txid,
#[serde(with = "serde_addr_opt")]
pub address: Option<Address<NetworkUnchecked>>,
pub category: WalletTxCategory,
pub amount: Amount,
pub confirmations: u32,
pub blockheight: u32,
}
/// An input reference within a decoded transaction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TxInputRef {
#[serde(rename = "txid")]
pub previous_txid: Txid,
#[serde(rename = "vout")]
pub previous_vout: u32,
pub sequence: u32,
pub coinbase: bool,
}
/// A transaction output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TxOutput {
pub n: u32,
#[serde(with = "serde_addr_opt")]
pub address: Option<Address<NetworkUnchecked>>,
pub value: Amount,
pub script_type: DescriptorType,
}
/// A fully decoded transaction with inputs and outputs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DecodedTransaction {
pub txid: Txid,
pub vin: Vec<TxInputRef>,
pub vout: Vec<TxOutput>,
pub version: i32,
pub locktime: u32,
pub vsize: u32,
pub confirmations: u32,
}
/// A current unspent transaction output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Utxo {
pub txid: Txid,
pub vout: u32,
#[serde(with = "serde_addr_opt")]
pub address: Option<Address<NetworkUnchecked>>,
pub amount: Amount,
pub confirmations: u32,
pub script_type: DescriptorType,
}
/// Complete wallet history with transactions and UTXOs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalletHistory {
pub wallet_txs: Vec<WalletTxEntry>,
pub utxos: Vec<Utxo>,
pub transactions: HashMap<Txid, DecodedTransaction>,
/// Addresses known to belong to internal (change) descriptor chains.
/// Populated by the descriptor scan path; may be empty for wallet scans.
#[serde(default, with = "serde_addr_set")]
pub internal_addresses: HashSet<Address<NetworkUnchecked>>,
}
/// A participant (input or output) in a transaction, enriched with
/// ownership information.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TransactionParticipant {
#[serde(with = "serde_addr")]
pub address: Address<NetworkUnchecked>,
pub value: Amount,
pub script_type: DescriptorType,
pub is_ours: bool,
pub funding_txid: Option<Txid>,
pub funding_vout: Option<u32>,
}
+8
View File
@@ -0,0 +1,8 @@
pub mod config;
pub mod descriptor;
pub mod error;
pub mod gateway;
pub mod scan;
pub mod types;
pub use types::*;
+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashSet;
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use serde::{Deserialize, Serialize};
use crate::config::AnalysisConfig;
/// What to scan.
#[derive(Debug, Clone)]
pub enum ScanTarget {
Descriptor(String),
Descriptors(Vec<String>),
Utxos(Vec<UtxoInput>),
}
/// A raw UTXO to analyse.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UtxoInput {
pub txid: Txid,
pub vout: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Amount>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::serde_addr_opt"
)]
pub address: Option<Address<NetworkUnchecked>>,
}
/// Top-level settings for the analysis engine, combining detector config
/// with optional known-wallet hooks used by taint and exchange detectors.
#[derive(Debug, Clone, Default)]
pub struct EngineSettings {
pub config: AnalysisConfig,
pub known_risky_txids: Option<HashSet<Txid>>,
pub known_exchange_txids: Option<HashSet<Txid>>,
}
+248
View File
@@ -0,0 +1,248 @@
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::gateway::WalletTxCategory;
/// Serde helper: serialize an [`Address<NetworkUnchecked>`] via its checked
/// display representation. Deserialization delegates to the standard
/// `Address<NetworkUnchecked>` deserializer.
pub mod serde_addr {
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(addr: &Address<NetworkUnchecked>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.collect_str(addr.assume_checked_ref())
}
pub fn deserialize<'de, D>(d: D) -> Result<Address<NetworkUnchecked>, D::Error>
where
D: Deserializer<'de>,
{
Address::<NetworkUnchecked>::deserialize(d)
}
}
/// Serde helper for `Option<Address<NetworkUnchecked>>`.
pub mod serde_addr_opt {
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(addr: &Option<Address<NetworkUnchecked>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match addr {
Some(a) => s.collect_str(a.assume_checked_ref()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D>(d: D) -> Result<Option<Address<NetworkUnchecked>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Address<NetworkUnchecked>>::deserialize(d)
}
}
/// Serde helper for `HashSet<Address<NetworkUnchecked>>`.
pub mod serde_addr_set {
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::{self, Deserialize, Deserializer, Serializer};
use std::collections::HashSet;
pub fn serialize<S>(addrs: &HashSet<Address<NetworkUnchecked>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(addrs.len()))?;
for addr in addrs {
seq.serialize_element(&addr.assume_checked_ref().to_string())?;
}
seq.end()
}
pub fn deserialize<'de, D>(d: D) -> Result<HashSet<Address<NetworkUnchecked>>, D::Error>
where
D: Deserializer<'de>,
{
let strings: Vec<String> = Vec::deserialize(d)?;
strings
.into_iter()
.map(|s| {
s.parse::<Address<NetworkUnchecked>>()
.map_err(serde::de::Error::custom)
})
.collect()
}
}
/// Severity levels for privacy vulnerability findings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
impl core::fmt::Display for Severity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Severity::Low => write!(f, "LOW"),
Severity::Medium => write!(f, "MEDIUM"),
Severity::High => write!(f, "HIGH"),
Severity::Critical => write!(f, "CRITICAL"),
}
}
}
/// The category of privacy vulnerability detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum VulnerabilityType {
AddressReuse,
Cioh,
Dust,
DustSpending,
ChangeDetection,
Consolidation,
ScriptTypeMixing,
ClusterMerge,
UtxoAgeSpread,
DormantUtxos,
ExchangeOrigin,
TaintedUtxoMerge,
DirectTaint,
BehavioralFingerprint,
}
impl core::fmt::Display for VulnerabilityType {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::AddressReuse => write!(f, "ADDRESS_REUSE"),
Self::Cioh => write!(f, "CIOH"),
Self::Dust => write!(f, "DUST"),
Self::DustSpending => write!(f, "DUST_SPENDING"),
Self::ChangeDetection => write!(f, "CHANGE_DETECTION"),
Self::Consolidation => write!(f, "CONSOLIDATION"),
Self::ScriptTypeMixing => write!(f, "SCRIPT_TYPE_MIXING"),
Self::ClusterMerge => write!(f, "CLUSTER_MERGE"),
Self::UtxoAgeSpread => write!(f, "UTXO_AGE_SPREAD"),
Self::DormantUtxos => write!(f, "DORMANT_UTXOS"),
Self::ExchangeOrigin => write!(f, "EXCHANGE_ORIGIN"),
Self::TaintedUtxoMerge => write!(f, "TAINTED_UTXO_MERGE"),
Self::DirectTaint => write!(f, "DIRECT_TAINT"),
Self::BehavioralFingerprint => write!(f, "BEHAVIORAL_FINGERPRINT"),
}
}
}
/// A single privacy vulnerability finding.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
#[serde(rename = "type")]
pub vulnerability_type: VulnerabilityType,
pub severity: Severity,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub correction: Option<String>,
}
/// Aggregate statistics about the scan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stats {
pub transactions_analyzed: usize,
pub addresses_seen: usize,
pub utxos_current: usize,
}
/// Summary of the scan results.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Summary {
pub findings: usize,
pub warnings: usize,
pub clean: bool,
}
/// The complete vulnerability scan report.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub stats: Stats,
pub findings: Vec<Finding>,
pub warnings: Vec<Finding>,
pub summary: Summary,
}
impl Report {
/// Construct a report from collected findings and warnings.
pub fn new(stats: Stats, findings: Vec<Finding>, warnings: Vec<Finding>) -> Self {
let summary = Summary {
findings: findings.len(),
warnings: warnings.len(),
clean: findings.is_empty() && warnings.is_empty(),
};
Report {
stats,
findings,
warnings,
summary,
}
}
}
/// Convert a BTC f64 value to an [`Amount`].
pub fn btc_to_amount(btc: f64) -> Amount {
Amount::from_sat((btc * 1e8).round() as u64)
}
/// Metadata about a derived address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddressInfo {
/// The script type (e.g. "p2wpkh", "p2tr", "p2sh", "p2wsh", "p2pkh").
pub script_type: String,
/// Whether this is a change (internal) address.
pub internal: bool,
/// The derivation index.
pub index: usize,
}
/// Information about a transaction input, resolved from the parent transaction.
#[derive(Debug, Clone)]
pub struct InputInfo {
pub address: Address<NetworkUnchecked>,
pub value: Amount,
pub funding_txid: Txid,
pub funding_vout: u32,
}
/// Information about a transaction output.
#[derive(Debug, Clone)]
pub struct OutputInfo {
pub address: Address<NetworkUnchecked>,
pub value: Amount,
pub index: u32,
pub script_type: String,
}
/// A wallet transaction entry (from `listtransactions`).
#[derive(Debug, Clone)]
pub struct WalletTx {
pub txid: Txid,
pub address: Address<NetworkUnchecked>,
pub category: WalletTxCategory,
pub amount: Amount,
pub confirmations: u32,
}