mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-05-29 21:29:27 -07:00
feat(engine): create engine rust package for detectors and orchestration
This commit is contained in:
26
engine/Cargo.toml
Normal file
26
engine/Cargo.toml
Normal file
@@ -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"
|
||||
116
engine/README.md
Normal file
116
engine/README.md
Normal file
@@ -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)
|
||||
1009
engine/src/detect.rs
Normal file
1009
engine/src/detect.rs
Normal file
File diff suppressed because it is too large
Load Diff
176
engine/src/engine.rs
Normal file
176
engine/src/engine.rs
Normal 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
engine/src/graph.rs
Normal file
226
engine/src/graph.rs
Normal 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
engine/src/lib.rs
Normal file
46
engine/src/lib.rs
Normal 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::*;
|
||||
715
engine/tests/integration.rs
Normal file
715
engine/tests/integration.rs
Normal file
@@ -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<Txid>>,
|
||||
known_exchange: Option<&HashSet<Txid>>,
|
||||
) -> 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<Input> = 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<Input> = 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<Input> = 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<Input> = 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<Address, Amount> = 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<Txid> = [send_result.0.parse::<Txid>().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<Input> = 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<Txid> = [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);
|
||||
}
|
||||
Reference in New Issue
Block a user