feat(engine): create engine rust package for detectors and orchestration

This commit is contained in:
Renato Britto
2026-03-25 22:27:26 -03:00
parent 86335e8467
commit b8c196f8eb
8 changed files with 2315 additions and 0 deletions
+1009
View File
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
//! Canonical analysis pipeline.
//!
//! [`AnalysisEngine`] is the primary entry point for running a privacy
//! scan. It accepts a [`BlockchainGateway`] for data access and routes
//! every scan request through the shared gateway abstraction, ensuring a
//! single execution path for HTTP, CLI, and library consumers.
use std::collections::{HashMap, HashSet};
use bitcoin::{Amount, Txid};
use crate::descriptor::normalize_descriptors;
use crate::error::AnalysisError;
use crate::gateway::{
BlockchainGateway, DecodedTransaction, DescriptorType, Utxo, WalletHistory, WalletTxCategory,
WalletTxEntry,
};
use crate::graph::TxGraph;
use crate::types::Report;
pub use stealth_model::scan::{EngineSettings, ScanTarget, UtxoInput};
// ── Engine ──────────────────────────────────────────────────────────────────
/// Runs a privacy analysis through a [`BlockchainGateway`].
///
/// Construct one per request (or per CLI invocation) and call
/// [`analyze`](Self::analyze).
pub struct AnalysisEngine<'a, G: BlockchainGateway> {
gateway: &'a G,
settings: EngineSettings,
}
impl<G: BlockchainGateway> std::fmt::Debug for AnalysisEngine<'_, G> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnalysisEngine")
.field("settings", &self.settings)
.finish_non_exhaustive()
}
}
impl<'a, G: BlockchainGateway> AnalysisEngine<'a, G> {
pub fn new(gateway: &'a G, settings: EngineSettings) -> Self {
Self { gateway, settings }
}
/// Run a full privacy scan for the given target.
pub fn analyze(&self, target: ScanTarget) -> Result<Report, AnalysisError> {
match target {
ScanTarget::Descriptor(d) => self.analyze_descriptors(vec![d]),
ScanTarget::Descriptors(ds) => self.analyze_descriptors(ds),
ScanTarget::Utxos(utxos) => self.analyze_utxos(utxos),
}
}
// ── descriptor path ─────────────────────────────────────────────────
fn analyze_descriptors(&self, raw_descriptors: Vec<String>) -> Result<Report, AnalysisError> {
let resolved = normalize_descriptors(
&raw_descriptors,
self.settings.config.derivation_range_end,
self.gateway,
)?;
let history = self.gateway.scan_descriptors(&resolved)?;
let graph = TxGraph::from_wallet_history(history);
Ok(graph.detect_all(
&self.settings.config.thresholds,
self.settings.known_risky_txids.as_ref(),
self.settings.known_exchange_txids.as_ref(),
))
}
// ── UTXO path ───────────────────────────────────────────────────────
fn analyze_utxos(&self, utxos: Vec<UtxoInput>) -> Result<Report, AnalysisError> {
let history = self.resolve_utxo_history(&utxos)?;
let graph = TxGraph::from_wallet_history(history);
Ok(graph.detect_all(
&self.settings.config.thresholds,
self.settings.known_risky_txids.as_ref(),
self.settings.known_exchange_txids.as_ref(),
))
}
/// Build a [`WalletHistory`] from raw UTXO inputs by fetching the
/// referenced transactions (and their parents) through the gateway.
fn resolve_utxo_history(&self, utxos: &[UtxoInput]) -> Result<WalletHistory, AnalysisError> {
let mut wallet_txs = Vec::new();
let mut utxo_entries = Vec::new();
let mut transactions: HashMap<Txid, DecodedTransaction> = HashMap::new();
let mut fetch_queue: Vec<Txid> = Vec::new();
for utxo in utxos {
// Fetch the UTXO's parent transaction.
if let std::collections::hash_map::Entry::Vacant(e) = transactions.entry(utxo.txid) {
let tx = self.gateway.get_transaction(utxo.txid)?;
fetch_queue.extend(
tx.vin
.iter()
.filter(|i| !i.coinbase)
.map(|i| i.previous_txid),
);
e.insert(tx);
}
let tx = &transactions[&utxo.txid];
let address = utxo.address.clone().or_else(|| {
tx.vout
.iter()
.find(|o| o.n == utxo.vout)
.and_then(|o| o.address.clone())
});
let value = utxo.value.unwrap_or_else(|| {
tx.vout
.iter()
.find(|o| o.n == utxo.vout)
.map(|o| o.value)
.unwrap_or(Amount::ZERO)
});
if address.is_some() {
wallet_txs.push(WalletTxEntry {
txid: utxo.txid,
address: address.clone(),
category: WalletTxCategory::Receive,
amount: value,
confirmations: 0,
blockheight: 0,
});
}
utxo_entries.push(Utxo {
txid: utxo.txid,
vout: utxo.vout,
address,
amount: value,
confirmations: 0,
script_type: DescriptorType::Unknown,
});
}
// Fetch ancestor transactions for input resolution, bounded by
// max_ancestor_depth to prevent unbounded graph traversal.
// A depth of 0 means we only keep the UTXO's own transaction.
let max_depth = self.settings.config.max_ancestor_depth;
if max_depth > 0 {
let mut depth_queue: Vec<(Txid, u32)> =
fetch_queue.into_iter().map(|txid| (txid, 1)).collect();
while let Some((txid, depth)) = depth_queue.pop() {
if transactions.contains_key(&txid) {
continue;
}
if let Ok(tx) = self.gateway.get_transaction(txid) {
if depth < max_depth {
depth_queue.extend(
tx.vin
.iter()
.filter(|i| !i.coinbase)
.map(|i| (i.previous_txid, depth + 1)),
);
}
transactions.insert(txid, tx);
}
}
}
Ok(WalletHistory {
wallet_txs,
utxos: utxo_entries,
transactions,
internal_addresses: HashSet::new(),
})
}
}
+226
View File
@@ -0,0 +1,226 @@
use std::collections::{HashMap, HashSet};
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use crate::gateway::{DecodedTransaction, WalletHistory, WalletTxCategory};
use crate::types::{AddressInfo, InputInfo, OutputInfo, WalletTx};
/// Indexed view of all transactions touching a wallet's address set.
///
/// All caches are populated up-front from a [`WalletHistory`] so no live
/// RPC connection is needed at detection time.
#[derive(Debug)]
pub struct TxGraph {
/// Map of our addresses → metadata.
pub addr_map: HashMap<Address<NetworkUnchecked>, AddressInfo>,
/// All our addresses (quick lookup).
pub our_addrs: HashSet<Address<NetworkUnchecked>>,
/// Current UTXOs from `listunspent`.
pub utxos: Vec<UtxoEntry>,
/// Transaction IDs that touch our wallet.
pub our_txids: HashSet<Txid>,
/// Per-address transaction entries.
pub addr_txs: HashMap<Address<NetworkUnchecked>, Vec<WalletTx>>,
/// Per-txid set of our addresses involved.
pub tx_addrs: HashMap<Txid, HashSet<Address<NetworkUnchecked>>>,
/// Decoded transactions keyed by txid.
pub tx_cache: HashMap<Txid, DecodedTransaction>,
/// Cached input addresses per txid.
pub input_cache: HashMap<Txid, Vec<InputInfo>>,
/// Cached output addresses per txid.
pub output_cache: HashMap<Txid, Vec<OutputInfo>>,
}
/// A UTXO entry from `listunspent`.
#[derive(Debug, Clone)]
pub struct UtxoEntry {
pub txid: Txid,
pub vout: u32,
pub address: Address<NetworkUnchecked>,
pub amount: Amount,
pub confirmations: u32,
}
impl TxGraph {
/// Check whether an address belongs to our wallet.
pub fn is_ours(&self, address: &Address<NetworkUnchecked>) -> bool {
self.our_addrs.contains(address)
}
/// Get the script type for an address.
pub fn script_type(&self, address: &Address<NetworkUnchecked>) -> String {
self.addr_map
.get(address)
.map(|info| info.script_type.clone())
.unwrap_or_else(|| script_type_from_address(address))
}
/// Look up a decoded transaction by txid.
pub fn fetch_tx(&self, txid: &Txid) -> Option<&DecodedTransaction> {
self.tx_cache.get(txid)
}
/// Get all input addresses for a transaction.
pub fn get_input_addresses(&self, txid: &Txid) -> Vec<InputInfo> {
self.input_cache.get(txid).cloned().unwrap_or_default()
}
/// Get all output addresses for a transaction.
pub fn get_output_addresses(&self, txid: &Txid) -> Vec<OutputInfo> {
self.output_cache.get(txid).cloned().unwrap_or_default()
}
/// Build a [`TxGraph`] from a pre-fetched [`WalletHistory`] produced
/// by a [`BlockchainGateway`](crate::gateway::BlockchainGateway).
///
/// All transaction caches are populated up-front so no live RPC
/// connection is needed.
pub fn from_wallet_history(history: WalletHistory) -> Self {
let mut our_addrs = HashSet::new();
let mut addr_map = HashMap::new();
let mut our_txids = HashSet::new();
let mut addr_txs: HashMap<Address<NetworkUnchecked>, Vec<WalletTx>> = HashMap::new();
let mut tx_addrs: HashMap<Txid, HashSet<Address<NetworkUnchecked>>> = HashMap::new();
for entry in &history.wallet_txs {
our_txids.insert(entry.txid);
let address = match &entry.address {
Some(addr) => addr,
None => continue,
};
let wtx = WalletTx {
txid: entry.txid,
address: address.clone(),
category: entry.category,
amount: entry.amount,
confirmations: entry.confirmations,
};
if entry.category != WalletTxCategory::Send {
our_addrs.insert(address.clone());
addr_map
.entry(address.clone())
.or_insert_with(|| AddressInfo {
script_type: script_type_from_address(address),
internal: history.internal_addresses.contains(address),
index: 0,
});
}
addr_txs.entry(address.clone()).or_default().push(wtx);
tx_addrs
.entry(entry.txid)
.or_default()
.insert(address.clone());
}
let utxos: Vec<UtxoEntry> = history
.utxos
.iter()
.filter_map(|u| {
let address = u.address.clone()?;
our_addrs.insert(address.clone());
addr_map
.entry(address.clone())
.or_insert_with(|| AddressInfo {
script_type: script_type_from_address(&address),
internal: history.internal_addresses.contains(&address),
index: 0,
});
Some(UtxoEntry {
txid: u.txid,
vout: u.vout,
address,
amount: u.amount,
confirmations: u.confirmations,
})
})
.collect();
// Pre-populate caches from decoded transactions.
let mut tx_cache = HashMap::new();
let mut input_cache: HashMap<Txid, Vec<InputInfo>> = HashMap::new();
let mut output_cache: HashMap<Txid, Vec<OutputInfo>> = HashMap::new();
for (txid, tx) in &history.transactions {
tx_cache.insert(*txid, tx.clone());
let inputs: Vec<InputInfo> = tx
.vin
.iter()
.filter_map(|input| {
if input.coinbase {
return None;
}
let parent = history.transactions.get(&input.previous_txid)?;
let out = parent.vout.iter().find(|o| o.n == input.previous_vout)?;
let address = out.address.clone()?;
Some(InputInfo {
address,
value: out.value,
funding_txid: input.previous_txid,
funding_vout: input.previous_vout,
})
})
.collect();
input_cache.insert(*txid, inputs);
let outputs: Vec<OutputInfo> = tx
.vout
.iter()
.filter_map(|out| {
let address = out.address.clone()?;
Some(OutputInfo {
address: address.clone(),
value: out.value,
index: out.n,
script_type: script_type_from_address(&address),
})
})
.collect();
output_cache.insert(*txid, outputs);
}
TxGraph {
addr_map,
our_addrs,
utxos,
our_txids,
addr_txs,
tx_addrs,
tx_cache,
input_cache,
output_cache,
}
}
}
/// Determine script type by decoding the address and inspecting the
/// resulting script.
///
/// * `bc1q` / `tb1q` / `bcrt1q` with a 20-byte program → **p2wpkh**
/// * `bc1q` / `tb1q` / `bcrt1q` with a 32-byte program → **p2wsh**
/// * `bc1p` / `tb1p` / `bcrt1p` → **p2tr**
/// * Base58 `1`/`m`/`n` (version 0x00/0x6f) → **p2pkh**
/// * Base58 `3`/`2` (version 0x05/0xc4) → **p2sh** (we *cannot* know if it
/// wraps p2wpkh, p2wsh, or bare multisig without the redeem script)
pub fn script_type_from_address(address: &Address<NetworkUnchecked>) -> String {
let addr = address.clone().assume_checked();
let script = addr.script_pubkey();
if script.is_p2pkh() {
"p2pkh".into()
} else if script.is_p2sh() {
"p2sh".into()
} else if script.is_p2wpkh() {
"p2wpkh".into()
} else if script.is_p2wsh() {
"p2wsh".into()
} else if script.is_p2tr() {
"p2tr".into()
} else {
"unknown".into()
}
}
+46
View File
@@ -0,0 +1,46 @@
//! # stealth-engine
//!
//! Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's
//! transaction history through a [`BlockchainGateway`](gateway::BlockchainGateway).
//!
//! The canonical execution path is:
//!
//! ```text
//! AnalysisEngine + BlockchainGateway → Report
//! ```
//!
//! Construct an [`AnalysisEngine`] with a concrete gateway implementation,
//! then call [`AnalysisEngine::analyze`] with a [`ScanTarget`].
//!
//! Results are returned as a structured [`Report`] that can be serialised
//! to JSON.
//!
//! ## Detected vulnerabilities
//!
//! | # | Vulnerability | Default severity |
//! |---|---------------|------------------|
//! | 1 | Address reuse | HIGH |
//! | 2 | Common-input-ownership heuristic (CIOH) | HIGH CRITICAL |
//! | 3 | Dust UTXO reception | MEDIUM HIGH |
//! | 4 | Dust spent alongside normal inputs | HIGH |
//! | 5 | Identifiable change outputs | MEDIUM |
//! | 6 | UTXOs born from consolidation transactions | MEDIUM |
//! | 7 | Mixed script types in inputs | HIGH |
//! | 8 | Cross-origin cluster merge | HIGH |
//! | 9 | UTXO age / lookback-depth spread | LOW |
//! | 10 | Exchange-origin batch withdrawal | MEDIUM |
//! | 11 | Tainted UTXO merge | HIGH |
//! | 12 | Behavioural fingerprinting | MEDIUM |
pub use stealth_model::config;
pub use stealth_model::descriptor;
mod detect;
pub mod engine;
pub use stealth_model::error;
pub use stealth_model::gateway;
mod graph;
pub use stealth_model::types;
pub use engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput};
pub use graph::TxGraph;
pub use stealth_model::types::*;