mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-09 14:11:52 -07:00
Merge pull request #16 from satsfy/add-rust-detector
refactor: implement vulnerability detection in Rust crate
This commit is contained in:
+1
-1
@@ -1,4 +1,3 @@
|
||||
backend/script/bitcoin-data/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
@@ -13,3 +12,4 @@ dist/
|
||||
.qwen
|
||||
**/__pycache__/
|
||||
target/
|
||||
Cargo.lock
|
||||
Generated
-1735
File diff suppressed because it is too large
Load Diff
+25
-11
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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)]
|
||||
@@ -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"] }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user