Files
stealth/engine/src/engine.rs
T
Renato Britto ea6c90bf41 fixup! feat(engine): create engine rust package for detectors and orchestration
Seed TxGraph ownership state from all derived addresses, not just addresses discovered from transaction outputs. This makes is_ours() recognize every descriptor-derived address and preserves internal/change tagging separately.
2026-03-31 19:31:48 -03:00

178 lines
6.7 KiB
Rust

//! 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(),
derived_addresses: HashSet::new(),
})
}
}