mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-11 06:43:31 -07:00
refactor(model): convert core crate into model for types and interfaces
This commit is contained in:
+1
-1
@@ -1,4 +1,3 @@
|
||||
backend/script/bitcoin-data/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
@@ -13,3 +12,4 @@ dist/
|
||||
.qwen
|
||||
**/__pycache__/
|
||||
target/
|
||||
Cargo.lock
|
||||
Generated
-1735
File diff suppressed because it is too large
Load Diff
+23
-11
@@ -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"] }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user