diff --git a/Cargo.toml b/Cargo.toml index bc025ff..8d61bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "model", "bitcoincore", + "engine", ] resolver = "2" diff --git a/engine/Cargo.toml b/engine/Cargo.toml new file mode 100644 index 0000000..d54075f --- /dev/null +++ b/engine/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "stealth-engine" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Detects and reproduces Bitcoin UTXO privacy vulnerabilities" +categories = ["cryptography::cryptocurrencies"] +keywords = ["bitcoin", "privacy", "utxo", "chain-analysis"] +readme = "README.md" + +[dependencies] +bitcoin = { workspace = true, features = ["std"] } +serde = { workspace = true } +serde_json = { workspace = true } +stealth-model = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +corepc-node = { workspace = true } +stealth-bitcoincore = { path = "../bitcoincore" } + +[lints.rust] +missing_debug_implementations = "deny" diff --git a/engine/README.md b/engine/README.md new file mode 100644 index 0000000..811f8df --- /dev/null +++ b/engine/README.md @@ -0,0 +1,116 @@ +# stealth-engine + +Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's transaction +history on a Bitcoin Core node via JSON-RPC. + +The library receives a pre-built `WalletHistory` (via any `BlockchainGateway` +implementation), indexes it into a `TxGraph`, then runs independent +vulnerability detectors through `TxGraph::detect_all()`. Results are returned +as a structured `Report` that serialises to JSON. + +Primary public scanning API: `TxGraph::detect_all(...)`. + +## 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 txs | 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 | + +## Prerequisites + +- **Rust** >= 1.93.1 +- **Bitcoin Core** (`bitcoind`) >= 0.29.0 — must be on your `PATH` + +### Installing Bitcoin Core + +```bash +# macOS (Homebrew) +brew install bitcoin + +# Ubuntu / Debian +sudo apt install bitcoind + +# Or download from https://bitcoincore.org/en/download/ +``` + +Verify it is available: + +```bash +bitcoind --version +``` + +## Usage + +Add the crate to your `Cargo.toml`: + +```toml +[dependencies] +stealth-engine = "0.1.0" +``` + +```rust,ignore +use stealth_engine::gateway::BlockchainGateway; +use stealth_engine::TxGraph; +use stealth_bitcoincore::BitcoinCoreRpc; + +// Connect to a wallet-loaded bitcoind +let gateway = BitcoinCoreRpc::from_url( + "http://127.0.0.1:8332", + Some("user".into()), + Some("pass".into()), +).unwrap(); +let history = gateway.scan_wallet("my_wallet").unwrap(); + +let graph = TxGraph::from_wallet_history(history); +let report = graph.detect_all(&Default::default(), None, None); + +for finding in &report.findings { + println!("{}: {}", finding.severity, finding.vulnerability_type); +} +``` + +## Running the tests + +The integration tests spin up a temporary `bitcoind` in regtest mode +(via [`corepc-node`](https://crates.io/crates/corepc-node)). +No external setup is required — just ensure `bitcoind` is on your `PATH`. + +```bash +# Run all tests (unit + all regtest integration tests) +cargo test -p stealth-engine + +# Run a single test with output +cargo test -p stealth-engine detect_address_reuse -- --nocapture +``` + +> **Note:** The integration tests create ephemeral regtest nodes that are +> automatically cleaned up. Each test takes a few seconds due to block mining. + +## Project structure + +``` +core/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Crate root and re-exports +│ ├── engine.rs # AnalysisEngine — canonical scan entry point +│ ├── graph.rs # TxGraph — indexed wallet transaction view +│ └── detect.rs # all vulnerability detectors + detect_all() +└── tests/ + └── integration.rs # all regtest integration tests +``` + +## License + +[MIT](../LICENSE) diff --git a/engine/src/detect.rs b/engine/src/detect.rs new file mode 100644 index 0000000..40a446e --- /dev/null +++ b/engine/src/detect.rs @@ -0,0 +1,1009 @@ +use std::collections::{HashMap, HashSet}; + +use bitcoin::{Amount, Txid}; +use serde_json::json; + +use crate::config::DetectorThresholds; +use crate::gateway::WalletTxCategory; +use crate::graph::TxGraph; +use crate::types::*; + +impl TxGraph { + /// Run all vulnerability detectors and produce a [`Report`]. + /// + /// Optionally pass sets of known-risky and known-exchange transaction IDs + /// to enable taint analysis (detector 11) and exchange-origin detection + /// (detector 10). + pub fn detect_all( + &self, + thresholds: &DetectorThresholds, + known_risky_txids: Option<&HashSet>, + known_exchange_txids: Option<&HashSet>, + ) -> Report { + let mut findings = Vec::new(); + let mut warnings = Vec::new(); + + self.detect_address_reuse(&mut findings); + self.detect_cioh(&mut findings); + self.detect_dust(thresholds, &mut findings); + self.detect_dust_spending(thresholds, &mut findings); + self.detect_change_detection(&mut findings); + self.detect_consolidation_origin(thresholds, &mut findings); + self.detect_script_type_mixing(&mut findings); + self.detect_cluster_merge(&mut findings); + self.detect_lookback_depth(thresholds, &mut findings, &mut warnings); + self.detect_exchange_origin(thresholds, &mut findings, known_exchange_txids); + self.detect_tainted_utxos(&mut findings, &mut warnings, known_risky_txids); + self.detect_behavioral_fingerprint(&mut findings); + + let stats = Stats { + transactions_analyzed: self.our_txids.len(), + addresses_seen: self.addr_map.len(), + utxos_current: self.utxos.len(), + }; + + Report::new(stats, findings, warnings) + } + + // ── 1. Address Reuse ─────────────────────────────────────────────────── + + fn detect_address_reuse(&self, findings: &mut Vec) { + for addr in &self.our_addrs { + let entries = match self.addr_txs.get(addr) { + Some(e) => e, + None => continue, + }; + let receive_txids: HashSet = entries + .iter() + .filter(|e| e.category == WalletTxCategory::Receive) + .map(|e| e.txid) + .collect(); + + if receive_txids.len() >= 2 { + let meta = self.addr_map.get(addr); + let role = if meta.is_some_and(|m: &AddressInfo| m.internal) { + "change" + } else { + "receive" + }; + findings.push(Finding { + vulnerability_type: VulnerabilityType::AddressReuse, + severity: Severity::High, + description: format!( + "Address {} ({}) reused across {} transactions", + addr.assume_checked_ref(), + role, + receive_txids.len() + ), + details: Some(json!({ + "address": addr.assume_checked_ref().to_string(), + "role": role, + "tx_count": receive_txids.len(), + "txids": receive_txids.iter().collect::>(), + })), + correction: Some( + "Generate a fresh address for every payment received. \ + Enable HD wallet derivation (BIP-32/44/84) so your wallet \ + produces a new address automatically." + .into(), + ), + }); + } + } + } + + // ── 2. Common Input Ownership Heuristic (CIOH) ───────────────────────── + + fn detect_cioh(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + if tx.vin.len() < 2 { + continue; + } + + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + + let our_inputs: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_inputs.len() < 2 { + continue; + } + + let total_inputs = input_addrs.len(); + let n_ours = our_inputs.len(); + let ownership_pct = (n_ours as f64 / total_inputs as f64 * 100.0).round() as u32; + + let severity = if n_ours == total_inputs { + Severity::Critical + } else { + Severity::High + }; + + findings.push(Finding { + vulnerability_type: VulnerabilityType::Cioh, + severity, + description: format!( + "TX {} merges {}/{} of your inputs ({}% ownership)", + txid, n_ours, total_inputs, ownership_pct + ), + details: Some(json!({ + "txid": txid, + "total_inputs": total_inputs, + "our_inputs": n_ours, + "ownership_pct": ownership_pct, + })), + correction: Some( + "Use coin control to select only one UTXO per transaction. \ + If consolidation is unavoidable, do it privately via a CoinJoin round." + .into(), + ), + }); + } + } + + // ── 3. Dust UTXO Detection ───────────────────────────────────────────── + + fn detect_dust(&self, thresholds: &DetectorThresholds, findings: &mut Vec) { + let dust = thresholds.dust; + let strict_dust = thresholds.strict_dust; + + // Current UTXOs + for utxo in &self.utxos { + if !self.is_ours(&utxo.address) { + continue; + } + let amt = utxo.amount; + if amt <= dust { + let label = if amt <= strict_dust { + "STRICT_DUST" + } else { + "dust-class" + }; + let severity = if amt <= strict_dust { + Severity::High + } else { + Severity::Medium + }; + findings.push(Finding { + vulnerability_type: VulnerabilityType::Dust, + severity, + description: format!( + "Dust UTXO at {} ({} sats, {}, unspent)", + utxo.address.assume_checked_ref(), + amt.to_sat(), + label + ), + details: Some(json!({ + "status": "unspent", + "address": utxo.address.assume_checked_ref().to_string(), + "sats": amt.to_sat(), + "label": label, + "txid": utxo.txid, + "vout": utxo.vout, + })), + correction: Some( + "Do not spend this dust output — doing so links your other inputs \ + to this address via CIOH. Use your wallet's coin freeze feature to \ + exclude it from future transactions." + .into(), + ), + }); + } + } + + // Historical dust (already spent) + let txids: Vec = self.our_txids.iter().copied().collect(); + let current_keys: HashSet<(Txid, String)> = self + .utxos + .iter() + .map(|u| (u.txid, u.address.assume_checked_ref().to_string())) + .collect(); + let mut seen = HashSet::new(); + for txid in &txids { + let outputs = self.get_output_addresses(txid); + for out in &outputs { + let amt = out.value; + if amt <= dust && self.is_ours(&out.address) { + let key = (*txid, out.address.assume_checked_ref().to_string()); + if !current_keys.contains(&key) && seen.insert(key) { + findings.push(Finding { + vulnerability_type: VulnerabilityType::Dust, + severity: Severity::Low, + description: format!( + "Historical dust output at {} ({} sats, already spent)", + out.address.assume_checked_ref(), + amt.to_sat() + ), + details: Some(json!({ + "status": "spent", + "address": out.address.assume_checked_ref().to_string(), + "sats": amt.to_sat(), + "txid": txid, + })), + correction: Some( + "This dust has already been spent. Going forward, reject \ + unsolicited dust by enabling automatic dust rejection." + .into(), + ), + }); + } + } + } + } + } + + // ── 4. Dust Spent with Normal Inputs ─────────────────────────────────── + + fn detect_dust_spending(&self, thresholds: &DetectorThresholds, findings: &mut Vec) { + let dust = thresholds.dust; + let normal_min = thresholds.normal_input_min; + + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + + let mut dust_inputs = Vec::new(); + let mut normal_inputs = Vec::new(); + for ia in &input_addrs { + if !self.is_ours(&ia.address) { + continue; + } + let amt = ia.value; + if amt <= dust { + dust_inputs.push(ia); + } else if amt > normal_min { + normal_inputs.push(ia); + } + } + + if !dust_inputs.is_empty() && !normal_inputs.is_empty() { + findings.push(Finding { + vulnerability_type: VulnerabilityType::DustSpending, + severity: Severity::High, + description: format!( + "TX {} spends {} dust input(s) alongside {} normal input(s)", + txid, + dust_inputs.len(), + normal_inputs.len() + ), + details: Some(json!({ + "txid": txid, + "dust_inputs": dust_inputs.iter().map(|d| { + json!({"address": d.address.assume_checked_ref().to_string(), "sats": d.value.to_sat()}) + }).collect::>(), + "normal_inputs": normal_inputs.iter().map(|n| { + json!({"address": n.address.assume_checked_ref().to_string(), "sats": n.value.to_sat()}) + }).collect::>(), + })), + correction: Some( + "Freeze dust UTXOs in your wallet to prevent them from being \ + automatically selected as inputs." + .into(), + ), + }); + } + } + } + + // ── 5. Change Detection ──────────────────────────────────────────────── + + fn detect_change_detection(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let outputs = self.get_output_addresses(txid); + if outputs.len() < 2 { + continue; + } + let input_addrs = self.get_input_addresses(txid); + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.is_empty() { + continue; + } + + let our_outs: Vec<_> = outputs + .iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + let ext_outs: Vec<_> = outputs + .iter() + .filter(|o| !self.is_ours(&o.address)) + .collect(); + if our_outs.is_empty() || ext_outs.is_empty() { + continue; + } + + let mut problems = Vec::new(); + for change in &our_outs { + let ch_sats = change.value.to_sat(); + let ch_round = ch_sats % 100_000 == 0 || ch_sats % 1_000_000 == 0; + + for payment in &ext_outs { + let pay_sats = payment.value.to_sat(); + let pay_round = pay_sats % 100_000 == 0 || pay_sats % 1_000_000 == 0; + + if pay_round && !ch_round { + problems.push(format!( + "Round payment ({} sats) vs non-round change ({} sats)", + pay_sats, ch_sats + )); + } + + let in_types: HashSet = our_in + .iter() + .map(|ia| self.script_type(&ia.address)) + .collect(); + let ch_type = self.script_type(&change.address); + if in_types.contains(&ch_type) && change.script_type != payment.script_type { + problems.push(format!( + "Change script type ({}) matches input type — different from payment ({})", + change.script_type, payment.script_type + )); + } + + if let Some(meta) = self.addr_map.get(&change.address) { + if meta.internal { + problems.push( + "Change uses an internal (BIP-44 /1/*) derivation path".into(), + ); + } + } + } + } + + if !problems.is_empty() { + problems.truncate(6); + findings.push(Finding { + vulnerability_type: VulnerabilityType::ChangeDetection, + severity: Severity::Medium, + description: format!( + "TX {} has identifiable change output(s) ({} heuristic(s) matched)", + txid, + problems.len() + ), + details: Some(json!({ + "txid": txid, + "reasons": problems, + })), + correction: Some( + "Use PayJoin (BIP-78) so the receiver also contributes an input. \ + Avoid sending round amounts so the change amount is not the obvious leftover." + .into(), + ), + }); + } + } + } + + // ── 6. Consolidation Origin ──────────────────────────────────────────── + fn detect_consolidation_origin( + &self, + thresholds: &DetectorThresholds, + findings: &mut Vec, + ) { + let min_inputs = thresholds.consolidation_min_inputs; + let max_outputs = thresholds.consolidation_max_outputs; + + for utxo in &self.utxos { + if !self.is_ours(&utxo.address) { + continue; + } + let parent = match self.fetch_tx(&utxo.txid) { + Some(t) => t, + None => continue, + }; + let n_in = parent.vin.len(); + let n_out = parent.vout.len(); + + if n_in >= min_inputs && n_out <= max_outputs { + let parent_inputs = self.get_input_addresses(&utxo.txid); + let our_parent_in = parent_inputs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .count(); + + findings.push(Finding { + vulnerability_type: VulnerabilityType::Consolidation, + severity: Severity::Medium, + description: format!( + "UTXO {}:{} ({:.8} BTC) born from a {}-input consolidation", + utxo.txid, + utxo.vout, + utxo.amount.to_btc(), + n_in + ), + details: Some(json!({ + "txid": utxo.txid, + "vout": utxo.vout, + "amount_sats": utxo.amount.to_sat(), + "consolidation_inputs": n_in, + "consolidation_outputs": n_out, + "our_inputs_in_consolidation": our_parent_in, + })), + correction: Some( + "Avoid consolidating many UTXOs into one in a single transaction. \ + If fee savings require consolidation, do it through a CoinJoin." + .into(), + ), + }); + } + } + } + + // ── 7. Script Type Mixing ────────────────────────────────────────────── + + fn detect_script_type_mixing(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.len() < 2 { + continue; + } + + let mut types: HashSet = HashSet::new(); + for ia in &input_addrs { + let t = self.script_type(&ia.address); + if t != "unknown" { + types.insert(t); + } + } + + if types.len() >= 2 { + let mut sorted: Vec = types.into_iter().collect(); + sorted.sort(); + findings.push(Finding { + vulnerability_type: VulnerabilityType::ScriptTypeMixing, + severity: Severity::High, + description: format!("TX {} mixes input script types: {:?}", txid, sorted), + details: Some(json!({ + "txid": txid, + "script_types": sorted, + })), + correction: Some( + "Migrate all funds to a single address type — preferably Taproot (P2TR). \ + Never mix P2PKH, P2SH, P2WPKH, P2WSH, and P2TR inputs in the same transaction." + .into(), + ), + }); + } + } + } + + // ── 8. Cluster Merge ─────────────────────────────────────────────────── + + fn detect_cluster_merge(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.len() < 2 { + continue; + } + + // Trace each input one hop back to find funding sources. + let mut funding_sources: HashMap> = HashMap::new(); + for ia in &our_in { + let parent_tx = match self.fetch_tx(&ia.funding_txid) { + Some(t) => t, + None => continue, + }; + let mut gp_sources = HashSet::new(); + for p_vin in &parent_tx.vin { + if p_vin.coinbase { + gp_sources.insert("coinbase".into()); + } else { + let ptxid = p_vin.previous_txid.to_string(); + gp_sources.insert(ptxid[..16].to_string()); + } + } + let ftxid = ia.funding_txid.to_string(); + let key = format!("{}:{}", &ftxid[..16], ia.funding_vout); + funding_sources.insert(key, gp_sources); + } + + let all_sources: Vec<&HashSet> = funding_sources.values().collect(); + if all_sources.len() >= 2 { + let mut merged = false; + 'outer: for i in 0..all_sources.len() { + for j in (i + 1)..all_sources.len() { + if all_sources[i].is_disjoint(all_sources[j]) { + merged = true; + break 'outer; + } + } + } + + if merged { + findings.push(Finding { + vulnerability_type: VulnerabilityType::ClusterMerge, + severity: Severity::High, + description: format!( + "TX {} merges UTXOs from {} different funding chains", + txid, + funding_sources.len() + ), + details: Some(json!({ + "txid": txid, + "funding_sources": funding_sources.iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect::>())) + .collect::>(), + })), + correction: Some( + "Use coin control to spend UTXOs from only one funding source \ + per transaction. Keep UTXOs received from different counterparties \ + in separate wallets." + .into(), + ), + }); + } + } + } + } + + // ── 9. Lookback Depth / UTXO Age ─────────────────────────────────────── + + fn detect_lookback_depth( + &self, + thresholds: &DetectorThresholds, + findings: &mut Vec, + warnings: &mut Vec, + ) { + let our_utxos: Vec<_> = self + .utxos + .iter() + .filter(|u| self.is_ours(&u.address)) + .cloned() + .collect(); + if our_utxos.len() < 2 { + return; + } + + let mut aged: Vec<_> = our_utxos.iter().map(|u| (u, u.confirmations)).collect(); + aged.sort_by(|a, b| b.1.cmp(&a.1)); + + let oldest = aged.first().unwrap(); + let newest = aged.last().unwrap(); + let spread = oldest.1 - newest.1; + + if spread < thresholds.utxo_age_spread_blocks { + return; + } + + findings.push(Finding { + vulnerability_type: VulnerabilityType::UtxoAgeSpread, + severity: Severity::Low, + description: format!( + "UTXO age spread of {} blocks between oldest and newest", + spread + ), + details: Some(json!({ + "spread_blocks": spread, + "oldest": { + "txid": oldest.0.txid, + "confirmations": oldest.1, + "amount_sats": oldest.0.amount.to_sat(), + }, + "newest": { + "txid": newest.0.txid, + "confirmations": newest.1, + "amount_sats": newest.0.amount.to_sat(), + }, + })), + correction: Some( + "Prefer spending older UTXOs first (FIFO coin selection) to normalize \ + the age distribution of your UTXO set." + .into(), + ), + }); + + let dormant_threshold = thresholds.dormant_utxo_blocks; + let old_count = aged.iter().filter(|(_, c)| *c >= dormant_threshold).count(); + if old_count > 0 { + warnings.push(Finding { + vulnerability_type: VulnerabilityType::DormantUtxos, + severity: Severity::Low, + description: format!( + "{} UTXO(s) have ≥{} confirmations (dormant/hoarded coins pattern)", + old_count, dormant_threshold + ), + details: Some(json!({ + "count": old_count, + "threshold_blocks": dormant_threshold, + })), + correction: None, + }); + } + } + + // ── 10. Exchange Origin ──────────────────────────────────────────────── + + fn detect_exchange_origin( + &self, + thresholds: &DetectorThresholds, + findings: &mut Vec, + known_exchange_txids: Option<&HashSet>, + ) { + let batch_threshold = thresholds.exchange_batch_min_outputs; + + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + let n_out = tx.vout.len(); + if n_out < batch_threshold { + continue; + } + + let input_addrs = self.get_input_addresses(txid); + let our_inputs: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if !our_inputs.is_empty() { + continue; // We're a sender, not a recipient. + } + + let our_outputs: Vec<_> = self + .get_output_addresses(txid) + .into_iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + if our_outputs.is_empty() { + continue; + } + + let mut signals = vec![format!("High output count: {}", n_out)]; + + let unique_addr_count = tx + .vout + .iter() + .filter_map(|o| o.address.as_ref()) + .collect::>() + .len(); + if unique_addr_count >= batch_threshold { + signals.push(format!("{} unique recipient addresses", unique_addr_count)); + } + + // Input-value to median-output-value ratio heuristic. + let total_input: Amount = input_addrs.iter().map(|ia| ia.value).sum(); + let mut output_values: Vec = tx.vout.iter().map(|o| o.value.to_sat()).collect(); + output_values.sort_unstable(); + if !output_values.is_empty() { + let median = output_values[output_values.len() / 2]; + if median > 0 && total_input.to_sat() > 10 * median { + signals.push(format!( + "Input/median-output ratio: {}x (exchange-like fan-out)", + total_input.to_sat() / median + )); + } + } + + if let Some(exchange_txids) = known_exchange_txids { + if exchange_txids.contains(txid) { + signals.push("TX matches known exchange wallet history".into()); + } + } + + if signals.len() >= 2 { + findings.push(Finding { + vulnerability_type: VulnerabilityType::ExchangeOrigin, + severity: Severity::Medium, + description: format!( + "TX {} looks like an exchange batch withdrawal ({} signal(s))", + txid, + signals.len() + ), + details: Some(json!({ + "txid": txid, + "signals": signals, + "received_outputs": our_outputs.iter().map(|o| { + json!({"address": o.address.assume_checked_ref().to_string(), "sats": o.value.to_sat()}) + }).collect::>(), + })), + correction: Some( + "Withdraw via Lightning Network to avoid the exchange-origin fingerprint. \ + After withdrawal, pass the UTXO through a CoinJoin." + .into(), + ), + }); + } + } + } + + // ── 11. Tainted UTXOs ────────────────────────────────────────────────── + + fn detect_tainted_utxos( + &self, + findings: &mut Vec, + warnings: &mut Vec, + known_risky_txids: Option<&HashSet>, + ) { + let risky_txids = match known_risky_txids { + Some(t) if !t.is_empty() => t, + _ => return, + }; + + let txids: Vec = self.our_txids.iter().copied().collect(); + + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.is_empty() || input_addrs.len() < 2 { + continue; + } + + let tainted: Vec<_> = input_addrs + .iter() + .filter(|ia| risky_txids.contains(&ia.funding_txid)) + .collect(); + let clean: Vec<_> = input_addrs + .iter() + .filter(|ia| !risky_txids.contains(&ia.funding_txid)) + .collect(); + + if !tainted.is_empty() && !clean.is_empty() { + let taint_pct = + (tainted.len() as f64 / input_addrs.len() as f64 * 100.0).round() as u32; + findings.push(Finding { + vulnerability_type: VulnerabilityType::TaintedUtxoMerge, + severity: Severity::High, + description: format!( + "TX {} merges {} tainted + {} clean inputs ({}% taint)", + txid, + tainted.len(), + clean.len(), + taint_pct + ), + details: Some(json!({ + "txid": txid, + "tainted_inputs": tainted.iter().map(|t| { + json!({"address": t.address.assume_checked_ref().to_string(), "sats": t.value.to_sat(), "source_txid": t.funding_txid}) + }).collect::>(), + "clean_inputs": clean.iter().map(|c| { + json!({"address": c.address.assume_checked_ref().to_string(), "sats": c.value.to_sat()}) + }).collect::>(), + "taint_pct": taint_pct, + })), + correction: Some( + "Freeze tainted UTXOs to prevent them from being spent alongside \ + clean funds. Never merge inputs from known risky sources." + .into(), + ), + }); + } + } + + // Direct taint: we received directly from a risky source. + for txid in &txids { + if risky_txids.contains(txid) { + let our_outs: Vec<_> = self + .get_output_addresses(txid) + .into_iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + if !our_outs.is_empty() { + warnings.push(Finding { + vulnerability_type: VulnerabilityType::DirectTaint, + severity: Severity::High, + description: format!("TX {} is directly from a known risky source", txid), + details: Some(json!({ + "txid": txid, + "received_outputs": our_outs.iter().map(|o| { + json!({"address": o.address.assume_checked_ref().to_string(), "sats": o.value.to_sat()}) + }).collect::>(), + })), + correction: None, + }); + } + } + } + } + + // ── 12. Behavioral Fingerprint ───────────────────────────────────────── + + fn detect_behavioral_fingerprint(&self, findings: &mut Vec) { + // Collect send transactions. Prefer explicit wallet-side `send` + // labels and fall back to ownership inferred from inputs. + let txids: Vec = self.our_txids.iter().copied().collect(); + let send_labeled_txids: HashSet = self + .addr_txs + .values() + .flatten() + .filter(|entry| entry.category == WalletTxCategory::Send) + .map(|entry| entry.txid) + .collect(); + let mut send_txids = Vec::new(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let has_our_input = input_addrs.iter().any(|ia| self.is_ours(&ia.address)); + if has_our_input || send_labeled_txids.contains(txid) { + send_txids.push(*txid); + } + } + + if send_txids.len() < 3 { + return; + } + + let mut output_counts = Vec::new(); + let mut input_script_types = Vec::new(); + let mut rbf_signals = Vec::new(); + let mut locktime_values = Vec::new(); + let mut fee_rates: Vec = Vec::new(); + let mut uses_round_amounts: usize = 0; + let mut total_payments: usize = 0; + + for txid in &send_txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + + output_counts.push(tx.vout.len()); + + locktime_values.push(tx.locktime as u64); + + for vin in &tx.vin { + rbf_signals.push(vin.sequence < 0xffff_fffe); + } + + let input_addrs = self.get_input_addresses(txid); + for ia in &input_addrs { + if self.is_ours(&ia.address) { + input_script_types.push(self.script_type(&ia.address)); + } + } + + let outputs = self.get_output_addresses(txid); + for out in &outputs { + if !self.is_ours(&out.address) { + let sats = out.value.to_sat(); + total_payments += 1; + if sats > 0 && (sats % 100_000 == 0 || sats % 1_000_000 == 0) { + uses_round_amounts += 1; + } + } + } + + // Fee rate + let vsize = tx.vsize as u64; + if vsize > 0 { + let in_total: Amount = input_addrs.iter().map(|ia| ia.value).sum(); + let out_total: Amount = tx.vout.iter().map(|o| o.value).sum(); + let fee_sats = in_total.to_sat().saturating_sub(out_total.to_sat()); + if fee_sats > 0 { + fee_rates.push(fee_sats as f64 / vsize as f64); + } + } + } + + let mut problems = Vec::new(); + + // Round amount pattern + if total_payments > 0 { + let round_pct = uses_round_amounts as f64 / total_payments as f64 * 100.0; + if round_pct > 60.0 { + problems.push(format!( + "Round payment amounts: {:.0}% of payments are round numbers.", + round_pct + )); + } + } + + // Uniform output count + if output_counts.len() >= 3 && output_counts.iter().all(|&c| c == output_counts[0]) { + problems.push(format!( + "Uniform output count: all {} send TXs have exactly {} outputs.", + output_counts.len(), + output_counts[0] + )); + } + + // Script type consistency + let input_types_set: HashSet<&String> = input_script_types.iter().collect(); + if input_types_set.len() > 1 { + problems.push(format!( + "Mixed input script types used across TXs: {:?}.", + input_types_set + )); + } + + // RBF signaling + if !rbf_signals.is_empty() { + let rbf_pct = rbf_signals.iter().filter(|&&b| b).count() as f64 + / rbf_signals.len() as f64 + * 100.0; + if rbf_pct == 100.0 { + problems.push("RBF always enabled: 100% of inputs signal replace-by-fee.".into()); + } else if rbf_pct == 0.0 { + problems.push("RBF never enabled: 0% of inputs signal replace-by-fee.".into()); + } + } + + // Locktime pattern + if locktime_values.len() >= 3 { + let all_nonzero = locktime_values.iter().all(|<| lt > 0); + let all_zero = locktime_values.iter().all(|<| lt == 0); + if all_nonzero { + problems.push( + "Anti-fee-sniping locktime always set — consistent with Bitcoin Core.".into(), + ); + } else if all_zero { + problems.push("Locktime always 0 — no anti-fee-sniping.".into()); + } + } + + // Fee rate consistency + if fee_rates.len() >= 3 { + let avg: f64 = fee_rates.iter().sum::() / fee_rates.len() as f64; + if avg > 0.0 { + let variance: f64 = fee_rates.iter().map(|f| (f - avg).powi(2)).sum::() + / fee_rates.len() as f64; + let stddev = variance.sqrt(); + let cv = stddev / avg; + if cv < 0.15 { + problems.push(format!( + "Very consistent fee rate: avg {:.1} sat/vB ± {:.1} (CV={:.2}).", + avg, stddev, cv + )); + } + } + } + + if problems.is_empty() { + return; + } + + findings.push(Finding { + vulnerability_type: VulnerabilityType::BehavioralFingerprint, + severity: Severity::Medium, + description: format!( + "Behavioral fingerprint detected across {} send transactions ({} pattern(s))", + send_txids.len(), + problems.len() + ), + details: Some(json!({ + "send_tx_count": send_txids.len(), + "patterns": problems, + })), + correction: Some( + "Switch to wallet software that applies anti-fingerprinting defaults. \ + Avoid sending only round amounts — add small random satoshi offsets. \ + Standardize on a single modern script type (Taproot)." + .into(), + ), + }); + } +} diff --git a/engine/src/engine.rs b/engine/src/engine.rs new file mode 100644 index 0000000..3e3cd07 --- /dev/null +++ b/engine/src/engine.rs @@ -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 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 { + 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) -> Result { + 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) -> Result { + 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 { + let mut wallet_txs = Vec::new(); + let mut utxo_entries = Vec::new(); + let mut transactions: HashMap = HashMap::new(); + let mut fetch_queue: Vec = 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(), + }) + } +} diff --git a/engine/src/graph.rs b/engine/src/graph.rs new file mode 100644 index 0000000..2400b78 --- /dev/null +++ b/engine/src/graph.rs @@ -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, AddressInfo>, + /// All our addresses (quick lookup). + pub our_addrs: HashSet>, + /// Current UTXOs from `listunspent`. + pub utxos: Vec, + /// Transaction IDs that touch our wallet. + pub our_txids: HashSet, + /// Per-address transaction entries. + pub addr_txs: HashMap, Vec>, + /// Per-txid set of our addresses involved. + pub tx_addrs: HashMap>>, + + /// Decoded transactions keyed by txid. + pub tx_cache: HashMap, + /// Cached input addresses per txid. + pub input_cache: HashMap>, + /// Cached output addresses per txid. + pub output_cache: HashMap>, +} + +/// A UTXO entry from `listunspent`. +#[derive(Debug, Clone)] +pub struct UtxoEntry { + pub txid: Txid, + pub vout: u32, + pub address: Address, + pub amount: Amount, + pub confirmations: u32, +} + +impl TxGraph { + /// Check whether an address belongs to our wallet. + pub fn is_ours(&self, address: &Address) -> bool { + self.our_addrs.contains(address) + } + + /// Get the script type for an address. + pub fn script_type(&self, address: &Address) -> 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 { + 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 { + 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, Vec> = HashMap::new(); + let mut tx_addrs: HashMap>> = 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 = 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> = HashMap::new(); + let mut output_cache: HashMap> = HashMap::new(); + + for (txid, tx) in &history.transactions { + tx_cache.insert(*txid, tx.clone()); + + let inputs: Vec = 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 = 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) -> 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() + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs new file mode 100644 index 0000000..a597514 --- /dev/null +++ b/engine/src/lib.rs @@ -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::*; diff --git a/engine/tests/integration.rs b/engine/tests/integration.rs new file mode 100644 index 0000000..c521b19 --- /dev/null +++ b/engine/tests/integration.rs @@ -0,0 +1,715 @@ +//! Integration tests for stealth-engine. +//! +//! Each test spins up a fresh regtest Bitcoin Core via `corepc-node`, +//! reproduces one or more privacy vulnerabilities, then runs the +//! detector through the canonical `AnalysisEngine` + `BitcoinCoreRpc` +//! gateway path to verify it fires the expected finding(s). + +use std::collections::{BTreeMap, HashSet}; + +use bitcoin::Txid; +use corepc_node::client::bitcoin::{Address, Amount}; +use corepc_node::{AddressType, Input, Node, Output}; +use stealth_bitcoincore::BitcoinCoreRpc; +use stealth_engine::gateway::BlockchainGateway; +use stealth_engine::{TxGraph, VulnerabilityType}; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn node() -> Node { + let exe = corepc_node::exe_path().expect("bitcoind not found"); + let mut conf = corepc_node::Conf::default(); + conf.args.push("-txindex"); + Node::with_conf(exe, &conf).expect("failed to start bitcoind") +} + +fn mine(node: &Node, n: usize, addr: &Address) { + node.client.generate_to_address(n, addr).unwrap(); +} + +fn gateway_for(node: &Node) -> BitcoinCoreRpc { + let cookie = + std::fs::read_to_string(&node.params.cookie_file).expect("failed to read cookie file"); + let mut parts = cookie.trim().splitn(2, ':'); + let user = parts.next().unwrap().to_string(); + let pass = parts.next().unwrap().to_string(); + BitcoinCoreRpc::from_url(&node.rpc_url(), Some(user), Some(pass)) + .expect("failed to build gateway") +} + +fn scan_wallet(gateway: &BitcoinCoreRpc, wallet: &str) -> stealth_engine::Report { + let history = gateway.scan_wallet(wallet).expect("scan_wallet failed"); + let graph = TxGraph::from_wallet_history(history); + graph.detect_all(&Default::default(), None, None) +} + +fn scan_wallet_with( + gateway: &BitcoinCoreRpc, + wallet: &str, + known_risky: Option<&HashSet>, + known_exchange: Option<&HashSet>, +) -> stealth_engine::Report { + let history = gateway.scan_wallet(wallet).expect("scan_wallet failed"); + let graph = TxGraph::from_wallet_history(history); + graph.detect_all(&Default::default(), known_risky, known_exchange) +} + +fn has_finding(report: &stealth_engine::Report, vtype: VulnerabilityType) -> bool { + report + .findings + .iter() + .any(|f| f.vulnerability_type == vtype) +} + +// ─── 1. Address Reuse ─────────────────────────────────────────────────────── + +#[test] +fn detect_address_reuse() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client.send_to_address(&ba, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + // Reuse the same alice address twice + let reused = alice.new_address().unwrap(); + bob.send_to_address(&reused, Amount::from_sat(1_000_000)) + .unwrap(); + bob.send_to_address(&reused, Amount::from_sat(2_000_000)) + .unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::AddressReuse)); +} + +// ─── 2. Common Input Ownership Heuristic (CIOH) ──────────────────────────── + +#[test] +fn detect_cioh() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice multiple small UTXOs (each to a different address) + for _ in 0..5 { + let a = alice.new_address().unwrap(); + bob.send_to_address(&a, Amount::from_sat(500_000)).unwrap(); + } + mine(&node, 1, &da); + + // Alice consolidates them into one tx (multi-input -> CIOH) + let utxos = alice.list_unspent().unwrap(); + let small: Vec<_> = utxos.0.iter().filter(|u| u.amount < 0.006).collect(); + assert!(small.len() >= 2, "need at least 2 small utxos"); + + let inputs: Vec = small + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = small.iter().map(|u| (u.amount * 1e8).round() as u64).sum(); + let fee_sats: u64 = 10_000; + let dest = bob.new_address().unwrap(); + let outputs = vec![Output::new(dest, Amount::from_sat(total_sats - fee_sats))]; + + let raw = alice.create_raw_transaction(&inputs, &outputs).unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + assert!(signed.complete); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::Cioh)); +} + +// ─── 3. Dust UTXO Detection ──────────────────────────────────────────────── + +#[test] +fn detect_dust() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client.send_to_address(&ba, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + // Create 1000-sat dust output to alice via raw tx + let dust_addr = alice.new_address().unwrap(); + let bob_utxos = bob.list_unspent().unwrap(); + let big = bob_utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + + let big_sats = (big.amount * 1e8).round() as u64; + let dust_sats: u64 = 1_000; + let fee_sats: u64 = 10_000; + let change_sats = big_sats - dust_sats - fee_sats; + + let change_addr = bob.new_address().unwrap(); + let raw = bob + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &[ + Output::new(dust_addr, Amount::from_sat(dust_sats)), + Output::new(change_addr, Amount::from_sat(change_sats)), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = bob.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + bob.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::Dust)); +} + +// ─── 4. Dust Spending with Normal Inputs ──────────────────────────────────── + +#[test] +fn detect_dust_spending() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice a normal UTXO + let alice_normal = alice.new_address().unwrap(); + bob.send_to_address(&alice_normal, Amount::from_btc(0.5).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice a dust UTXO via raw tx + let dust_addr = alice.new_address().unwrap(); + let bob_utxos = bob.list_unspent().unwrap(); + let big = bob_utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + let big_sats = (big.amount * 1e8).round() as u64; + let dust_sats: u64 = 1_000; + let fee_sats: u64 = 10_000; + + let change_addr = bob.new_address().unwrap(); + let raw = bob + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &[ + Output::new(dust_addr, Amount::from_sat(dust_sats)), + Output::new( + change_addr, + Amount::from_sat(big_sats - dust_sats - fee_sats), + ), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = bob.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + bob.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + // Now alice spends dust + normal together + let utxos = alice.list_unspent().unwrap(); + let dust_u = utxos + .0 + .iter() + .find(|u| (u.amount * 1e8).round() as u64 <= 1000) + .expect("dust utxo"); + let normal_u = utxos + .0 + .iter() + .find(|u| u.amount > 0.001) + .expect("normal utxo"); + + let total_sats = (dust_u.amount * 1e8).round() as u64 + (normal_u.amount * 1e8).round() as u64; + let dest = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &[ + Input { + txid: dust_u.txid.parse().unwrap(), + vout: dust_u.vout as u64, + sequence: None, + }, + Input { + txid: normal_u.txid.parse().unwrap(), + vout: normal_u.vout as u64, + sequence: None, + }, + ], + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::DustSpending)); +} + +// ─── 5. Change Detection ─────────────────────────────────────────────────── + +#[test] +fn detect_change_detection() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund alice with a clean 1 BTC UTXO + let aa = alice.new_address().unwrap(); + node.client.send_to_address(&aa, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + // Alice sends a round 0.05 BTC to bob via send_to_address. + // Bitcoin Core will automatically create a change output. + let bob_addr = bob.new_address().unwrap(); + alice + .send_to_address(&bob_addr, Amount::from_sat(5_000_000)) + .unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::ChangeDetection)); +} + +// ─── 6. Consolidation Origin ─────────────────────────────────────────────── + +#[test] +fn detect_consolidation() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice 4 small UTXOs + for _ in 0..4 { + let a = alice.new_address().unwrap(); + bob.send_to_address(&a, Amount::from_sat(300_000)).unwrap(); + } + mine(&node, 1, &da); + + // Alice consolidates into one address (>=3 inputs, <=2 outputs) + let utxos = alice.list_unspent().unwrap(); + let small: Vec<_> = utxos + .0 + .iter() + .filter(|u| u.amount > 0.002 && u.amount < 0.004) + .collect(); + assert!(small.len() >= 3, "need at least 3 small utxos"); + + let inputs: Vec = small + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = small.iter().map(|u| (u.amount * 1e8).round() as u64).sum(); + let consol_addr = alice.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new( + consol_addr, + Amount::from_sat(total_sats - 10_000), + )], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::Consolidation)); +} + +// ─── 7. Script Type Mixing ───────────────────────────────────────────────── + +#[test] +fn detect_script_type_mixing() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice one P2WPKH and one P2TR utxo + let wpkh_addr = alice.new_address_with_type(AddressType::Bech32).unwrap(); + let tr_addr = alice.new_address_with_type(AddressType::Bech32m).unwrap(); + bob.send_to_address(&wpkh_addr, Amount::from_sat(500_000)) + .unwrap(); + bob.send_to_address(&tr_addr, Amount::from_sat(500_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends both types together + let utxos = alice.list_unspent().unwrap(); + assert!(utxos.0.len() >= 2, "need at least 2 utxos"); + + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let dest = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::ScriptTypeMixing)); +} + +// ─── 8. Cluster Merge ────────────────────────────────────────────────────── + +#[test] +fn detect_cluster_merge() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let carol = node.create_wallet("carol").unwrap(); + // Fund bob and carol + let ba = bob.new_address().unwrap(); + let ca = carol.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + node.client + .send_to_address(&ca, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Bob sends to alice_addr_1, Carol sends to alice_addr_2 + let a1 = alice.new_address().unwrap(); + let a2 = alice.new_address().unwrap(); + bob.send_to_address(&a1, Amount::from_sat(400_000)).unwrap(); + carol + .send_to_address(&a2, Amount::from_sat(400_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends both together -> cluster merge + let utxos = alice.list_unspent().unwrap(); + assert!(utxos.0.len() >= 2, "need at least 2 utxos"); + + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let dest = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::ClusterMerge)); +} + +// ─── 9. Lookback Depth / UTXO Age ────────────────────────────────────────── + +#[test] +fn detect_utxo_age_spread() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + + // Old UTXO + let old_addr = alice.new_address().unwrap(); + node.client + .send_to_address(&old_addr, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 20, &da); + + // New UTXO + let new_addr = alice.new_address().unwrap(); + node.client + .send_to_address(&new_addr, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, VulnerabilityType::UtxoAgeSpread)); +} + +// ─── 10. Exchange Origin ─────────────────────────────────────────────────── + +#[test] +fn detect_exchange_origin() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let exchange = node.create_wallet("exchange").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + // Fund exchange + let ea = exchange.new_address().unwrap(); + node.client + .send_to_address(&ea, Amount::from_btc(5.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Exchange batch withdrawal to 8 addresses (alice gets some, bob gets some) + let mut amounts: BTreeMap = BTreeMap::new(); + for i in 0..5u64 { + let a = alice.new_address().unwrap(); + amounts.insert(a, Amount::from_sat(1_000_000 + i * 100_000)); + } + for i in 0..3u64 { + let b = bob.new_address().unwrap(); + amounts.insert(b, Amount::from_sat(1_000_000 + i * 200_000)); + } + let send_result = exchange.send_many(amounts).unwrap(); + mine(&node, 1, &da); + + let exchange_txids: HashSet = [send_result.0.parse::().unwrap()] + .into_iter() + .collect(); + let gateway = gateway_for(&node); + let report = scan_wallet_with(&gateway, "alice", None, Some(&exchange_txids)); + assert!(has_finding(&report, VulnerabilityType::ExchangeOrigin)); +} + +// ─── 11. Tainted UTXOs ───────────────────────────────────────────────────── + +#[test] +fn detect_tainted_utxo_merge() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let risky = node.create_wallet("risky").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund + let ra = risky.new_address().unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ra, Amount::from_btc(2.0).unwrap()) + .unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Risky sends to alice + let ta = alice.new_address().unwrap(); + let taint_result = risky + .send_to_address(&ta, Amount::from_sat(1_000_000)) + .unwrap(); + let taint_txid: Txid = taint_result.0.parse().unwrap(); + + // Bob sends clean to alice + let ca = alice.new_address().unwrap(); + bob.send_to_address(&ca, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends both together (tainted + clean) + let utxos = alice.list_unspent().unwrap(); + assert!(utxos.0.len() >= 2); + + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let carol = node.create_wallet("carol").unwrap(); + let dest = carol.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let risky_txids: HashSet = [taint_txid].into_iter().collect(); + let gateway = gateway_for(&node); + let report = scan_wallet_with(&gateway, "alice", Some(&risky_txids), None); + assert!(has_finding(&report, VulnerabilityType::TaintedUtxoMerge)); +} + +// ─── 12. Behavioral Fingerprint ──────────────────────────────────────────── + +#[test] +fn detect_behavioral_fingerprint() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let carol = node.create_wallet("carol").unwrap(); + + // Fund alice generously + let aa = alice.new_address().unwrap(); + node.client + .send_to_address(&aa, Amount::from_btc(5.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Alice sends 5 round-amount payments (behavioral pattern) + for i in 1u64..=5 { + let dest = carol.new_address().unwrap(); + alice + .send_to_address(&dest, Amount::from_sat(i * 1_000_000)) + .unwrap(); + mine(&node, 1, &da); + } + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(report + .findings + .iter() + .any(|f| f.vulnerability_type == VulnerabilityType::BehavioralFingerprint)); +} + +// ─── Full Report Smoke Test ───────────────────────────────────────────────── + +#[test] +fn full_report_generates() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let aa = alice.new_address().unwrap(); + node.client.send_to_address(&aa, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + let gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + + assert_eq!( + report.summary.findings + report.summary.warnings, + report.findings.len() + report.warnings.len() + ); + assert_eq!(report.stats.utxos_current, 1); +}