Merge pull request #16 from satsfy/add-rust-detector

refactor: implement vulnerability detection in Rust crate
This commit is contained in:
LORDBABUINO
2026-04-04 01:39:39 -03:00
committed by GitHub
36 changed files with 3450 additions and 4816 deletions
+1 -1
View File
@@ -1,4 +1,3 @@
backend/script/bitcoin-data/
node_modules/
dist/
.env
@@ -13,3 +12,4 @@ dist/
.qwen
**/__pycache__/
target/
Cargo.lock
Generated
-1735
View File
File diff suppressed because it is too large Load Diff
+25 -11
View File
@@ -1,20 +1,34 @@
[workspace]
members = [
"crates/stealth-app",
"crates/stealth-bitcoincore",
"crates/stealth-core",
"model",
"bitcoincore",
"engine",
]
resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
version = "0.1.0"
edition = "2021"
authors = [
"Breno Brito (brenorb)",
"Herberson Miranda (hsmiranda)",
"LORDBABUINO <lordbabuino@protonmail.com>",
"Renato Britto (satsfy) <0xsatsfy@gmail.com>",
]
license = "MIT"
repository = "https://github.com/stealth-bitcoin/stealth"
rust-version = "1.93.1"
[workspace.dependencies]
axum = "0.8"
clap = { version = "4.5", features = ["derive", "env"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal"] }
bitcoin = { version = "0.32.0", default-features = false, features = ["serde", "base64", "secp-recovery"] }
corepc-node = { version = "0.10.1", features = ["29_0"] }
serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
serde_json = "1.0.145"
thiserror = "2.0.17"
stealth-engine = { path = "engine" }
stealth-model = { path = "model" }
axum = "0.8.6"
tokio = { version = "1.48.0", features = ["macros", "net", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] }
+66 -22
View File
@@ -27,6 +27,12 @@ Stealth is currently transitioning from a controlled regtest environment to real
The immediate focus is enabling analysis of real wallet data using a local Bitcoin node.
Stealth ships a Rust workspace with:
- `stealth-engine` (analysis engine)
- `stealth-model` (domain model types and interfaces)
- `stealth-bitcoincore` (Bitcoin Core RPC gateway adapter)
## Project Direction
Stealth is evolving into a modular privacy heuristics engine for Bitcoin.
@@ -69,36 +75,53 @@ Stealth identifies real-world privacy issues such as:
Stealth's source-of-truth detector is:
```
backend/script/detect.py
engine/src/detect.rs
```
### Finding types
The report model and type names are defined in:
| Type | Meaning |
| ------------------------ | ----------------------------------------------- |
| `ADDRESS_REUSE` | Address received funds in multiple transactions |
| `CIOH` | Multi-input linkage across co-spent inputs |
| `DUST` | Dust output detection |
| `DUST_SPENDING` | Dust inputs linking clusters |
| `CHANGE_DETECTION` | Identifiable change output |
| `CONSOLIDATION` | Many-input transaction merging UTXOs |
| `SCRIPT_TYPE_MIXING` | Mixed script types in one spend |
| `CLUSTER_MERGE` | Previously separate funding chains merged |
| `UTXO_AGE_SPREAD` | Reveals dormancy and timing patterns |
| `EXCHANGE_ORIGIN` | Likely exchange withdrawal origin |
| `TAINTED_UTXO_MERGE` | Tainted inputs propagating risk |
| `BEHAVIORAL_FINGERPRINT` | Consistent identifiable patterns |
```
model/src/types.rs
```
### Severity levels
| Level | Meaning |
| ---------- | ----------------------------------------------------------------- |
| `LOW` | Weak or contextual signal; monitor behavior |
| `MEDIUM` | Meaningful privacy leakage under common heuristics |
| `HIGH` | Strong linkage/fingerprinting risk |
| `CRITICAL` | Very strong deanonymization signal requiring immediate mitigation |
## Vulnerabilities detected
Stealth currently runs **12 detectors** in `stealth-engine`.
| # | Type | Default severity | What it indicates |
| --- | ------------------------ | ---------------- | ------------------------------------------------------ |
| 1 | `ADDRESS_REUSE` | HIGH | Same receive address used across multiple transactions |
| 2 | `CIOH` | HIGH - CRITICAL | Multi-input ownership linkage |
| 3 | `DUST` | MEDIUM - HIGH | Dust outputs received/spent |
| 4 | `DUST_SPENDING` | HIGH | Dust merged with normal inputs |
| 5 | `CHANGE_DETECTION` | MEDIUM | Identifiable change output patterns |
| 6 | `CONSOLIDATION` | MEDIUM | Consolidation transactions linking clusters |
| 7 | `SCRIPT_TYPE_MIXING` | HIGH | Mixed script types that fingerprint wallet behavior |
| 8 | `CLUSTER_MERGE` | HIGH | Previously separate clusters merged on-chain |
| 9 | `UTXO_AGE_SPREAD` | LOW | Broad age spread revealing timing behavior |
| 10 | `EXCHANGE_ORIGIN` | MEDIUM | Signals typical of exchange batch withdrawals |
| 11 | `TAINTED_UTXO_MERGE` | HIGH | Tainted and clean inputs merged |
| 12 | `BEHAVIORAL_FINGERPRINT` | MEDIUM | Repeating transaction patterns |
### Warning types
| Type | Meaning |
| --------------- | -------------------------------- |
| `DORMANT_UTXOS` | Dormant funds pattern |
| `DIRECT_TAINT` | Direct exposure to risky sources |
| Type | Typical severity | Meaning |
| --------------- | ---------------- | ----------------------------------------------- |
| `DORMANT_UTXOS` | LOW | Dormant/hoarded UTXO behavior |
| `DIRECT_TAINT` | HIGH | Funds directly received from known risky source |
## How to use
## How to use the frontend
1. Open the application
1. Run and open the application
2. Paste a wallet descriptor (`wpkh(...)`, `tr(...)`, etc.)
3. Click **Analyze**
4. Review:
@@ -183,6 +206,17 @@ yarn dev
```
stealth/
├── Cargo.toml # Rust workspace definition
├── engine/ # stealth-engine (detectors + graph + report model)
│ ├── src/
│ │ ├── detect.rs # privacy detectors
│ │ ├── engine.rs # AnalysisEngine entry point
│ │ ├── graph.rs # Transaction graph builder
│ │ └── lib.rs # Crate root and re-exports
│ └── tests/
│ └── integration.rs # Regtest integration tests
├── model/ # stealth-model (domain model types and interfaces)
├── bitcoincore/ # Bitcoin Core gateway implementation crate
├── frontend/ # React + Vite UI
│ └── src/
│ ├── components/ # FindingCard, VulnerabilityBadge
@@ -200,6 +234,16 @@ stealth/
└── slides/ # Slidev pitch presentation
```
### Test Coverage
Stealth test coverage includes end-to-end api tests, integration tests using bitcoind regtest in core/ and additional unit tests.
You may run tests with:
```bash
cargo test
```
## Privacy notice
Stealth follows a local-first approach.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "stealth-bitcoincore"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Bitcoin Core RPC gateway for stealth-engine"
[dependencies]
bitcoin = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
ini = { package = "rust-ini", version = "0.21" }
serde = { workspace = true }
serde_json = { workspace = true }
stealth-model = { workspace = true }
thiserror = { workspace = true }
urlencoding = "2.1"
+138
View File
@@ -0,0 +1,138 @@
# stealth-bitcoincore
`stealth-bitcoincore` is the Bitcoin Core JSON-RPC gateway implementation for
[`stealth-engine`](../core/README.md).
It implements `stealth_engine::gateway::BlockchainGateway` and is used by both:
- `stealth-cli` (terminal scans)
- `stealth-api` (HTTP scans)
## What it does
This crate wraps the Bitcoin Core RPC surface needed by the analysis engine:
- descriptor normalization and derivation
- descriptor import into temporary watch-only wallets
- wallet history + UTXO retrieval
- raw transaction expansion (including ancestry walk)
The output is converted into `stealth-engine` gateway types (`WalletHistory`,
`DecodedTransaction`, `Utxo`, etc), so the engine can run detectors without
knowing anything about RPC transport details.
## Authentication and configuration
The gateway supports:
- RPC user/password
- Bitcoin Core cookie auth
`BitcoinCoreRpc::from_url(...)` does not auto-discover cookie files. If you
want internal cookie lookup, construct with `BitcoinCoreConfig` and set
`datadir`.
### 1) Build from URL (used by API/CLI)
```rust
use stealth_bitcoincore::BitcoinCoreRpc;
let gateway = BitcoinCoreRpc::from_url(
"http://127.0.0.1:18443",
Some("rpcuser".to_owned()),
Some("rpcpassword".to_owned()),
)?;
# let _ = gateway;
# Ok::<(), stealth_model::error::AnalysisError>(())
```
Pass explicit credentials (or parse a cookie file yourself and pass those
values here).
### 2) Build from INI file
```ini
[bitcoin]
network=regtest
datadir=/home/user/.bitcoin
rpchost=127.0.0.1
rpcport=18443
rpcuser=rpcuser
rpcpassword=rpcpassword
```
```rust
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
let config = BitcoinCoreConfig::from_ini_file("stealth.ini")?;
let gateway = BitcoinCoreRpc::new(config)?;
# let _ = gateway;
# Ok::<(), stealth_model::error::AnalysisError>(())
```
Config defaults:
- `network`: `regtest`
- `rpchost`: `127.0.0.1`
- `rpcport`: inferred from network (`8332` mainnet, `18332` testnet, `38332` signet, `18443` regtest)
- `datadir`: optional (required for cookie fallback)
Cookie lookup:
- mainnet: `<datadir>/.cookie`
- other networks: `<datadir>/<network>/.cookie`, then `<datadir>/.cookie`
## Using with the analysis engine
```rust,ignore
use stealth_bitcoincore::BitcoinCoreRpc;
use stealth_engine::{AnalysisEngine, EngineSettings, ScanTarget};
let gateway = BitcoinCoreRpc::from_url(
"http://127.0.0.1:18443",
Some("rpcuser".to_owned()),
Some("rpcpassword".to_owned()),
)?;
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
let report = engine.analyze(ScanTarget::Descriptor(
"wpkh([f23f9fd2/84h/1h/0h]tpub.../0/*)".to_owned(),
))?;
println!("clean: {}", report.summary.clean);
# Ok::<(), stealth_model::error::AnalysisError>(())
```
## RPC methods used
| Gateway behavior | Bitcoin Core RPC |
| --- | --- |
| Descriptor normalization | `getdescriptorinfo` |
| Address derivation | `deriveaddresses` |
| Temporary wallet creation | `createwallet` |
| Descriptor import | `importdescriptors` |
| Wallet tx list | `listtransactions` |
| UTXO list | `listunspent` |
| Raw tx decode/history expansion | `getrawtransaction` (verbose) |
| Wallet descriptor listing | `listdescriptors` |
| Wallet cleanup | `unloadwallet` |
## Notes
- Descriptor scans create a temporary wallet named `_stealth_scan_<timestamp_ms>`
and unload it after collection.
- Most gateway failures are surfaced as
`AnalysisError::EnvironmentUnavailable(...)` with the underlying RPC/context
message.
- For robust transaction lookups beyond wallet-only data, running `bitcoind`
with `txindex=1` is recommended.
## Development
```bash
cargo test -p stealth-bitcoincore
```
## License
[MIT](../LICENSE)
@@ -3,17 +3,19 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Txid};
use ini::Ini;
use reqwest::blocking::Client;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use stealth_core::error::AnalysisError;
use stealth_core::gateway::BlockchainGateway;
use stealth_core::model::{
DecodedTransaction, DescriptorType, ResolvedDescriptor, TxInputRef, TxOutput, Utxo,
WalletHistory, WalletTxCategory, WalletTxEntry,
use serde::Deserialize;
use serde_json::{json, Value};
use stealth_model::error::AnalysisError;
use stealth_model::gateway::{
BlockchainGateway, DecodedTransaction, DescriptorType, ResolvedDescriptor, TxInputRef,
TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry,
};
use stealth_model::types::btc_to_amount;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BitcoinCoreConfig {
@@ -93,13 +95,8 @@ impl BitcoinCoreConfig {
if !candidate.exists() {
continue;
}
let contents = fs::read_to_string(&candidate)
.map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?;
let mut parts = contents.trim().splitn(2, ':');
let user = parts.next().unwrap_or_default().to_string();
let password = parts.next().unwrap_or_default().to_string();
if !user.is_empty() && !password.is_empty() {
return Ok((user, password));
if let Ok(creds) = read_cookie_file(&candidate) {
return Ok(creds);
}
}
@@ -109,6 +106,34 @@ impl BitcoinCoreConfig {
}
}
/// Read a Bitcoin Core `.cookie` file, returning `(user, password)`.
///
/// The cookie format is a single line of `__cookie__:hex_password`.
pub fn read_cookie_file(path: &Path) -> Result<(String, String), AnalysisError> {
let contents = fs::read_to_string(path).map_err(|e| {
AnalysisError::EnvironmentUnavailable(format!(
"cannot read cookie file {}: {e}",
path.display()
))
})?;
let mut parts = contents.trim().splitn(2, ':');
let user = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display()))
})?
.to_string();
let pass = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display()))
})?
.to_string();
Ok((user, pass))
}
pub struct BitcoinCoreRpc {
config: BitcoinCoreConfig,
client: Client,
@@ -122,6 +147,27 @@ impl BitcoinCoreRpc {
Ok(Self { config, client })
}
/// Construct a gateway from a URL and optional credentials.
///
/// This mirrors the env-var based configuration used by the HTTP
/// API (`STEALTH_RPC_URL`, `STEALTH_RPC_USER`, `STEALTH_RPC_PASS`).
pub fn from_url(
url: &str,
user: Option<String>,
password: Option<String>,
) -> Result<Self, AnalysisError> {
let (host, port) = parse_host_port_from_url(url);
let config = BitcoinCoreConfig {
network: infer_network_from_port(port),
datadir: None,
rpchost: host,
rpcport: port,
rpcuser: user,
rpcpassword: password,
};
Self::new(config)
}
fn rpc_url(&self, wallet: Option<&str>) -> String {
let base = format!("http://{}:{}", self.config.rpchost, self.config.rpcport);
wallet
@@ -183,9 +229,9 @@ impl BitcoinCoreRpc {
let utxos = self.list_unspent(wallet_name)?;
let mut txids = wallet_txs
.iter()
.map(|entry| entry.txid.clone())
.map(|entry| entry.txid)
.collect::<HashSet<_>>();
txids.extend(utxos.iter().map(|utxo| utxo.txid.clone()));
txids.extend(utxos.iter().map(|utxo| utxo.txid));
let mut transactions = HashMap::new();
let mut queue = txids.into_iter().collect::<Vec<_>>();
@@ -193,19 +239,21 @@ impl BitcoinCoreRpc {
if transactions.contains_key(&txid) {
continue;
}
let tx = self.get_transaction(&txid)?;
let tx = self.decode_transaction(txid)?;
for input in &tx.vin {
if !input.coinbase && !transactions.contains_key(&input.previous_txid) {
queue.push(input.previous_txid.clone());
queue.push(input.previous_txid);
}
}
transactions.insert(txid.clone(), tx);
transactions.insert(txid, tx);
}
Ok(WalletHistory {
wallet_txs,
utxos,
transactions,
internal_addresses: HashSet::new(),
derived_addresses: HashSet::new(),
})
}
@@ -215,21 +263,25 @@ impl BitcoinCoreRpc {
"listtransactions",
vec![json!("*"), json!(10000), json!(0), json!(true)],
)?;
Ok(entries
entries
.into_iter()
.map(|entry| WalletTxEntry {
txid: entry.txid,
address: entry.address.unwrap_or_default(),
category: match entry.category.as_deref() {
Some("send") => WalletTxCategory::Send,
Some("receive") => WalletTxCategory::Receive,
_ => WalletTxCategory::Unknown,
},
amount_btc: entry.amount,
confirmations: entry.confirmations.unwrap_or_default(),
blockheight: entry.blockheight.unwrap_or_default(),
.map(|entry| {
let address: Option<Address<NetworkUnchecked>> =
entry.address.as_deref().and_then(|s| s.parse().ok());
Ok(WalletTxEntry {
txid: parse_txid(&entry.txid)?,
address,
category: match entry.category.as_deref() {
Some("send") => WalletTxCategory::Send,
Some("receive") => WalletTxCategory::Receive,
_ => WalletTxCategory::Unknown,
},
amount: btc_to_amount(entry.amount.abs()),
confirmations: entry.confirmations.unwrap_or_default(),
blockheight: entry.blockheight.unwrap_or_default(),
})
})
.collect())
.collect()
}
fn list_unspent(&self, wallet_name: &str) -> Result<Vec<Utxo>, AnalysisError> {
@@ -238,43 +290,59 @@ impl BitcoinCoreRpc {
"listunspent",
vec![json!(0), json!(9_999_999)],
)?;
Ok(utxos
utxos
.into_iter()
.map(|utxo| {
let address = utxo.address.unwrap_or_default();
Utxo {
txid: utxo.txid,
let address: Option<Address<NetworkUnchecked>> =
utxo.address.as_deref().and_then(|s| s.parse().ok());
Ok(Utxo {
txid: parse_txid(&utxo.txid)?,
vout: utxo.vout,
address: address.clone(),
amount_btc: utxo.amount,
script_type: address
.as_ref()
.map(DescriptorType::infer_from_address)
.unwrap_or(DescriptorType::Unknown),
address,
amount: btc_to_amount(utxo.amount),
confirmations: utxo.confirmations.unwrap_or_default(),
script_type: DescriptorType::infer_from_address(&address),
}
})
})
.collect())
.collect()
}
fn get_transaction(&self, txid: &str) -> Result<DecodedTransaction, AnalysisError> {
let tx =
self.call::<RawTransaction>(None, "getrawtransaction", vec![json!(txid), json!(true)])?;
fn decode_transaction(&self, txid: Txid) -> Result<DecodedTransaction, AnalysisError> {
let tx = self.call::<RawTransaction>(
None,
"getrawtransaction",
vec![json!(txid.to_string()), json!(true)],
)?;
Ok(DecodedTransaction {
txid: tx.txid,
txid: parse_txid(&tx.txid)?,
vin: tx
.vin
.into_iter()
.map(|input| TxInputRef {
previous_txid: input.txid.unwrap_or_default(),
previous_vout: input.vout.unwrap_or_default(),
sequence: input.sequence.unwrap_or(0xffff_ffff),
coinbase: input.coinbase.is_some(),
.map(|input| {
Ok(TxInputRef {
previous_txid: match &input.txid {
Some(s) => parse_txid(s)?,
// Bitcoin protocol: coinbase inputs reference all-zeros.
None => parse_txid(
"0000000000000000000000000000000000000000000000000000000000000000",
)
.expect("zero txid is always valid"),
},
previous_vout: input.vout.unwrap_or_default(),
sequence: input.sequence.unwrap_or(0xffff_ffff),
coinbase: input.coinbase.is_some(),
})
})
.collect(),
.collect::<Result<Vec<_>, AnalysisError>>()?,
vout: tx
.vout
.into_iter()
.map(|output| {
let address = output
let address: Option<Address<NetworkUnchecked>> = output
.script_pub_key
.address
.or_else(|| {
@@ -283,17 +351,22 @@ impl BitcoinCoreRpc {
.addresses
.and_then(|mut items| items.pop())
})
.unwrap_or_default();
.and_then(|s| s.parse().ok());
TxOutput {
n: output.n,
address: address.clone(),
value_btc: output.value,
script_type: output
.script_pub_key
.script_type
.as_deref()
.map(descriptor_type_from_script_pub_key)
.unwrap_or_else(|| DescriptorType::infer_from_address(&address)),
script_type: address
.as_ref()
.map(DescriptorType::infer_from_address)
.or_else(|| {
output
.script_pub_key
.script_type
.as_deref()
.map(descriptor_type_from_script_pub_key)
})
.unwrap_or(DescriptorType::Unknown),
address,
value: btc_to_amount(output.value),
}
})
.collect(),
@@ -335,12 +408,20 @@ impl BlockchainGateway for BitcoinCoreRpc {
fn derive_addresses(
&self,
descriptor: &ResolvedDescriptor,
) -> Result<Vec<String>, AnalysisError> {
self.call(
) -> Result<Vec<Address<NetworkUnchecked>>, AnalysisError> {
let strings: Vec<String> = self.call(
None,
"deriveaddresses",
vec![json!(descriptor.desc), json!([0, descriptor.range_end])],
)
)?;
strings
.into_iter()
.map(|s| {
s.parse::<Address<NetworkUnchecked>>().map_err(|e| {
AnalysisError::EnvironmentUnavailable(format!("invalid address '{s}': {e}"))
})
})
.collect()
}
fn scan_descriptors(
@@ -356,16 +437,27 @@ impl BlockchainGateway for BitcoinCoreRpc {
);
self.create_watch_only_wallet(&wallet_name)?;
// RAII guard: ensure the temporary wallet is always unloaded,
// even if the body below returns an early error via `?`.
let _guard = WalletGuard {
rpc: self,
name: &wallet_name,
};
let imports = descriptors
.iter()
.map(|descriptor| {
json!({
let is_ranged = descriptor.desc.contains('*');
let mut entry = json!({
"desc": descriptor.desc,
"timestamp": 0,
"internal": descriptor.internal,
"active": descriptor.active,
"range": [0, descriptor.range_end],
})
"active": is_ranged && descriptor.active,
});
if is_ranged {
entry["range"] = json!([0, descriptor.range_end]);
}
entry
})
.collect::<Vec<_>>();
@@ -375,15 +467,34 @@ impl BlockchainGateway for BitcoinCoreRpc {
vec![json!(imports)],
)?;
if import_results.iter().any(|result| !result.success) {
self.unload_wallet(&wallet_name);
return Err(AnalysisError::EnvironmentUnavailable(
"descriptor import failed".into(),
));
let errors: Vec<_> = import_results
.iter()
.filter(|r| !r.success)
.filter_map(|r| r.error.as_ref().map(|e| e.message.as_str()))
.collect();
return Err(AnalysisError::EnvironmentUnavailable(format!(
"descriptor import failed: {}",
errors.join("; ")
)));
}
let history = self.load_history_for_wallet(&wallet_name);
self.unload_wallet(&wallet_name);
history
let mut history = self.load_history_for_wallet(&wallet_name)?;
// Derive all addresses from every descriptor
let mut internal_addresses = HashSet::new();
let mut derived_addresses = HashSet::new();
for desc in descriptors {
if let Ok(addrs) = self.derive_addresses(desc) {
if desc.internal {
internal_addresses.extend(addrs.iter().cloned());
}
derived_addresses.extend(addrs);
}
}
history.internal_addresses = internal_addresses;
history.derived_addresses = derived_addresses;
Ok(history)
}
fn list_wallet_descriptors(
@@ -411,13 +522,30 @@ impl BlockchainGateway for BitcoinCoreRpc {
}
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError> {
self.load_history_for_wallet(wallet_name)
let mut history = self.load_history_for_wallet(wallet_name)?;
// Derive ALL addresses from every descriptor (both external and
// internal chains) so that `is_ours()` in TxGraph recognises
// every derived address.
if let Ok(descriptors) = self.list_wallet_descriptors(wallet_name) {
let mut internal_addresses = HashSet::new();
let mut derived_addresses = HashSet::new();
for desc in &descriptors {
if let Ok(addrs) = self.derive_addresses(desc) {
if desc.internal {
internal_addresses.extend(addrs.iter().cloned());
}
derived_addresses.extend(addrs);
}
}
history.internal_addresses = internal_addresses;
history.derived_addresses = derived_addresses;
}
Ok(history)
}
fn known_wallet_txids(
&self,
wallet_names: &[String],
) -> Result<HashSet<String>, AnalysisError> {
fn known_wallet_txids(&self, wallet_names: &[String]) -> Result<HashSet<Txid>, AnalysisError> {
let mut txids = HashSet::new();
for wallet_name in wallet_names {
txids.extend(
@@ -428,6 +556,28 @@ impl BlockchainGateway for BitcoinCoreRpc {
}
Ok(txids)
}
fn get_transaction(&self, txid: Txid) -> Result<DecodedTransaction, AnalysisError> {
self.decode_transaction(txid)
}
}
/// RAII guard that calls `unloadwallet` when dropped, ensuring cleanup
/// even when an early `?` return skips the normal unload path.
struct WalletGuard<'a> {
rpc: &'a BitcoinCoreRpc,
name: &'a str,
}
impl Drop for WalletGuard<'_> {
fn drop(&mut self) {
self.rpc.unload_wallet(self.name);
}
}
fn parse_txid(s: &str) -> Result<Txid, AnalysisError> {
s.parse::<Txid>()
.map_err(|e| AnalysisError::EnvironmentUnavailable(format!("invalid txid '{s}': {e}")))
}
fn default_rpc_port(network: &str) -> u16 {
@@ -443,12 +593,38 @@ fn descriptor_type_from_script_pub_key(script_type: &str) -> DescriptorType {
match script_type {
"witness_v0_keyhash" => DescriptorType::P2wpkh,
"witness_v1_taproot" => DescriptorType::P2tr,
"scripthash" => DescriptorType::P2shP2wpkh,
"scripthash" => DescriptorType::P2sh,
"pubkeyhash" => DescriptorType::P2pkh,
_ => DescriptorType::Unknown,
}
}
fn parse_host_port_from_url(url: &str) -> (String, u16) {
let without_scheme = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.unwrap_or(url);
let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
match authority.rsplit_once(':') {
Some((host, port_str)) => {
let port = port_str.parse::<u16>().unwrap_or(8332);
(host.to_owned(), port)
}
None => (authority.to_owned(), 8332),
}
}
fn infer_network_from_port(port: u16) -> String {
match port {
8332 => "mainnet",
18332 => "testnet",
38332 => "signet",
18443 => "regtest",
_ => "regtest",
}
.to_owned()
}
#[derive(Debug, Deserialize)]
struct JsonRpcEnvelope<T> {
result: Option<T>,
@@ -468,6 +644,14 @@ struct DescriptorInfo {
#[derive(Debug, Deserialize)]
struct ImportResult {
success: bool,
#[serde(default)]
error: Option<ImportError>,
}
#[derive(Debug, Deserialize)]
struct ImportError {
#[serde(default)]
message: String,
}
#[derive(Debug, Deserialize)]
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "stealth-app"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
axum.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
stealth-bitcoincore = { path = "../stealth-bitcoincore" }
stealth-core = { path = "../stealth-core" }
tokio.workspace = true
tower-http = { version = "0.6", features = ["cors"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
-39
View File
@@ -1,39 +0,0 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use stealth_app::{build_router, build_runtime_service, default_bitcoin_config_path};
use stealth_core::engine::EngineSettings;
#[derive(Debug, Parser)]
struct ApiCli {
#[arg(long, default_value = "0.0.0.0")]
host: String,
#[arg(long, default_value_t = 8080)]
port: u16,
#[arg(long, default_value = "http://localhost:5173")]
cors_origin: String,
#[arg(long, default_value_os_t = default_bitcoin_config_path())]
config: PathBuf,
#[arg(long = "known-risky-wallet")]
known_risky_wallets: Vec<String>,
#[arg(long = "known-exchange-wallet")]
known_exchange_wallets: Vec<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = ApiCli::parse();
let settings = EngineSettings {
known_exchange_wallets: cli.known_exchange_wallets,
known_risky_wallets: cli.known_risky_wallets,
..EngineSettings::default()
};
let service = build_runtime_service(&cli.config, settings)?;
let router = build_router(Arc::new(service), Some(&cli.cors_origin));
let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
}
-189
View File
@@ -1,189 +0,0 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::{HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::{Json, Router, routing::get};
use serde::{Deserialize, Serialize};
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
use stealth_core::error::AnalysisError;
use stealth_core::gateway::BlockchainGateway;
use stealth_core::model::AnalysisReport;
use tower_http::cors::CorsLayer;
pub trait ScanService: Send + Sync + 'static {
fn analyze_descriptor(&self, descriptor: String) -> Result<AnalysisReport, AnalysisError>;
}
pub struct CoreScanService<G> {
gateway: G,
settings: EngineSettings,
}
impl<G> CoreScanService<G> {
pub fn new(gateway: G, settings: EngineSettings) -> Self {
Self { gateway, settings }
}
}
impl<G> ScanService for CoreScanService<G>
where
G: BlockchainGateway + Send + Sync + 'static,
{
fn analyze_descriptor(&self, descriptor: String) -> Result<AnalysisReport, AnalysisError> {
AnalysisEngine::new(&self.gateway, self.settings.clone())
.analyze(ScanTarget::Descriptors(vec![descriptor]))
}
}
pub fn default_bitcoin_config_path() -> PathBuf {
PathBuf::from("backend/script/config.ini")
}
pub fn build_runtime_service(
config_path: &Path,
settings: EngineSettings,
) -> Result<CoreScanService<BitcoinCoreRpc>, AnalysisError> {
let config = BitcoinCoreConfig::from_ini_file(config_path)?;
let gateway = BitcoinCoreRpc::new(config)?;
Ok(CoreScanService::new(gateway, settings))
}
pub fn build_router<S>(service: Arc<S>, cors_origin: Option<&str>) -> Router
where
S: ScanService,
{
let mut router = Router::new()
.route("/api/wallet/scan", get(scan_handler::<S>))
.with_state(service);
if let Some(origin) = cors_origin {
if let Ok(header_value) = HeaderValue::from_str(origin) {
router = router.layer(
CorsLayer::new()
.allow_origin(header_value)
.allow_methods([Method::GET])
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::ACCEPT]),
);
}
}
router
}
#[derive(Debug, Deserialize)]
struct ScanQuery {
descriptor: Option<String>,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
async fn scan_handler<S>(State(service): State<Arc<S>>, Query(query): Query<ScanQuery>) -> Response
where
S: ScanService,
{
let Some(descriptor) = query.descriptor.map(|value| value.trim().to_string()) else {
return json_error(
StatusCode::BAD_REQUEST,
"descriptor query parameter is required".into(),
);
};
if descriptor.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
"descriptor query parameter is required".into(),
);
}
match service.analyze_descriptor(descriptor) {
Ok(report) => Json(report).into_response(),
Err(error) => map_error(error),
}
}
fn map_error(error: AnalysisError) -> Response {
match error {
AnalysisError::EmptyDescriptor | AnalysisError::DescriptorNormalization { .. } => {
json_error(StatusCode::BAD_REQUEST, error.to_string())
}
AnalysisError::AnalysisEmpty => json_error(StatusCode::NOT_FOUND, error.to_string()),
AnalysisError::EnvironmentUnavailable(_) => {
json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
}
}
}
fn json_error(status: StatusCode, message: String) -> Response {
(status, Json(ErrorBody { error: message })).into_response()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use serde_json::json;
use tower::util::ServiceExt;
use super::*;
struct MockService(Result<AnalysisReport, AnalysisError>);
impl ScanService for MockService {
fn analyze_descriptor(&self, _descriptor: String) -> Result<AnalysisReport, AnalysisError> {
self.0.clone()
}
}
#[tokio::test]
async fn returns_400_for_missing_descriptor() {
let app = build_router(
Arc::new(MockService(Ok(AnalysisReport::new(
0,
0,
Vec::new(),
Vec::new(),
)))),
None,
);
let response = app
.oneshot(
Request::builder()
.uri("/api/wallet/scan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn returns_json_report_for_successful_scan() {
let report = AnalysisReport::new(1, 2, Vec::new(), Vec::new());
let app = build_router(Arc::new(MockService(Ok(report))), None);
let response = app
.oneshot(
Request::builder()
.uri("/api/wallet/scan?descriptor=wpkh(test)")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json = serde_json::from_slice::<serde_json::Value>(&body).unwrap();
assert_eq!(json["summary"]["clean"], json!(true));
}
}
-48
View File
@@ -1,48 +0,0 @@
use std::path::PathBuf;
use clap::Parser;
use stealth_app::default_bitcoin_config_path;
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
#[derive(Debug, Parser)]
struct Cli {
#[arg(long = "descriptor", short = 'd')]
descriptors: Vec<String>,
#[arg(long)]
wallet: Option<String>,
#[arg(long, default_value_os_t = default_bitcoin_config_path())]
config: PathBuf,
#[arg(long = "known-risky-wallet")]
known_risky_wallets: Vec<String>,
#[arg(long = "known-exchange-wallet")]
known_exchange_wallets: Vec<String>,
#[arg(long)]
pretty: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let settings = EngineSettings {
known_exchange_wallets: cli.known_exchange_wallets,
known_risky_wallets: cli.known_risky_wallets,
..EngineSettings::default()
};
let config = BitcoinCoreConfig::from_ini_file(&cli.config)?;
let gateway = BitcoinCoreRpc::new(config)?;
let engine = AnalysisEngine::new(&gateway, settings);
let report = if let Some(wallet_name) = cli.wallet {
engine.analyze(ScanTarget::WalletName(wallet_name))?
} else {
engine.analyze(ScanTarget::Descriptors(cli.descriptors))?
};
if cli.pretty {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", serde_json::to_string(&report)?);
}
Ok(())
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "stealth-bitcoincore"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
ini = { package = "rust-ini", version = "0.21" }
serde.workspace = true
serde_json.workspace = true
stealth-core = { path = "../stealth-core" }
thiserror.workspace = true
urlencoding = "2.1"
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "stealth-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
use crate::config::AnalysisConfig;
use crate::descriptor::normalize_descriptors;
use crate::detectors::{DetectorContext, run_all};
use crate::error::AnalysisError;
use crate::gateway::BlockchainGateway;
use crate::graph::TxGraph;
use crate::model::{
AnalysisReport, DerivedAddress, DescriptorChainRole, DescriptorType, ResolvedDescriptor,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EngineSettings {
pub analysis: AnalysisConfig,
pub known_exchange_wallets: Vec<String>,
pub known_risky_wallets: Vec<String>,
}
impl Default for EngineSettings {
fn default() -> Self {
Self {
analysis: AnalysisConfig::default(),
known_exchange_wallets: Vec::new(),
known_risky_wallets: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScanTarget {
Descriptors(Vec<String>),
WalletName(String),
}
pub struct AnalysisEngine<'a, G> {
gateway: &'a G,
settings: EngineSettings,
}
impl<'a, G> AnalysisEngine<'a, G>
where
G: BlockchainGateway,
{
pub fn new(gateway: &'a G, settings: EngineSettings) -> Self {
Self { gateway, settings }
}
pub fn analyze(&self, target: ScanTarget) -> Result<AnalysisReport, AnalysisError> {
let (descriptors, history) = match target {
ScanTarget::Descriptors(raw_descriptors) => {
if raw_descriptors.is_empty() {
return Err(AnalysisError::EmptyDescriptor);
}
let descriptors = normalize_descriptors(
&raw_descriptors,
self.settings.analysis.derivation_range_end,
self.gateway,
)?;
let history = self.gateway.scan_descriptors(&descriptors)?;
(descriptors, history)
}
ScanTarget::WalletName(wallet_name) => {
let descriptors = self.gateway.list_wallet_descriptors(&wallet_name)?;
let history = self.gateway.scan_wallet(&wallet_name)?;
(descriptors, history)
}
};
if history.wallet_txs.is_empty() {
return Err(AnalysisError::AnalysisEmpty);
}
let derived_addresses = self.derive_all_addresses(&descriptors)?;
let graph = TxGraph::new(derived_addresses.clone(), history);
let known_exchange_txids = self
.gateway
.known_wallet_txids(&self.settings.known_exchange_wallets)?;
let known_risky_txids = self
.gateway
.known_wallet_txids(&self.settings.known_risky_wallets)?;
let detector_result = run_all(&DetectorContext {
graph: &graph,
config: &self.settings.analysis,
known_exchange_txids: &known_exchange_txids,
known_risky_txids: &known_risky_txids,
});
Ok(AnalysisReport::new(
graph.our_txids().count(),
derived_addresses.len(),
detector_result.findings,
detector_result.warnings,
))
}
fn derive_all_addresses(
&self,
descriptors: &[ResolvedDescriptor],
) -> Result<Vec<DerivedAddress>, AnalysisError> {
let mut addresses = Vec::new();
for descriptor in descriptors {
let descriptor_type = DescriptorType::from_descriptor(&descriptor.desc);
let chain_role = if descriptor.internal {
DescriptorChainRole::Internal
} else {
DescriptorChainRole::External
};
let derived = self.gateway.derive_addresses(descriptor)?;
addresses.extend(derived.into_iter().enumerate().map(|(index, address)| {
DerivedAddress {
address,
descriptor_type,
chain_role,
derivation_index: index as u32,
}
}));
}
Ok(addresses)
}
}
-33
View File
@@ -1,33 +0,0 @@
use std::collections::HashSet;
use crate::descriptor::DescriptorNormalizer;
use crate::error::AnalysisError;
use crate::model::{ResolvedDescriptor, WalletHistory};
pub trait BlockchainGateway {
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError>;
fn derive_addresses(
&self,
descriptor: &ResolvedDescriptor,
) -> Result<Vec<String>, AnalysisError>;
fn scan_descriptors(
&self,
descriptors: &[ResolvedDescriptor],
) -> Result<WalletHistory, AnalysisError>;
fn list_wallet_descriptors(
&self,
wallet_name: &str,
) -> Result<Vec<ResolvedDescriptor>, AnalysisError>;
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError>;
fn known_wallet_txids(&self, wallet_names: &[String])
-> Result<HashSet<String>, AnalysisError>;
}
impl<T> DescriptorNormalizer for T
where
T: BlockchainGateway + ?Sized,
{
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError> {
self.normalize_descriptor(descriptor)
}
}
-151
View File
@@ -1,151 +0,0 @@
use std::collections::{HashMap, HashSet};
use crate::model::{
DecodedTransaction, DerivedAddress, DescriptorChainRole, DescriptorType,
TransactionParticipant, TxOutput, Utxo, WalletHistory, WalletTxEntry,
};
#[derive(Debug, Clone)]
pub struct TxGraph {
addresses: HashMap<String, DerivedAddress>,
our_addrs: HashSet<String>,
history: WalletHistory,
addr_txs: HashMap<String, Vec<WalletTxEntry>>,
tx_addrs: HashMap<String, HashSet<String>>,
our_txids: HashSet<String>,
}
impl TxGraph {
pub fn new(addresses: Vec<DerivedAddress>, history: WalletHistory) -> Self {
let mut address_map = HashMap::new();
let mut our_addrs = HashSet::new();
let mut addr_txs: HashMap<String, Vec<WalletTxEntry>> = HashMap::new();
let mut tx_addrs: HashMap<String, HashSet<String>> = HashMap::new();
let mut our_txids = HashSet::new();
for address in addresses {
our_addrs.insert(address.address.clone());
address_map.insert(address.address.clone(), address);
}
for entry in &history.wallet_txs {
our_txids.insert(entry.txid.clone());
if !entry.address.is_empty() {
addr_txs
.entry(entry.address.clone())
.or_default()
.push(entry.clone());
tx_addrs
.entry(entry.txid.clone())
.or_default()
.insert(entry.address.clone());
}
}
Self {
addresses: address_map,
our_addrs,
history,
addr_txs,
tx_addrs,
our_txids,
}
}
pub fn addresses(&self) -> impl Iterator<Item = &DerivedAddress> {
self.addresses.values()
}
pub fn derived_address(&self, address: &str) -> Option<&DerivedAddress> {
self.addresses.get(address)
}
pub fn wallet_entries(&self, address: &str) -> &[WalletTxEntry] {
self.addr_txs.get(address).map(Vec::as_slice).unwrap_or(&[])
}
pub fn tx_addrs(&self, txid: &str) -> Option<&HashSet<String>> {
self.tx_addrs.get(txid)
}
pub fn tx(&self, txid: &str) -> Option<&DecodedTransaction> {
self.history.transactions.get(txid)
}
pub fn our_txids(&self) -> impl Iterator<Item = &String> {
self.our_txids.iter()
}
pub fn utxos(&self) -> &[Utxo] {
&self.history.utxos
}
pub fn is_ours(&self, address: &str) -> bool {
self.our_addrs.contains(address)
}
pub fn get_script_type(&self, address: &str) -> DescriptorType {
self.derived_address(address)
.map(|item| item.descriptor_type)
.unwrap_or_else(|| DescriptorType::infer_from_address(address))
}
pub fn output_by_outpoint(&self, txid: &str, vout: u32) -> Option<&TxOutput> {
self.tx(txid)?.vout.iter().find(|output| output.n == vout)
}
pub fn input_participants(&self, txid: &str) -> Vec<TransactionParticipant> {
let Some(tx) = self.tx(txid) else {
return Vec::new();
};
tx.vin
.iter()
.filter(|input| !input.coinbase)
.filter_map(|input| {
let previous_output =
self.output_by_outpoint(&input.previous_txid, input.previous_vout)?;
let script_type = self.get_script_type(&previous_output.address);
Some(TransactionParticipant {
address: previous_output.address.clone(),
value_btc: previous_output.value_btc,
value_sats: btc_to_sats(previous_output.value_btc),
script_type,
is_ours: self.is_ours(&previous_output.address),
funding_txid: Some(input.previous_txid.clone()),
funding_vout: Some(input.previous_vout),
})
})
.collect()
}
pub fn output_participants(&self, txid: &str) -> Vec<TransactionParticipant> {
let Some(tx) = self.tx(txid) else {
return Vec::new();
};
tx.vout
.iter()
.map(|output| TransactionParticipant {
address: output.address.clone(),
value_btc: output.value_btc,
value_sats: btc_to_sats(output.value_btc),
script_type: self.get_script_type(&output.address),
is_ours: self.is_ours(&output.address),
funding_txid: Some(txid.to_string()),
funding_vout: Some(output.n),
})
.collect()
}
pub fn address_role(&self, address: &str) -> &'static str {
match self.derived_address(address).map(|item| item.chain_role) {
Some(DescriptorChainRole::Internal) => "change",
_ => "receive",
}
}
}
pub fn btc_to_sats(value_btc: f64) -> u64 {
(value_btc * 100_000_000.0).round() as u64
}
-80
View File
@@ -1,80 +0,0 @@
pub mod config;
pub mod descriptor;
pub mod detectors;
pub mod engine;
pub mod error;
pub mod gateway;
pub mod graph;
pub mod model;
#[cfg(test)]
mod tests {
use crate::descriptor::{DescriptorNormalizer, normalize_descriptors};
use crate::model::{
AnalysisReport, Finding, FindingDetails, FindingKind, Severity, Warning, WarningDetails,
WarningKind,
};
use serde_json::json;
#[test]
fn normalizes_checksums_and_infers_change_descriptor_pair() {
struct RecordingNormalizer;
impl DescriptorNormalizer for RecordingNormalizer {
fn normalize(&self, descriptor: &str) -> Result<String, crate::error::AnalysisError> {
Ok(format!("normalized:{descriptor}"))
}
}
let normalized = normalize_descriptors(
&[String::from("wpkh([abcd/84h/1h/0h]tpub123/0/*)#checksum")],
777,
&RecordingNormalizer,
)
.expect("descriptor normalization should succeed");
assert_eq!(normalized.len(), 2);
assert_eq!(
normalized[0].desc,
"normalized:wpkh([abcd/84h/1h/0h]tpub123/0/*)"
);
assert!(!normalized[0].internal);
assert_eq!(
normalized[1].desc,
"normalized:wpkh([abcd/84h/1h/0h]tpub123/1/*)"
);
assert!(normalized[1].internal);
assert_eq!(normalized[1].range_end, 777);
}
#[test]
fn report_summary_tracks_clean_state_and_counts() {
let finding = Finding {
kind: FindingKind::AddressReuse,
severity: Severity::High,
description: "address reused".into(),
details: FindingDetails::Generic(json!({"address":"bcrt1qexample"})),
correction: Some("use a fresh address".into()),
};
let warning = Warning {
kind: WarningKind::DormantUtxos,
severity: Severity::Low,
description: "dormant coins".into(),
details: WarningDetails::Generic(json!({"count":1})),
};
let report = AnalysisReport::new(12, 34, vec![finding], vec![warning]);
assert_eq!(report.summary.findings, 1);
assert_eq!(report.summary.warnings, 1);
assert!(!report.summary.clean);
assert_eq!(report.stats.transactions_analyzed, 12);
assert_eq!(report.stats.addresses_derived, 34);
}
#[test]
fn empty_report_is_marked_clean() {
let report = AnalysisReport::new(0, 0, Vec::new(), Vec::new());
assert!(report.summary.clean);
}
}
-267
View File
@@ -1,267 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FindingKind {
AddressReuse,
Cioh,
Dust,
DustSpending,
ChangeDetection,
Consolidation,
ScriptTypeMixing,
ClusterMerge,
UtxoAgeSpread,
ExchangeOrigin,
TaintedUtxoMerge,
BehavioralFingerprint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WarningKind {
DormantUtxos,
DirectTaint,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FindingDetails {
Generic(Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WarningDetails {
Generic(Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
#[serde(rename = "type")]
pub kind: FindingKind,
pub severity: Severity,
pub description: String,
pub details: FindingDetails,
#[serde(skip_serializing_if = "Option::is_none")]
pub correction: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Warning {
#[serde(rename = "type")]
pub kind: WarningKind,
pub severity: Severity,
pub description: String,
pub details: WarningDetails,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalysisStats {
pub transactions_analyzed: usize,
pub addresses_derived: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalysisSummary {
pub findings: usize,
pub warnings: usize,
pub clean: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisReport {
pub stats: AnalysisStats,
pub findings: Vec<Finding>,
pub warnings: Vec<Warning>,
pub summary: AnalysisSummary,
}
impl AnalysisReport {
pub fn new(
transactions_analyzed: usize,
addresses_derived: usize,
findings: Vec<Finding>,
warnings: Vec<Warning>,
) -> Self {
let summary = AnalysisSummary {
findings: findings.len(),
warnings: warnings.len(),
clean: findings.is_empty() && warnings.is_empty(),
};
Self {
stats: AnalysisStats {
transactions_analyzed,
addresses_derived,
},
findings,
warnings,
summary,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorChainRole {
External,
Internal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorType {
P2wpkh,
P2tr,
P2shP2wpkh,
P2pkh,
Unknown,
}
impl DescriptorType {
pub fn from_descriptor(descriptor: &str) -> Self {
if descriptor.starts_with("wpkh(") {
Self::P2wpkh
} else if descriptor.starts_with("tr(") {
Self::P2tr
} else if descriptor.starts_with("sh(wpkh(") {
Self::P2shP2wpkh
} else if descriptor.starts_with("pkh(") {
Self::P2pkh
} else {
Self::Unknown
}
}
pub fn infer_from_address(address: &str) -> Self {
if address.starts_with("bc1q")
|| address.starts_with("tb1q")
|| address.starts_with("bcrt1q")
{
Self::P2wpkh
} else if address.starts_with("bc1p")
|| address.starts_with("tb1p")
|| address.starts_with("bcrt1p")
{
Self::P2tr
} else if address.starts_with('2') || address.starts_with('3') {
Self::P2shP2wpkh
} else if address.starts_with('1') || address.starts_with('m') || address.starts_with('n') {
Self::P2pkh
} else {
Self::Unknown
}
}
pub fn as_script_name(self) -> &'static str {
match self {
Self::P2wpkh => "witness_v0_keyhash",
Self::P2tr => "witness_v1_taproot",
Self::P2shP2wpkh => "scripthash",
Self::P2pkh => "pubkeyhash",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DerivedAddress {
pub address: String,
pub descriptor_type: DescriptorType,
pub chain_role: DescriptorChainRole,
pub derivation_index: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedDescriptor {
pub desc: String,
pub internal: bool,
pub active: bool,
pub range_end: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WalletTxCategory {
Send,
Receive,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalletTxEntry {
pub txid: String,
pub address: String,
pub category: WalletTxCategory,
pub amount_btc: f64,
pub confirmations: u32,
pub blockheight: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TxInputRef {
#[serde(rename = "txid")]
pub previous_txid: String,
#[serde(rename = "vout")]
pub previous_vout: u32,
pub sequence: u32,
pub coinbase: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TxOutput {
pub n: u32,
pub address: String,
pub value_btc: f64,
pub script_type: DescriptorType,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DecodedTransaction {
pub txid: String,
pub vin: Vec<TxInputRef>,
pub vout: Vec<TxOutput>,
pub version: i32,
pub locktime: u32,
pub vsize: u32,
pub confirmations: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Utxo {
pub txid: String,
pub vout: u32,
pub address: String,
pub amount_btc: f64,
pub confirmations: u32,
pub script_type: DescriptorType,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalletHistory {
pub wallet_txs: Vec<WalletTxEntry>,
pub utxos: Vec<Utxo>,
pub transactions: std::collections::HashMap<String, DecodedTransaction>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TransactionParticipant {
pub address: String,
pub value_btc: f64,
pub value_sats: u64,
pub script_type: DescriptorType,
pub is_ours: bool,
pub funding_txid: Option<String>,
pub funding_vout: Option<u32>,
}
-689
View File
@@ -1,689 +0,0 @@
use std::collections::{HashMap, HashSet};
use stealth_core::config::AnalysisConfig;
use stealth_core::detectors::{
DetectorContext, detect_address_reuse, detect_behavioral_fingerprint, detect_change_detection,
detect_cioh, detect_cluster_merge, detect_consolidation, detect_dust, detect_dust_spending,
detect_exchange_origin, detect_script_type_mixing, detect_tainted_utxo_merge,
detect_utxo_age_spread,
};
use stealth_core::graph::TxGraph;
use stealth_core::model::{
DecodedTransaction, DerivedAddress, DescriptorChainRole, DescriptorType, FindingKind,
TxInputRef, TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry, WarningKind,
};
fn satoshis(value: u64) -> f64 {
value as f64 / 100_000_000.0
}
fn our_address(
address: &str,
descriptor_type: DescriptorType,
chain_role: DescriptorChainRole,
) -> DerivedAddress {
DerivedAddress {
address: address.to_string(),
descriptor_type,
chain_role,
derivation_index: 0,
}
}
fn wallet_entry(
txid: &str,
address: &str,
category: WalletTxCategory,
sats: u64,
confirmations: u32,
) -> WalletTxEntry {
WalletTxEntry {
txid: txid.to_string(),
address: address.to_string(),
category,
amount_btc: satoshis(sats),
confirmations,
blockheight: 0,
}
}
fn tx(
txid: &str,
vin: Vec<TxInputRef>,
vout: Vec<TxOutput>,
confirmations: u32,
) -> DecodedTransaction {
DecodedTransaction {
txid: txid.to_string(),
vin,
vout,
version: 2,
locktime: 0,
vsize: 200,
confirmations,
}
}
fn input(previous_txid: &str, previous_vout: u32) -> TxInputRef {
TxInputRef {
previous_txid: previous_txid.to_string(),
previous_vout,
sequence: 0xffff_fffd,
coinbase: false,
}
}
fn output(n: u32, address: &str, sats: u64, script_type: DescriptorType) -> TxOutput {
TxOutput {
n,
address: address.to_string(),
value_btc: satoshis(sats),
script_type,
}
}
fn utxo(
txid: &str,
vout: u32,
address: &str,
sats: u64,
confirmations: u32,
script_type: DescriptorType,
) -> Utxo {
Utxo {
txid: txid.to_string(),
vout,
address: address.to_string(),
amount_btc: satoshis(sats),
confirmations,
script_type,
}
}
fn graph(
addresses: Vec<DerivedAddress>,
wallet_txs: Vec<WalletTxEntry>,
utxos: Vec<Utxo>,
transactions: Vec<DecodedTransaction>,
) -> TxGraph {
let history = WalletHistory {
wallet_txs,
utxos,
transactions: transactions
.into_iter()
.map(|item| (item.txid.clone(), item))
.collect::<HashMap<_, _>>(),
};
TxGraph::new(addresses, history)
}
fn context<'a>(
graph: &'a TxGraph,
config: &'a AnalysisConfig,
known_exchange_txids: &'a HashSet<String>,
known_risky_txids: &'a HashSet<String>,
) -> DetectorContext<'a> {
DetectorContext {
graph,
config,
known_exchange_txids,
known_risky_txids,
}
}
#[test]
fn address_reuse_is_detected() {
let receive = our_address(
"bcrt1qreceive",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let graph = graph(
vec![receive.clone()],
vec![
wallet_entry(
"reuse-1",
&receive.address,
WalletTxCategory::Receive,
1_000_000,
6,
),
wallet_entry(
"reuse-2",
&receive.address,
WalletTxCategory::Receive,
2_000_000,
5,
),
],
Vec::new(),
vec![
tx(
"reuse-1",
Vec::new(),
vec![output(
0,
&receive.address,
1_000_000,
DescriptorType::P2wpkh,
)],
6,
),
tx(
"reuse-2",
Vec::new(),
vec![output(
0,
&receive.address,
2_000_000,
DescriptorType::P2wpkh,
)],
5,
),
],
);
let config = AnalysisConfig::default();
let known_exchange = HashSet::new();
let known_risky = HashSet::new();
let findings =
detect_address_reuse(&context(&graph, &config, &known_exchange, &known_risky)).findings;
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].kind, FindingKind::AddressReuse);
}
#[test]
fn dust_current_and_historical_are_detected() {
let strict = our_address(
"bcrt1qdust",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let spent = our_address(
"bcrt1qspent",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let graph = graph(
vec![strict.clone(), spent.clone()],
vec![
wallet_entry(
"dust-live",
&strict.address,
WalletTxCategory::Receive,
546,
3,
),
wallet_entry(
"dust-spent",
&spent.address,
WalletTxCategory::Receive,
1_000,
2,
),
],
vec![utxo(
"dust-live",
0,
&strict.address,
546,
3,
DescriptorType::P2wpkh,
)],
vec![
tx(
"dust-live",
Vec::new(),
vec![output(0, &strict.address, 546, DescriptorType::P2wpkh)],
3,
),
tx(
"dust-spent",
Vec::new(),
vec![output(0, &spent.address, 1_000, DescriptorType::P2wpkh)],
2,
),
],
);
let config = AnalysisConfig::default();
let known_exchange = HashSet::new();
let known_risky = HashSet::new();
let findings = detect_dust(&context(&graph, &config, &known_exchange, &known_risky)).findings;
assert_eq!(findings.len(), 2);
assert!(
findings
.iter()
.any(|finding| finding.kind == FindingKind::Dust)
);
}
#[test]
fn multi_input_heuristics_are_detected() {
let a = our_address(
"bcrt1qin1",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let b = our_address(
"bcrt1qin2",
DescriptorType::P2tr,
DescriptorChainRole::External,
);
let change = our_address(
"bcrt1qchange",
DescriptorType::P2wpkh,
DescriptorChainRole::Internal,
);
let transactions = vec![
tx(
"fund-a",
vec![input("bob-parent", 0)],
vec![output(0, &a.address, 50_000, DescriptorType::P2wpkh)],
10,
),
tx(
"fund-b",
vec![input("carol-parent", 0)],
vec![output(0, &b.address, 60_000, DescriptorType::P2tr)],
10,
),
tx(
"spend",
vec![input("fund-a", 0), input("fund-b", 0)],
vec![
output(0, "mipcPayment", 1_000_000, DescriptorType::P2pkh),
output(1, &change.address, 10_345, DescriptorType::P2wpkh),
],
2,
),
];
let graph = graph(
vec![a.clone(), b.clone(), change.clone()],
vec![
wallet_entry("fund-a", &a.address, WalletTxCategory::Receive, 50_000, 10),
wallet_entry("fund-b", &b.address, WalletTxCategory::Receive, 60_000, 10),
wallet_entry("spend", &change.address, WalletTxCategory::Send, 10_345, 2),
],
vec![utxo(
"spend",
1,
&change.address,
10_345,
2,
DescriptorType::P2wpkh,
)],
transactions,
);
let config = AnalysisConfig::default();
let known_exchange = HashSet::new();
let known_risky = HashSet::new();
let ctx = context(&graph, &config, &known_exchange, &known_risky);
assert_eq!(detect_cioh(&ctx).findings[0].kind, FindingKind::Cioh);
assert_eq!(
detect_change_detection(&ctx).findings[0].kind,
FindingKind::ChangeDetection
);
assert_eq!(
detect_script_type_mixing(&ctx).findings[0].kind,
FindingKind::ScriptTypeMixing
);
assert_eq!(
detect_cluster_merge(&ctx).findings[0].kind,
FindingKind::ClusterMerge
);
}
#[test]
fn consolidation_and_dust_spending_are_detected() {
let dust_addr = our_address(
"bcrt1qdustin",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let normal_addr = our_address(
"bcrt1qnormal",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let consolidated = our_address(
"bcrt1qconsolidated",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let transactions = vec![
tx(
"dust-fund",
vec![input("miner-a", 0)],
vec![output(0, &dust_addr.address, 1_000, DescriptorType::P2wpkh)],
20,
),
tx(
"normal-fund",
vec![input("miner-b", 0)],
vec![output(
0,
&normal_addr.address,
25_000,
DescriptorType::P2wpkh,
)],
20,
),
tx(
"consolidation-parent",
vec![input("src-1", 0), input("src-2", 0), input("src-3", 0)],
vec![output(
0,
&consolidated.address,
26_000,
DescriptorType::P2wpkh,
)],
5,
),
tx(
"spend-dust",
vec![input("dust-fund", 0), input("normal-fund", 0)],
vec![output(0, "mipcRecipient", 20_000, DescriptorType::P2pkh)],
2,
),
];
let graph = graph(
vec![dust_addr.clone(), normal_addr.clone(), consolidated.clone()],
vec![
wallet_entry(
"dust-fund",
&dust_addr.address,
WalletTxCategory::Receive,
1_000,
20,
),
wallet_entry(
"normal-fund",
&normal_addr.address,
WalletTxCategory::Receive,
25_000,
20,
),
wallet_entry(
"consolidation-parent",
&consolidated.address,
WalletTxCategory::Receive,
26_000,
5,
),
wallet_entry(
"spend-dust",
"mipcRecipient",
WalletTxCategory::Send,
20_000,
2,
),
],
vec![utxo(
"consolidation-parent",
0,
&consolidated.address,
26_000,
5,
DescriptorType::P2wpkh,
)],
transactions,
);
let config = AnalysisConfig::default();
let known_exchange = HashSet::new();
let known_risky = HashSet::new();
let ctx = context(&graph, &config, &known_exchange, &known_risky);
assert_eq!(
detect_dust_spending(&ctx).findings[0].kind,
FindingKind::DustSpending
);
assert_eq!(
detect_consolidation(&ctx).findings[0].kind,
FindingKind::Consolidation
);
}
#[test]
fn age_spread_emits_finding_and_warning() {
let old = our_address(
"bcrt1qold",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let fresh = our_address(
"bcrt1qfresh",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let graph = graph(
vec![old.clone(), fresh.clone()],
Vec::new(),
vec![
utxo(
"old-utxo",
0,
&old.address,
300_000,
120,
DescriptorType::P2wpkh,
),
utxo(
"fresh-utxo",
0,
&fresh.address,
310_000,
5,
DescriptorType::P2wpkh,
),
],
vec![
tx(
"old-utxo",
Vec::new(),
vec![output(0, &old.address, 300_000, DescriptorType::P2wpkh)],
120,
),
tx(
"fresh-utxo",
Vec::new(),
vec![output(0, &fresh.address, 310_000, DescriptorType::P2wpkh)],
5,
),
],
);
let config = AnalysisConfig::default();
let known_exchange = HashSet::new();
let known_risky = HashSet::new();
let result = detect_utxo_age_spread(&context(&graph, &config, &known_exchange, &known_risky));
assert_eq!(result.findings[0].kind, FindingKind::UtxoAgeSpread);
assert_eq!(result.warnings[0].kind, WarningKind::DormantUtxos);
}
#[test]
fn exchange_origin_and_tainted_merge_are_detected() {
let receive = our_address(
"bcrt1qexchange",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let clean = our_address(
"bcrt1qclean",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let tainted = our_address(
"bcrt1qtainted",
DescriptorType::P2wpkh,
DescriptorChainRole::External,
);
let transactions = vec![
tx(
"exchange-batch",
vec![input("exchange-hot", 0)],
vec![
output(0, &receive.address, 200_000, DescriptorType::P2wpkh),
output(1, "bcrt1qsomeone1", 190_000, DescriptorType::P2wpkh),
output(2, "bcrt1qsomeone2", 180_000, DescriptorType::P2wpkh),
output(3, "bcrt1qsomeone3", 170_000, DescriptorType::P2wpkh),
output(4, "bcrt1qsomeone4", 160_000, DescriptorType::P2wpkh),
],
4,
),
tx(
"risky-source",
vec![input("risky-parent", 0)],
vec![output(0, &tainted.address, 80_000, DescriptorType::P2wpkh)],
8,
),
tx(
"clean-source",
vec![input("clean-parent", 0)],
vec![output(0, &clean.address, 90_000, DescriptorType::P2wpkh)],
8,
),
tx(
"merge-taint",
vec![input("risky-source", 0), input("clean-source", 0)],
vec![output(0, "mipcOut", 150_000, DescriptorType::P2pkh)],
1,
),
];
let graph = graph(
vec![receive.clone(), clean.clone(), tainted.clone()],
vec![
wallet_entry(
"exchange-batch",
&receive.address,
WalletTxCategory::Receive,
200_000,
4,
),
wallet_entry(
"risky-source",
&tainted.address,
WalletTxCategory::Receive,
80_000,
8,
),
wallet_entry(
"clean-source",
&clean.address,
WalletTxCategory::Receive,
90_000,
8,
),
wallet_entry("merge-taint", "mipcOut", WalletTxCategory::Send, 150_000, 1),
],
Vec::new(),
transactions,
);
let known_exchange_txids = HashSet::from([String::from("exchange-batch")]);
let known_risky_txids = HashSet::from([String::from("risky-source")]);
let config = AnalysisConfig::default();
let ctx = context(&graph, &config, &known_exchange_txids, &known_risky_txids);
assert_eq!(
detect_exchange_origin(&ctx).findings[0].kind,
FindingKind::ExchangeOrigin
);
let taint_result = detect_tainted_utxo_merge(&ctx);
assert_eq!(taint_result.findings[0].kind, FindingKind::TaintedUtxoMerge);
assert_eq!(taint_result.warnings[0].kind, WarningKind::DirectTaint);
}
#[test]
fn behavioral_fingerprint_requires_consistent_patterns() {
let in1 = our_address(
"bcrt1qbeh1",
DescriptorType::P2pkh,
DescriptorChainRole::External,
);
let in2 = our_address(
"bcrt1qbeh2",
DescriptorType::P2pkh,
DescriptorChainRole::External,
);
let change = our_address(
"bcrt1qbehchange",
DescriptorType::P2wpkh,
DescriptorChainRole::Internal,
);
let transactions = vec![
tx(
"fund-1",
vec![input("source-1", 0)],
vec![output(0, &in1.address, 400_000, DescriptorType::P2pkh)],
20,
),
tx(
"fund-2",
vec![input("source-2", 0)],
vec![output(0, &in2.address, 400_000, DescriptorType::P2pkh)],
20,
),
tx(
"send-1",
vec![input("fund-1", 0), input("fund-2", 0)],
vec![
output(0, "mipcDest1", 100_000, DescriptorType::P2pkh),
output(1, &change.address, 20_000, DescriptorType::P2wpkh),
],
3,
),
tx(
"send-2",
vec![input("fund-1", 0), input("fund-2", 0)],
vec![
output(0, "mipcDest2", 200_000, DescriptorType::P2pkh),
output(1, &change.address, 30_000, DescriptorType::P2wpkh),
],
2,
),
tx(
"send-3",
vec![input("fund-1", 0), input("fund-2", 0)],
vec![
output(0, "mipcDest3", 300_000, DescriptorType::P2pkh),
output(1, &change.address, 40_000, DescriptorType::P2wpkh),
],
1,
),
];
let graph = graph(
vec![in1.clone(), in2.clone(), change.clone()],
vec![
wallet_entry(
"fund-1",
&in1.address,
WalletTxCategory::Receive,
400_000,
20,
),
wallet_entry(
"fund-2",
&in2.address,
WalletTxCategory::Receive,
400_000,
20,
),
wallet_entry("send-1", &change.address, WalletTxCategory::Send, 20_000, 3),
wallet_entry("send-2", &change.address, WalletTxCategory::Send, 30_000, 2),
wallet_entry("send-3", &change.address, WalletTxCategory::Send, 40_000, 1),
],
Vec::new(),
transactions,
);
let config = AnalysisConfig::default();
let known_exchange = HashSet::new();
let known_risky = HashSet::new();
let findings =
detect_behavioral_fingerprint(&context(&graph, &config, &known_exchange, &known_risky))
.findings;
assert_eq!(findings[0].kind, FindingKind::BehavioralFingerprint);
}
-253
View File
@@ -1,253 +0,0 @@
use std::collections::{HashMap, HashSet};
use stealth_core::config::AnalysisConfig;
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
use stealth_core::error::AnalysisError;
use stealth_core::gateway::BlockchainGateway;
use stealth_core::model::{
DecodedTransaction, DescriptorType, ResolvedDescriptor, TxOutput, WalletHistory,
WalletTxCategory, WalletTxEntry,
};
#[derive(Default)]
struct MockGateway {
normalized: HashMap<String, String>,
derived: HashMap<String, Vec<String>>,
descriptor_history: Option<WalletHistory>,
wallet_descriptors: HashMap<String, Vec<ResolvedDescriptor>>,
wallet_history: HashMap<String, WalletHistory>,
known_wallet_txids: HashMap<String, HashSet<String>>,
}
impl BlockchainGateway for MockGateway {
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError> {
self.normalized.get(descriptor).cloned().ok_or_else(|| {
AnalysisError::DescriptorNormalization {
descriptor: descriptor.to_string(),
message: "missing normalization fixture".into(),
}
})
}
fn derive_addresses(
&self,
descriptor: &ResolvedDescriptor,
) -> Result<Vec<String>, AnalysisError> {
self.derived.get(&descriptor.desc).cloned().ok_or_else(|| {
AnalysisError::EnvironmentUnavailable("missing derivation fixture".into())
})
}
fn scan_descriptors(
&self,
_descriptors: &[ResolvedDescriptor],
) -> Result<WalletHistory, AnalysisError> {
self.descriptor_history
.clone()
.ok_or(AnalysisError::AnalysisEmpty)
}
fn list_wallet_descriptors(
&self,
wallet_name: &str,
) -> Result<Vec<ResolvedDescriptor>, AnalysisError> {
self.wallet_descriptors
.get(wallet_name)
.cloned()
.ok_or_else(|| AnalysisError::EnvironmentUnavailable("wallet not found".into()))
}
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError> {
self.wallet_history
.get(wallet_name)
.cloned()
.ok_or(AnalysisError::AnalysisEmpty)
}
fn known_wallet_txids(
&self,
wallet_names: &[String],
) -> Result<HashSet<String>, AnalysisError> {
Ok(wallet_names
.iter()
.filter_map(|wallet_name| self.known_wallet_txids.get(wallet_name))
.flat_map(|txids| txids.iter().cloned())
.collect())
}
}
fn satoshis(value: u64) -> f64 {
value as f64 / 100_000_000.0
}
fn descriptor(desc: &str, internal: bool) -> ResolvedDescriptor {
ResolvedDescriptor {
desc: desc.to_string(),
internal,
active: true,
range_end: 50,
}
}
fn history_for_address_reuse(address: &str) -> WalletHistory {
WalletHistory {
wallet_txs: vec![
WalletTxEntry {
txid: "tx-1".into(),
address: address.into(),
category: WalletTxCategory::Receive,
amount_btc: satoshis(100_000),
confirmations: 6,
blockheight: 0,
},
WalletTxEntry {
txid: "tx-2".into(),
address: address.into(),
category: WalletTxCategory::Receive,
amount_btc: satoshis(200_000),
confirmations: 5,
blockheight: 0,
},
],
utxos: Vec::new(),
transactions: HashMap::from([
(
"tx-1".into(),
DecodedTransaction {
txid: "tx-1".into(),
vin: Vec::new(),
vout: vec![TxOutput {
n: 0,
address: address.into(),
value_btc: satoshis(100_000),
script_type: DescriptorType::P2wpkh,
}],
version: 2,
locktime: 0,
vsize: 100,
confirmations: 6,
},
),
(
"tx-2".into(),
DecodedTransaction {
txid: "tx-2".into(),
vin: Vec::new(),
vout: vec![TxOutput {
n: 0,
address: address.into(),
value_btc: satoshis(200_000),
script_type: DescriptorType::P2wpkh,
}],
version: 2,
locktime: 0,
vsize: 100,
confirmations: 5,
},
),
]),
}
}
#[test]
fn descriptor_scan_normalizes_derives_and_reports_findings() {
let normalized_external = "normalized:wpkh(xpub/0/*)";
let normalized_internal = "normalized:wpkh(xpub/1/*)";
let address = "bcrt1qengine";
let gateway = MockGateway {
normalized: HashMap::from([
("wpkh(xpub/0/*)".into(), normalized_external.into()),
("wpkh(xpub/1/*)".into(), normalized_internal.into()),
]),
derived: HashMap::from([
(normalized_external.into(), vec![address.into()]),
(normalized_internal.into(), vec!["bcrt1qchange".into()]),
]),
descriptor_history: Some(history_for_address_reuse(address)),
..MockGateway::default()
};
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
let report = engine
.analyze(ScanTarget::Descriptors(vec!["wpkh(xpub/0/*)#abcd".into()]))
.expect("analysis should succeed");
assert_eq!(report.summary.findings, 1);
assert_eq!(
report.findings[0].kind,
stealth_core::model::FindingKind::AddressReuse
);
assert_eq!(report.stats.addresses_derived, 2);
}
#[test]
fn wallet_scan_uses_existing_wallet_descriptors() {
let address = "bcrt1qwallet";
let wallet_name = "alice";
let gateway = MockGateway {
derived: HashMap::from([
("normalized:wpkh(wallet/0/*)".into(), vec![address.into()]),
(
"normalized:wpkh(wallet/1/*)".into(),
vec!["bcrt1qwalletchange".into()],
),
]),
wallet_descriptors: HashMap::from([(
wallet_name.into(),
vec![
descriptor("normalized:wpkh(wallet/0/*)", false),
descriptor("normalized:wpkh(wallet/1/*)", true),
],
)]),
wallet_history: HashMap::from([(wallet_name.into(), history_for_address_reuse(address))]),
..MockGateway::default()
};
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
let report = engine
.analyze(ScanTarget::WalletName(wallet_name.into()))
.expect("wallet analysis should succeed");
assert_eq!(report.summary.findings, 1);
assert_eq!(report.stats.transactions_analyzed, 2);
}
#[test]
fn empty_history_returns_typed_error() {
let gateway = MockGateway {
normalized: HashMap::from([
("wpkh(xpub/0/*)".into(), "normalized:wpkh(xpub/0/*)".into()),
("wpkh(xpub/1/*)".into(), "normalized:wpkh(xpub/1/*)".into()),
]),
derived: HashMap::from([
(
"normalized:wpkh(xpub/0/*)".into(),
vec!["bcrt1qnone".into()],
),
(
"normalized:wpkh(xpub/1/*)".into(),
vec!["bcrt1qnonechange".into()],
),
]),
descriptor_history: Some(WalletHistory {
wallet_txs: Vec::new(),
utxos: Vec::new(),
transactions: HashMap::new(),
}),
..MockGateway::default()
};
let engine = AnalysisEngine::new(
&gateway,
EngineSettings {
analysis: AnalysisConfig::default(),
known_exchange_wallets: Vec::new(),
known_risky_wallets: Vec::new(),
},
);
let error = engine
.analyze(ScanTarget::Descriptors(vec!["wpkh(xpub/0/*)".into()]))
.expect_err("analysis should fail");
assert_eq!(error, AnalysisError::AnalysisEmpty);
}
+26
View 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
View 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
View File
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
//! Canonical analysis pipeline.
//!
//! [`AnalysisEngine`] is the primary entry point for running a privacy
//! scan. It accepts a [`BlockchainGateway`] for data access and routes
//! every scan request through the shared gateway abstraction, ensuring a
//! single execution path for HTTP, CLI, and library consumers.
use std::collections::{HashMap, HashSet};
use bitcoin::{Amount, Txid};
use crate::descriptor::normalize_descriptors;
use crate::error::AnalysisError;
use crate::gateway::{
BlockchainGateway, DecodedTransaction, DescriptorType, Utxo, WalletHistory, WalletTxCategory,
WalletTxEntry,
};
use crate::graph::TxGraph;
use crate::types::Report;
pub use stealth_model::scan::{EngineSettings, ScanTarget, UtxoInput};
// ── Engine ──────────────────────────────────────────────────────────────────
/// Runs a privacy analysis through a [`BlockchainGateway`].
///
/// Construct one per request (or per CLI invocation) and call
/// [`analyze`](Self::analyze).
pub struct AnalysisEngine<'a, G: BlockchainGateway> {
gateway: &'a G,
settings: EngineSettings,
}
impl<G: BlockchainGateway> std::fmt::Debug for AnalysisEngine<'_, G> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnalysisEngine")
.field("settings", &self.settings)
.finish_non_exhaustive()
}
}
impl<'a, G: BlockchainGateway> AnalysisEngine<'a, G> {
pub fn new(gateway: &'a G, settings: EngineSettings) -> Self {
Self { gateway, settings }
}
/// Run a full privacy scan for the given target.
pub fn analyze(&self, target: ScanTarget) -> Result<Report, AnalysisError> {
match target {
ScanTarget::Descriptor(d) => self.analyze_descriptors(vec![d]),
ScanTarget::Descriptors(ds) => self.analyze_descriptors(ds),
ScanTarget::Utxos(utxos) => self.analyze_utxos(utxos),
}
}
// ── descriptor path ─────────────────────────────────────────────────
fn analyze_descriptors(&self, raw_descriptors: Vec<String>) -> Result<Report, AnalysisError> {
let resolved = normalize_descriptors(
&raw_descriptors,
self.settings.config.derivation_range_end,
self.gateway,
)?;
let history = self.gateway.scan_descriptors(&resolved)?;
let graph = TxGraph::from_wallet_history(history);
Ok(graph.detect_all(
&self.settings.config.thresholds,
self.settings.known_risky_txids.as_ref(),
self.settings.known_exchange_txids.as_ref(),
))
}
// ── UTXO path ───────────────────────────────────────────────────────
fn analyze_utxos(&self, utxos: Vec<UtxoInput>) -> Result<Report, AnalysisError> {
let history = self.resolve_utxo_history(&utxos)?;
let graph = TxGraph::from_wallet_history(history);
Ok(graph.detect_all(
&self.settings.config.thresholds,
self.settings.known_risky_txids.as_ref(),
self.settings.known_exchange_txids.as_ref(),
))
}
/// Build a [`WalletHistory`] from raw UTXO inputs by fetching the
/// referenced transactions (and their parents) through the gateway.
fn resolve_utxo_history(&self, utxos: &[UtxoInput]) -> Result<WalletHistory, AnalysisError> {
let mut wallet_txs = Vec::new();
let mut utxo_entries = Vec::new();
let mut transactions: HashMap<Txid, DecodedTransaction> = HashMap::new();
let mut fetch_queue: Vec<Txid> = Vec::new();
for utxo in utxos {
// Fetch the UTXO's parent transaction.
if let std::collections::hash_map::Entry::Vacant(e) = transactions.entry(utxo.txid) {
let tx = self.gateway.get_transaction(utxo.txid)?;
fetch_queue.extend(
tx.vin
.iter()
.filter(|i| !i.coinbase)
.map(|i| i.previous_txid),
);
e.insert(tx);
}
let tx = &transactions[&utxo.txid];
let address = utxo.address.clone().or_else(|| {
tx.vout
.iter()
.find(|o| o.n == utxo.vout)
.and_then(|o| o.address.clone())
});
let value = utxo.value.unwrap_or_else(|| {
tx.vout
.iter()
.find(|o| o.n == utxo.vout)
.map(|o| o.value)
.unwrap_or(Amount::ZERO)
});
if address.is_some() {
wallet_txs.push(WalletTxEntry {
txid: utxo.txid,
address: address.clone(),
category: WalletTxCategory::Receive,
amount: value,
confirmations: 0,
blockheight: 0,
});
}
utxo_entries.push(Utxo {
txid: utxo.txid,
vout: utxo.vout,
address,
amount: value,
confirmations: 0,
script_type: DescriptorType::Unknown,
});
}
// Fetch ancestor transactions for input resolution, bounded by
// max_ancestor_depth to prevent unbounded graph traversal.
// A depth of 0 means we only keep the UTXO's own transaction.
let max_depth = self.settings.config.max_ancestor_depth;
if max_depth > 0 {
let mut depth_queue: Vec<(Txid, u32)> =
fetch_queue.into_iter().map(|txid| (txid, 1)).collect();
while let Some((txid, depth)) = depth_queue.pop() {
if transactions.contains_key(&txid) {
continue;
}
if let Ok(tx) = self.gateway.get_transaction(txid) {
if depth < max_depth {
depth_queue.extend(
tx.vin
.iter()
.filter(|i| !i.coinbase)
.map(|i| (i.previous_txid, depth + 1)),
);
}
transactions.insert(txid, tx);
}
}
}
Ok(WalletHistory {
wallet_txs,
utxos: utxo_entries,
transactions,
internal_addresses: HashSet::new(),
derived_addresses: HashSet::new(),
})
}
}
+236
View File
@@ -0,0 +1,236 @@
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();
// Add ALL derived addresses to `our_addrs` and `addr_map`
for addr in &history.derived_addresses {
our_addrs.insert(addr.clone());
addr_map.entry(addr.clone()).or_insert_with(|| AddressInfo {
script_type: script_type_from_address(addr),
internal: history.internal_addresses.contains(addr),
index: 0,
});
}
// Pre-populate caches from decoded transactions.
let mut tx_cache = HashMap::new();
let mut input_cache: HashMap<Txid, Vec<InputInfo>> = HashMap::new();
let mut output_cache: HashMap<Txid, Vec<OutputInfo>> = HashMap::new();
for (txid, tx) in &history.transactions {
tx_cache.insert(*txid, tx.clone());
let inputs: Vec<InputInfo> = tx
.vin
.iter()
.filter_map(|input| {
if input.coinbase {
return None;
}
let parent = history.transactions.get(&input.previous_txid)?;
let out = parent.vout.iter().find(|o| o.n == input.previous_vout)?;
let address = out.address.clone()?;
Some(InputInfo {
address,
value: out.value,
funding_txid: input.previous_txid,
funding_vout: input.previous_vout,
})
})
.collect();
input_cache.insert(*txid, inputs);
let outputs: Vec<OutputInfo> = tx
.vout
.iter()
.filter_map(|out| {
let address = out.address.clone()?;
Some(OutputInfo {
address: address.clone(),
value: out.value,
index: out.n,
script_type: script_type_from_address(&address),
})
})
.collect();
output_cache.insert(*txid, outputs);
}
TxGraph {
addr_map,
our_addrs,
utxos,
our_txids,
addr_txs,
tx_addrs,
tx_cache,
input_cache,
output_cache,
}
}
}
/// Determine script type by decoding the address and inspecting the
/// resulting script.
///
/// * `bc1q` / `tb1q` / `bcrt1q` with a 20-byte program → **p2wpkh**
/// * `bc1q` / `tb1q` / `bcrt1q` with a 32-byte program → **p2wsh**
/// * `bc1p` / `tb1p` / `bcrt1p` → **p2tr**
/// * Base58 `1`/`m`/`n` (version 0x00/0x6f) → **p2pkh**
/// * Base58 `3`/`2` (version 0x05/0xc4) → **p2sh** (we *cannot* know if it
/// wraps p2wpkh, p2wsh, or bare multisig without the redeem script)
pub fn script_type_from_address(address: &Address<NetworkUnchecked>) -> String {
let addr = address.clone().assume_checked();
let script = addr.script_pubkey();
if script.is_p2pkh() {
"p2pkh".into()
} else if script.is_p2sh() {
"p2sh".into()
} else if script.is_p2wpkh() {
"p2wpkh".into()
} else if script.is_p2wsh() {
"p2wsh".into()
} else if script.is_p2tr() {
"p2tr".into()
} else {
"unknown".into()
}
}
+46
View File
@@ -0,0 +1,46 @@
//! # stealth-engine
//!
//! Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's
//! transaction history through a [`BlockchainGateway`](gateway::BlockchainGateway).
//!
//! The canonical execution path is:
//!
//! ```text
//! AnalysisEngine + BlockchainGateway → Report
//! ```
//!
//! Construct an [`AnalysisEngine`] with a concrete gateway implementation,
//! then call [`AnalysisEngine::analyze`] with a [`ScanTarget`].
//!
//! Results are returned as a structured [`Report`] that can be serialised
//! to JSON.
//!
//! ## Detected vulnerabilities
//!
//! | # | Vulnerability | Default severity |
//! |---|---------------|------------------|
//! | 1 | Address reuse | HIGH |
//! | 2 | Common-input-ownership heuristic (CIOH) | HIGH CRITICAL |
//! | 3 | Dust UTXO reception | MEDIUM HIGH |
//! | 4 | Dust spent alongside normal inputs | HIGH |
//! | 5 | Identifiable change outputs | MEDIUM |
//! | 6 | UTXOs born from consolidation transactions | MEDIUM |
//! | 7 | Mixed script types in inputs | HIGH |
//! | 8 | Cross-origin cluster merge | HIGH |
//! | 9 | UTXO age / lookback-depth spread | LOW |
//! | 10 | Exchange-origin batch withdrawal | MEDIUM |
//! | 11 | Tainted UTXO merge | HIGH |
//! | 12 | Behavioural fingerprinting | MEDIUM |
pub use stealth_model::config;
pub use stealth_model::descriptor;
mod detect;
pub mod engine;
pub use stealth_model::error;
pub use stealth_model::gateway;
mod graph;
pub use stealth_model::types;
pub use engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput};
pub use graph::TxGraph;
pub use stealth_model::types::*;
+715
View 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);
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "stealth-model"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Domain model types for Stealth wallet privacy analysis"
categories = ["cryptography::cryptocurrencies"]
keywords = ["bitcoin", "privacy", "utxo"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
bitcoin = { workspace = true }
@@ -1,5 +1,8 @@
use std::collections::HashSet;
use bitcoin::Amount;
/// Identifies a specific detector for enable/disable configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectorId {
AddressReuse,
@@ -16,38 +19,49 @@ pub enum DetectorId {
BehavioralFingerprint,
}
/// Numeric thresholds used by the detectors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectorThresholds {
pub dust_sats: u64,
pub strict_dust_sats: u64,
pub normal_input_min_sats: u64,
pub dust: Amount,
pub strict_dust: Amount,
pub normal_input_min: Amount,
pub consolidation_min_inputs: usize,
pub consolidation_max_outputs: usize,
pub utxo_age_spread_blocks: u32,
pub dormant_utxo_blocks: u32,
pub exchange_batch_outputs: usize,
pub exchange_batch_min_outputs: usize,
pub dust_attack_min_outputs: usize,
pub dust_attack_min_dust_outputs: usize,
pub toxic_change_upper: Amount,
}
impl Default for DetectorThresholds {
fn default() -> Self {
Self {
dust_sats: 1_000,
strict_dust_sats: 546,
normal_input_min_sats: 10_000,
dust: Amount::from_sat(1_000),
strict_dust: Amount::from_sat(546),
normal_input_min: Amount::from_sat(10_000),
consolidation_min_inputs: 3,
consolidation_max_outputs: 2,
utxo_age_spread_blocks: 10,
dormant_utxo_blocks: 100,
exchange_batch_outputs: 5,
exchange_batch_min_outputs: 5,
dust_attack_min_outputs: 10,
dust_attack_min_dust_outputs: 5,
toxic_change_upper: Amount::from_sat(10_000),
}
}
}
/// Top-level analysis configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalysisConfig {
pub derivation_range_end: u32,
pub thresholds: DetectorThresholds,
pub enabled_detectors: HashSet<DetectorId>,
/// Maximum ancestor-fetch depth when resolving UTXO history.
/// `0` means only UTXO's own tx; `2` (the default)
pub max_ancestor_depth: u32,
}
impl Default for AnalysisConfig {
@@ -69,6 +83,7 @@ impl Default for AnalysisConfig {
DetectorId::TaintedUtxoMerge,
DetectorId::BehavioralFingerprint,
]),
max_ancestor_depth: 2,
}
}
}
@@ -1,10 +1,17 @@
use crate::error::AnalysisError;
use crate::model::ResolvedDescriptor;
use crate::gateway::ResolvedDescriptor;
/// Trait for normalizing a raw descriptor string (e.g. via `getdescriptorinfo`).
pub trait DescriptorNormalizer {
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError>;
}
/// Normalize raw descriptor strings: strip checksums, infer receive/change
/// pairs (`/0/*` ↔ `/1/*`), deduplicate.
///
/// When a `normalizer` is provided (typically a [`BlockchainGateway`]),
/// each candidate is passed through `getdescriptorinfo` for canonical
/// checksumming.
pub fn normalize_descriptors(
raw_descriptors: &[String],
derivation_range_end: u32,
@@ -64,3 +71,46 @@ pub fn normalize_descriptors(
Ok(resolved)
}
/// Lightweight descriptor normalization that strips checksums and infers
/// receive/change pairs without calling an RPC normalizer.
///
/// Returns `(descriptor_string, is_internal)` pairs.
pub fn normalize_descriptors_raw(raw_descriptors: &[String]) -> Vec<(String, bool)> {
let mut result = Vec::new();
for raw in raw_descriptors {
let without_checksum = raw
.split('#')
.next()
.map(str::trim)
.unwrap_or_default()
.to_string();
if without_checksum.is_empty() {
continue;
}
let candidates = if without_checksum.contains("/0/*") {
vec![
(without_checksum.clone(), false),
(without_checksum.replace("/0/*", "/1/*"), true),
]
} else if without_checksum.contains("/1/*") {
vec![
(without_checksum.replace("/1/*", "/0/*"), false),
(without_checksum.clone(), true),
]
} else {
vec![(without_checksum, false)]
};
for pair in candidates {
if !result.contains(&pair) {
result.push(pair);
}
}
}
result
}
@@ -1,5 +1,6 @@
use thiserror::Error;
/// Errors from the analysis pipeline.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum AnalysisError {
#[error("descriptor input cannot be empty")]
@@ -8,6 +9,6 @@ pub enum AnalysisError {
DescriptorNormalization { descriptor: String, message: String },
#[error("environment unavailable: {0}")]
EnvironmentUnavailable(String),
#[error("analysis found no history for the supplied descriptors")]
AnalysisEmpty,
#[error("analysis execution failed: {0}")]
Execution(String),
}
+222
View File
@@ -0,0 +1,222 @@
use std::collections::{HashMap, HashSet};
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use serde::{Deserialize, Serialize};
use crate::descriptor::DescriptorNormalizer;
use crate::error::AnalysisError;
use crate::types::{serde_addr, serde_addr_opt, serde_addr_set};
/// Abstraction over a blockchain data source (e.g. Bitcoin Core RPC).
///
/// Implementations provide descriptor normalization, address derivation,
/// wallet scanning, and transaction history retrieval. This trait decouples
/// domain logic from the concrete RPC transport, making it possible to
/// test with mocks.
pub trait BlockchainGateway {
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError>;
fn derive_addresses(
&self,
descriptor: &ResolvedDescriptor,
) -> Result<Vec<Address<NetworkUnchecked>>, AnalysisError>;
fn scan_descriptors(
&self,
descriptors: &[ResolvedDescriptor],
) -> Result<WalletHistory, AnalysisError>;
fn list_wallet_descriptors(
&self,
wallet_name: &str,
) -> Result<Vec<ResolvedDescriptor>, AnalysisError>;
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError>;
fn known_wallet_txids(&self, wallet_names: &[String]) -> Result<HashSet<Txid>, AnalysisError>;
fn get_transaction(&self, txid: Txid) -> Result<DecodedTransaction, AnalysisError>;
}
/// Blanket implementation: any `BlockchainGateway` is also a
/// `DescriptorNormalizer`.
impl<T> DescriptorNormalizer for T
where
T: BlockchainGateway + ?Sized,
{
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError> {
self.normalize_descriptor(descriptor)
}
}
// ── Gateway model types ─────────────────────────────────────────────────────
/// A descriptor that has been normalized and resolved for import.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedDescriptor {
pub desc: String,
pub internal: bool,
pub active: bool,
pub range_end: u32,
}
/// Role of a descriptor chain (external receive vs internal change).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorChainRole {
External,
Internal,
}
/// Script/address type derived from a descriptor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DescriptorType {
P2wpkh,
P2tr,
P2shP2wpkh,
P2sh,
P2pkh,
Unknown,
}
impl DescriptorType {
pub fn from_descriptor(descriptor: &str) -> Self {
if descriptor.starts_with("wpkh(") {
Self::P2wpkh
} else if descriptor.starts_with("tr(") {
Self::P2tr
} else if descriptor.starts_with("sh(wpkh(") {
Self::P2shP2wpkh
} else if descriptor.starts_with("pkh(") {
Self::P2pkh
} else {
Self::Unknown
}
}
pub fn infer_from_address(address: &Address<NetworkUnchecked>) -> Self {
let script = address.clone().assume_checked().script_pubkey();
if script.is_p2wpkh() {
Self::P2wpkh
} else if script.is_p2tr() {
Self::P2tr
} else if script.is_p2sh() {
Self::P2sh
} else if script.is_p2pkh() {
Self::P2pkh
} else {
Self::Unknown
}
}
pub fn as_script_name(self) -> &'static str {
match self {
Self::P2wpkh => "witness_v0_keyhash",
Self::P2tr => "witness_v1_taproot",
Self::P2shP2wpkh | Self::P2sh => "scripthash",
Self::P2pkh => "pubkeyhash",
Self::Unknown => "unknown",
}
}
}
/// A derived address with metadata about its origin descriptor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DerivedAddress {
#[serde(with = "serde_addr")]
pub address: Address<NetworkUnchecked>,
pub descriptor_type: DescriptorType,
pub chain_role: DescriptorChainRole,
pub derivation_index: u32,
}
/// Wallet transaction category.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WalletTxCategory {
Send,
Receive,
Unknown,
}
/// A wallet transaction entry (from `listtransactions`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalletTxEntry {
pub txid: Txid,
#[serde(with = "serde_addr_opt")]
pub address: Option<Address<NetworkUnchecked>>,
pub category: WalletTxCategory,
pub amount: Amount,
pub confirmations: u32,
pub blockheight: u32,
}
/// An input reference within a decoded transaction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TxInputRef {
#[serde(rename = "txid")]
pub previous_txid: Txid,
#[serde(rename = "vout")]
pub previous_vout: u32,
pub sequence: u32,
pub coinbase: bool,
}
/// A transaction output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TxOutput {
pub n: u32,
#[serde(with = "serde_addr_opt")]
pub address: Option<Address<NetworkUnchecked>>,
pub value: Amount,
pub script_type: DescriptorType,
}
/// A fully decoded transaction with inputs and outputs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DecodedTransaction {
pub txid: Txid,
pub vin: Vec<TxInputRef>,
pub vout: Vec<TxOutput>,
pub version: i32,
pub locktime: u32,
pub vsize: u32,
pub confirmations: u32,
}
/// A current unspent transaction output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Utxo {
pub txid: Txid,
pub vout: u32,
#[serde(with = "serde_addr_opt")]
pub address: Option<Address<NetworkUnchecked>>,
pub amount: Amount,
pub confirmations: u32,
pub script_type: DescriptorType,
}
/// Complete wallet history with transactions and UTXOs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalletHistory {
pub wallet_txs: Vec<WalletTxEntry>,
pub utxos: Vec<Utxo>,
pub transactions: HashMap<Txid, DecodedTransaction>,
/// Addresses known to belong to internal (change) descriptor chains.
/// Populated by the descriptor scan path; may be empty for wallet scans.
#[serde(default, with = "serde_addr_set")]
pub internal_addresses: HashSet<Address<NetworkUnchecked>>,
/// Every address derived from ALL wallet descriptors (external + internal).
/// Used by `TxGraph` to seed `our_addrs`
#[serde(default, with = "serde_addr_set")]
pub derived_addresses: HashSet<Address<NetworkUnchecked>>,
}
/// A participant (input or output) in a transaction, enriched with
/// ownership information.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TransactionParticipant {
#[serde(with = "serde_addr")]
pub address: Address<NetworkUnchecked>,
pub value: Amount,
pub script_type: DescriptorType,
pub is_ours: bool,
pub funding_txid: Option<Txid>,
pub funding_vout: Option<u32>,
}
+8
View File
@@ -0,0 +1,8 @@
pub mod config;
pub mod descriptor;
pub mod error;
pub mod gateway;
pub mod scan;
pub mod types;
pub use types::*;
+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashSet;
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use serde::{Deserialize, Serialize};
use crate::config::AnalysisConfig;
/// What to scan.
#[derive(Debug, Clone)]
pub enum ScanTarget {
Descriptor(String),
Descriptors(Vec<String>),
Utxos(Vec<UtxoInput>),
}
/// A raw UTXO to analyse.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UtxoInput {
pub txid: Txid,
pub vout: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Amount>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::serde_addr_opt"
)]
pub address: Option<Address<NetworkUnchecked>>,
}
/// Top-level settings for the analysis engine, combining detector config
/// with optional known-wallet hooks used by taint and exchange detectors.
#[derive(Debug, Clone, Default)]
pub struct EngineSettings {
pub config: AnalysisConfig,
pub known_risky_txids: Option<HashSet<Txid>>,
pub known_exchange_txids: Option<HashSet<Txid>>,
}
+248
View File
@@ -0,0 +1,248 @@
use bitcoin::address::NetworkUnchecked;
use bitcoin::{Address, Amount, Txid};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::gateway::WalletTxCategory;
/// Serde helper: serialize an [`Address<NetworkUnchecked>`] via its checked
/// display representation. Deserialization delegates to the standard
/// `Address<NetworkUnchecked>` deserializer.
pub mod serde_addr {
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(addr: &Address<NetworkUnchecked>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.collect_str(addr.assume_checked_ref())
}
pub fn deserialize<'de, D>(d: D) -> Result<Address<NetworkUnchecked>, D::Error>
where
D: Deserializer<'de>,
{
Address::<NetworkUnchecked>::deserialize(d)
}
}
/// Serde helper for `Option<Address<NetworkUnchecked>>`.
pub mod serde_addr_opt {
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(addr: &Option<Address<NetworkUnchecked>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match addr {
Some(a) => s.collect_str(a.assume_checked_ref()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D>(d: D) -> Result<Option<Address<NetworkUnchecked>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Address<NetworkUnchecked>>::deserialize(d)
}
}
/// Serde helper for `HashSet<Address<NetworkUnchecked>>`.
pub mod serde_addr_set {
use bitcoin::address::NetworkUnchecked;
use bitcoin::Address;
use serde::{self, Deserialize, Deserializer, Serializer};
use std::collections::HashSet;
pub fn serialize<S>(addrs: &HashSet<Address<NetworkUnchecked>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(addrs.len()))?;
for addr in addrs {
seq.serialize_element(&addr.assume_checked_ref().to_string())?;
}
seq.end()
}
pub fn deserialize<'de, D>(d: D) -> Result<HashSet<Address<NetworkUnchecked>>, D::Error>
where
D: Deserializer<'de>,
{
let strings: Vec<String> = Vec::deserialize(d)?;
strings
.into_iter()
.map(|s| {
s.parse::<Address<NetworkUnchecked>>()
.map_err(serde::de::Error::custom)
})
.collect()
}
}
/// Severity levels for privacy vulnerability findings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
impl core::fmt::Display for Severity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Severity::Low => write!(f, "LOW"),
Severity::Medium => write!(f, "MEDIUM"),
Severity::High => write!(f, "HIGH"),
Severity::Critical => write!(f, "CRITICAL"),
}
}
}
/// The category of privacy vulnerability detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum VulnerabilityType {
AddressReuse,
Cioh,
Dust,
DustSpending,
ChangeDetection,
Consolidation,
ScriptTypeMixing,
ClusterMerge,
UtxoAgeSpread,
DormantUtxos,
ExchangeOrigin,
TaintedUtxoMerge,
DirectTaint,
BehavioralFingerprint,
}
impl core::fmt::Display for VulnerabilityType {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::AddressReuse => write!(f, "ADDRESS_REUSE"),
Self::Cioh => write!(f, "CIOH"),
Self::Dust => write!(f, "DUST"),
Self::DustSpending => write!(f, "DUST_SPENDING"),
Self::ChangeDetection => write!(f, "CHANGE_DETECTION"),
Self::Consolidation => write!(f, "CONSOLIDATION"),
Self::ScriptTypeMixing => write!(f, "SCRIPT_TYPE_MIXING"),
Self::ClusterMerge => write!(f, "CLUSTER_MERGE"),
Self::UtxoAgeSpread => write!(f, "UTXO_AGE_SPREAD"),
Self::DormantUtxos => write!(f, "DORMANT_UTXOS"),
Self::ExchangeOrigin => write!(f, "EXCHANGE_ORIGIN"),
Self::TaintedUtxoMerge => write!(f, "TAINTED_UTXO_MERGE"),
Self::DirectTaint => write!(f, "DIRECT_TAINT"),
Self::BehavioralFingerprint => write!(f, "BEHAVIORAL_FINGERPRINT"),
}
}
}
/// A single privacy vulnerability finding.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
#[serde(rename = "type")]
pub vulnerability_type: VulnerabilityType,
pub severity: Severity,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub correction: Option<String>,
}
/// Aggregate statistics about the scan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stats {
pub transactions_analyzed: usize,
pub addresses_seen: usize,
pub utxos_current: usize,
}
/// Summary of the scan results.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Summary {
pub findings: usize,
pub warnings: usize,
pub clean: bool,
}
/// The complete vulnerability scan report.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub stats: Stats,
pub findings: Vec<Finding>,
pub warnings: Vec<Finding>,
pub summary: Summary,
}
impl Report {
/// Construct a report from collected findings and warnings.
pub fn new(stats: Stats, findings: Vec<Finding>, warnings: Vec<Finding>) -> Self {
let summary = Summary {
findings: findings.len(),
warnings: warnings.len(),
clean: findings.is_empty() && warnings.is_empty(),
};
Report {
stats,
findings,
warnings,
summary,
}
}
}
/// Convert a BTC f64 value to an [`Amount`].
pub fn btc_to_amount(btc: f64) -> Amount {
Amount::from_sat((btc * 1e8).round() as u64)
}
/// Metadata about a derived address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddressInfo {
/// The script type (e.g. "p2wpkh", "p2tr", "p2sh", "p2wsh", "p2pkh").
pub script_type: String,
/// Whether this is a change (internal) address.
pub internal: bool,
/// The derivation index.
pub index: usize,
}
/// Information about a transaction input, resolved from the parent transaction.
#[derive(Debug, Clone)]
pub struct InputInfo {
pub address: Address<NetworkUnchecked>,
pub value: Amount,
pub funding_txid: Txid,
pub funding_vout: u32,
}
/// Information about a transaction output.
#[derive(Debug, Clone)]
pub struct OutputInfo {
pub address: Address<NetworkUnchecked>,
pub value: Amount,
pub index: u32,
pub script_type: String,
}
/// A wallet transaction entry (from `listtransactions`).
#[derive(Debug, Clone)]
pub struct WalletTx {
pub txid: Txid,
pub address: Address<NetworkUnchecked>,
pub category: WalletTxCategory,
pub amount: Amount,
pub confirmations: u32,
}