From 9eb6b8ba584b5902236243658b74504b480e3db9 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Thu, 26 Mar 2026 12:42:11 -0300 Subject: [PATCH] refactor: remove deprecated crates/ --- crates/stealth-app/Cargo.toml | 18 - crates/stealth-app/src/bin/stealth-api.rs | 39 - crates/stealth-app/src/lib.rs | 189 ---- crates/stealth-app/src/main.rs | 48 - crates/stealth-bitcoincore/Cargo.toml | 14 - crates/stealth-bitcoincore/src/lib.rs | 558 ----------- crates/stealth-core/Cargo.toml | 10 - crates/stealth-core/src/config.rs | 74 -- crates/stealth-core/src/descriptor.rs | 66 -- crates/stealth-core/src/detectors.rs | 1042 --------------------- crates/stealth-core/src/engine.rs | 122 --- crates/stealth-core/src/error.rs | 13 - crates/stealth-core/src/gateway.rs | 33 - crates/stealth-core/src/graph.rs | 151 --- crates/stealth-core/src/lib.rs | 80 -- crates/stealth-core/src/model.rs | 267 ------ crates/stealth-core/tests/detectors.rs | 689 -------------- crates/stealth-core/tests/engine.rs | 253 ----- 18 files changed, 3666 deletions(-) delete mode 100644 crates/stealth-app/Cargo.toml delete mode 100644 crates/stealth-app/src/bin/stealth-api.rs delete mode 100644 crates/stealth-app/src/lib.rs delete mode 100644 crates/stealth-app/src/main.rs delete mode 100644 crates/stealth-bitcoincore/Cargo.toml delete mode 100644 crates/stealth-bitcoincore/src/lib.rs delete mode 100644 crates/stealth-core/Cargo.toml delete mode 100644 crates/stealth-core/src/config.rs delete mode 100644 crates/stealth-core/src/descriptor.rs delete mode 100644 crates/stealth-core/src/detectors.rs delete mode 100644 crates/stealth-core/src/engine.rs delete mode 100644 crates/stealth-core/src/error.rs delete mode 100644 crates/stealth-core/src/gateway.rs delete mode 100644 crates/stealth-core/src/graph.rs delete mode 100644 crates/stealth-core/src/lib.rs delete mode 100644 crates/stealth-core/src/model.rs delete mode 100644 crates/stealth-core/tests/detectors.rs delete mode 100644 crates/stealth-core/tests/engine.rs diff --git a/crates/stealth-app/Cargo.toml b/crates/stealth-app/Cargo.toml deleted file mode 100644 index 9db2343..0000000 --- a/crates/stealth-app/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/crates/stealth-app/src/bin/stealth-api.rs b/crates/stealth-app/src/bin/stealth-api.rs deleted file mode 100644 index d54947f..0000000 --- a/crates/stealth-app/src/bin/stealth-api.rs +++ /dev/null @@ -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, - #[arg(long = "known-exchange-wallet")] - known_exchange_wallets: Vec, -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - 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(()) -} diff --git a/crates/stealth-app/src/lib.rs b/crates/stealth-app/src/lib.rs deleted file mode 100644 index 4458728..0000000 --- a/crates/stealth-app/src/lib.rs +++ /dev/null @@ -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; -} - -pub struct CoreScanService { - gateway: G, - settings: EngineSettings, -} - -impl CoreScanService { - pub fn new(gateway: G, settings: EngineSettings) -> Self { - Self { gateway, settings } - } -} - -impl ScanService for CoreScanService -where - G: BlockchainGateway + Send + Sync + 'static, -{ - fn analyze_descriptor(&self, descriptor: String) -> Result { - 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, AnalysisError> { - let config = BitcoinCoreConfig::from_ini_file(config_path)?; - let gateway = BitcoinCoreRpc::new(config)?; - Ok(CoreScanService::new(gateway, settings)) -} - -pub fn build_router(service: Arc, cors_origin: Option<&str>) -> Router -where - S: ScanService, -{ - let mut router = Router::new() - .route("/api/wallet/scan", get(scan_handler::)) - .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, -} - -#[derive(Debug, Serialize)] -struct ErrorBody { - error: String, -} - -async fn scan_handler(State(service): State>, Query(query): Query) -> 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); - - impl ScanService for MockService { - fn analyze_descriptor(&self, _descriptor: String) -> Result { - 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::(&body).unwrap(); - assert_eq!(json["summary"]["clean"], json!(true)); - } -} diff --git a/crates/stealth-app/src/main.rs b/crates/stealth-app/src/main.rs deleted file mode 100644 index 99860e4..0000000 --- a/crates/stealth-app/src/main.rs +++ /dev/null @@ -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, - #[arg(long)] - wallet: Option, - #[arg(long, default_value_os_t = default_bitcoin_config_path())] - config: PathBuf, - #[arg(long = "known-risky-wallet")] - known_risky_wallets: Vec, - #[arg(long = "known-exchange-wallet")] - known_exchange_wallets: Vec, - #[arg(long)] - pretty: bool, -} - -fn main() -> Result<(), Box> { - 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(()) -} diff --git a/crates/stealth-bitcoincore/Cargo.toml b/crates/stealth-bitcoincore/Cargo.toml deleted file mode 100644 index 222222a..0000000 --- a/crates/stealth-bitcoincore/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/stealth-bitcoincore/src/lib.rs b/crates/stealth-bitcoincore/src/lib.rs deleted file mode 100644 index 95769f7..0000000 --- a/crates/stealth-bitcoincore/src/lib.rs +++ /dev/null @@ -1,558 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -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, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BitcoinCoreConfig { - pub network: String, - pub datadir: Option, - pub rpchost: String, - pub rpcport: u16, - pub rpcuser: Option, - pub rpcpassword: Option, -} - -impl BitcoinCoreConfig { - pub fn from_ini_file(path: impl AsRef) -> Result { - let path = path.as_ref(); - let ini = Ini::load_from_file(path) - .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; - let section = ini.section(Some("bitcoin")).ok_or_else(|| { - AnalysisError::EnvironmentUnavailable("missing [bitcoin] section".into()) - })?; - - let network = section - .get("network") - .map(|value| value.trim().to_lowercase()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "regtest".into()); - let datadir = section.get("datadir").and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else if Path::new(trimmed).is_absolute() { - Some(PathBuf::from(trimmed)) - } else { - Some( - path.parent() - .unwrap_or_else(|| Path::new(".")) - .join(trimmed), - ) - } - }); - - Ok(Self { - rpcport: section - .get("rpcport") - .and_then(|value| value.parse::().ok()) - .unwrap_or_else(|| default_rpc_port(&network)), - rpchost: section - .get("rpchost") - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "127.0.0.1".into()), - rpcuser: section - .get("rpcuser") - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()), - rpcpassword: section - .get("rpcpassword") - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()), - network, - datadir, - }) - } - - fn cookie_credentials(&self) -> Result<(String, String), AnalysisError> { - let datadir = self.datadir.as_ref().ok_or_else(|| { - AnalysisError::EnvironmentUnavailable("missing datadir for cookie auth".into()) - })?; - let mut candidates = Vec::new(); - if self.network == "mainnet" { - candidates.push(datadir.join(".cookie")); - } else { - candidates.push(datadir.join(&self.network).join(".cookie")); - candidates.push(datadir.join(".cookie")); - } - - for candidate in candidates { - 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)); - } - } - - Err(AnalysisError::EnvironmentUnavailable( - "could not locate a readable Bitcoin Core cookie file".into(), - )) - } -} - -pub struct BitcoinCoreRpc { - config: BitcoinCoreConfig, - client: Client, -} - -impl BitcoinCoreRpc { - pub fn new(config: BitcoinCoreConfig) -> Result { - let client = Client::builder() - .build() - .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; - Ok(Self { config, client }) - } - - fn rpc_url(&self, wallet: Option<&str>) -> String { - let base = format!("http://{}:{}", self.config.rpchost, self.config.rpcport); - wallet - .map(|wallet_name| format!("{base}/wallet/{}", urlencoding::encode(wallet_name))) - .unwrap_or(base) - } - - fn credentials(&self) -> Result<(String, String), AnalysisError> { - if let (Some(user), Some(password)) = - (self.config.rpcuser.clone(), self.config.rpcpassword.clone()) - { - Ok((user, password)) - } else { - self.config.cookie_credentials() - } - } - - fn call( - &self, - wallet: Option<&str>, - method: &str, - params: Vec, - ) -> Result { - let (user, password) = self.credentials()?; - let response = self - .client - .post(self.rpc_url(wallet)) - .basic_auth(user, Some(password)) - .json(&json!({ - "jsonrpc": "1.0", - "id": "stealth-rust", - "method": method, - "params": params, - })) - .send() - .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; - - if !response.status().is_success() { - return Err(AnalysisError::EnvironmentUnavailable(format!( - "rpc transport error: {}", - response.status() - ))); - } - - let envelope = response - .json::>() - .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; - match (envelope.result, envelope.error) { - (Some(result), None) => Ok(result), - (_, Some(error)) => Err(AnalysisError::EnvironmentUnavailable(error.message)), - _ => Err(AnalysisError::EnvironmentUnavailable( - "rpc returned neither result nor error".into(), - )), - } - } - - fn load_history_for_wallet(&self, wallet_name: &str) -> Result { - let wallet_txs = self.list_transactions(wallet_name)?; - let utxos = self.list_unspent(wallet_name)?; - let mut txids = wallet_txs - .iter() - .map(|entry| entry.txid.clone()) - .collect::>(); - txids.extend(utxos.iter().map(|utxo| utxo.txid.clone())); - - let mut transactions = HashMap::new(); - let mut queue = txids.into_iter().collect::>(); - while let Some(txid) = queue.pop() { - if transactions.contains_key(&txid) { - continue; - } - let tx = self.get_transaction(&txid)?; - for input in &tx.vin { - if !input.coinbase && !transactions.contains_key(&input.previous_txid) { - queue.push(input.previous_txid.clone()); - } - } - transactions.insert(txid.clone(), tx); - } - - Ok(WalletHistory { - wallet_txs, - utxos, - transactions, - }) - } - - fn list_transactions(&self, wallet_name: &str) -> Result, AnalysisError> { - let entries = self.call::>( - Some(wallet_name), - "listtransactions", - vec![json!("*"), json!(10000), json!(0), json!(true)], - )?; - Ok(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(), - }) - .collect()) - } - - fn list_unspent(&self, wallet_name: &str) -> Result, AnalysisError> { - let utxos = self.call::>( - Some(wallet_name), - "listunspent", - vec![json!(0), json!(9_999_999)], - )?; - Ok(utxos - .into_iter() - .map(|utxo| { - let address = utxo.address.unwrap_or_default(); - Utxo { - txid: utxo.txid, - vout: utxo.vout, - address: address.clone(), - amount_btc: utxo.amount, - confirmations: utxo.confirmations.unwrap_or_default(), - script_type: DescriptorType::infer_from_address(&address), - } - }) - .collect()) - } - - fn get_transaction(&self, txid: &str) -> Result { - let tx = - self.call::(None, "getrawtransaction", vec![json!(txid), json!(true)])?; - - Ok(DecodedTransaction { - 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(), - }) - .collect(), - vout: tx - .vout - .into_iter() - .map(|output| { - let address = output - .script_pub_key - .address - .or_else(|| { - output - .script_pub_key - .addresses - .and_then(|mut items| items.pop()) - }) - .unwrap_or_default(); - 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)), - } - }) - .collect(), - version: tx.version.unwrap_or(2), - locktime: tx.locktime.unwrap_or_default(), - vsize: tx.vsize.unwrap_or_default(), - confirmations: tx.confirmations.unwrap_or_default(), - }) - } - - fn create_watch_only_wallet(&self, wallet_name: &str) -> Result<(), AnalysisError> { - let _ = self.call::( - None, - "createwallet", - vec![ - json!(wallet_name), - json!(true), - json!(true), - json!(""), - json!(false), - json!(true), - ], - )?; - Ok(()) - } - - fn unload_wallet(&self, wallet_name: &str) { - let _ = self.call::(None, "unloadwallet", vec![json!(wallet_name)]); - } -} - -impl BlockchainGateway for BitcoinCoreRpc { - fn normalize_descriptor(&self, descriptor: &str) -> Result { - let response = - self.call::(None, "getdescriptorinfo", vec![json!(descriptor)])?; - Ok(response.descriptor) - } - - fn derive_addresses( - &self, - descriptor: &ResolvedDescriptor, - ) -> Result, AnalysisError> { - self.call( - None, - "deriveaddresses", - vec![json!(descriptor.desc), json!([0, descriptor.range_end])], - ) - } - - fn scan_descriptors( - &self, - descriptors: &[ResolvedDescriptor], - ) -> Result { - let wallet_name = format!( - "_stealth_scan_{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))? - .as_millis() - ); - self.create_watch_only_wallet(&wallet_name)?; - - let imports = descriptors - .iter() - .map(|descriptor| { - json!({ - "desc": descriptor.desc, - "timestamp": 0, - "internal": descriptor.internal, - "active": descriptor.active, - "range": [0, descriptor.range_end], - }) - }) - .collect::>(); - - let import_results = self.call::>( - Some(&wallet_name), - "importdescriptors", - 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 history = self.load_history_for_wallet(&wallet_name); - self.unload_wallet(&wallet_name); - history - } - - fn list_wallet_descriptors( - &self, - wallet_name: &str, - ) -> Result, AnalysisError> { - let response = - self.call::(Some(wallet_name), "listdescriptors", Vec::new())?; - Ok(response - .descriptors - .into_iter() - .map(|descriptor| ResolvedDescriptor { - desc: descriptor.desc, - internal: descriptor.internal.unwrap_or(false), - active: descriptor.active.unwrap_or(true), - range_end: descriptor - .range - .map(|range| match range { - DescriptorRange::Single(value) => value, - DescriptorRange::Pair([_, end]) => end, - }) - .unwrap_or(999), - }) - .collect()) - } - - fn scan_wallet(&self, wallet_name: &str) -> Result { - self.load_history_for_wallet(wallet_name) - } - - fn known_wallet_txids( - &self, - wallet_names: &[String], - ) -> Result, AnalysisError> { - let mut txids = HashSet::new(); - for wallet_name in wallet_names { - txids.extend( - self.list_transactions(wallet_name)? - .into_iter() - .map(|entry| entry.txid), - ); - } - Ok(txids) - } -} - -fn default_rpc_port(network: &str) -> u16 { - match network { - "mainnet" => 8332, - "testnet" => 18332, - "signet" => 38332, - _ => 18443, - } -} - -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, - "pubkeyhash" => DescriptorType::P2pkh, - _ => DescriptorType::Unknown, - } -} - -#[derive(Debug, Deserialize)] -struct JsonRpcEnvelope { - result: Option, - error: Option, -} - -#[derive(Debug, Deserialize)] -struct JsonRpcError { - message: String, -} - -#[derive(Debug, Deserialize)] -struct DescriptorInfo { - descriptor: String, -} - -#[derive(Debug, Deserialize)] -struct ImportResult { - success: bool, -} - -#[derive(Debug, Deserialize)] -struct ListDescriptorsResponse { - descriptors: Vec, -} - -#[derive(Debug, Deserialize)] -struct DescriptorRecord { - desc: String, - internal: Option, - active: Option, - range: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum DescriptorRange { - Single(u32), - Pair([u32; 2]), -} - -#[derive(Debug, Deserialize)] -struct ListTransactionEntry { - txid: String, - address: Option, - category: Option, - amount: f64, - confirmations: Option, - blockheight: Option, -} - -#[derive(Debug, Deserialize)] -struct ListUnspentEntry { - txid: String, - vout: u32, - address: Option, - amount: f64, - confirmations: Option, -} - -#[derive(Debug, Deserialize)] -struct RawTransaction { - txid: String, - vin: Vec, - vout: Vec, - version: Option, - locktime: Option, - vsize: Option, - confirmations: Option, -} - -#[derive(Debug, Deserialize)] -struct RawVin { - txid: Option, - vout: Option, - coinbase: Option, - sequence: Option, -} - -#[derive(Debug, Deserialize)] -struct RawVout { - value: f64, - n: u32, - #[serde(rename = "scriptPubKey")] - script_pub_key: RawScriptPubKey, -} - -#[derive(Debug, Deserialize)] -struct RawScriptPubKey { - address: Option, - addresses: Option>, - #[serde(rename = "type")] - script_type: Option, -} - -#[cfg(test)] -mod tests { - use super::default_rpc_port; - - #[test] - fn network_defaults_match_bitcoin_core_ports() { - assert_eq!(default_rpc_port("regtest"), 18443); - assert_eq!(default_rpc_port("testnet"), 18332); - assert_eq!(default_rpc_port("signet"), 38332); - assert_eq!(default_rpc_port("mainnet"), 8332); - } -} diff --git a/crates/stealth-core/Cargo.toml b/crates/stealth-core/Cargo.toml deleted file mode 100644 index 466f94b..0000000 --- a/crates/stealth-core/Cargo.toml +++ /dev/null @@ -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 diff --git a/crates/stealth-core/src/config.rs b/crates/stealth-core/src/config.rs deleted file mode 100644 index 171aa2c..0000000 --- a/crates/stealth-core/src/config.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::collections::HashSet; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DetectorId { - AddressReuse, - Cioh, - Dust, - DustSpending, - ChangeDetection, - Consolidation, - ScriptTypeMixing, - ClusterMerge, - UtxoAgeSpread, - ExchangeOrigin, - TaintedUtxoMerge, - BehavioralFingerprint, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DetectorThresholds { - pub dust_sats: u64, - pub strict_dust_sats: u64, - pub normal_input_min_sats: u64, - 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, -} - -impl Default for DetectorThresholds { - fn default() -> Self { - Self { - dust_sats: 1_000, - strict_dust_sats: 546, - normal_input_min_sats: 10_000, - consolidation_min_inputs: 3, - consolidation_max_outputs: 2, - utxo_age_spread_blocks: 10, - dormant_utxo_blocks: 100, - exchange_batch_outputs: 5, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AnalysisConfig { - pub derivation_range_end: u32, - pub thresholds: DetectorThresholds, - pub enabled_detectors: HashSet, -} - -impl Default for AnalysisConfig { - fn default() -> Self { - Self { - derivation_range_end: 999, - thresholds: DetectorThresholds::default(), - enabled_detectors: HashSet::from([ - DetectorId::AddressReuse, - DetectorId::Cioh, - DetectorId::Dust, - DetectorId::DustSpending, - DetectorId::ChangeDetection, - DetectorId::Consolidation, - DetectorId::ScriptTypeMixing, - DetectorId::ClusterMerge, - DetectorId::UtxoAgeSpread, - DetectorId::ExchangeOrigin, - DetectorId::TaintedUtxoMerge, - DetectorId::BehavioralFingerprint, - ]), - } - } -} diff --git a/crates/stealth-core/src/descriptor.rs b/crates/stealth-core/src/descriptor.rs deleted file mode 100644 index c03b98f..0000000 --- a/crates/stealth-core/src/descriptor.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::error::AnalysisError; -use crate::model::ResolvedDescriptor; - -pub trait DescriptorNormalizer { - fn normalize(&self, descriptor: &str) -> Result; -} - -pub fn normalize_descriptors( - raw_descriptors: &[String], - derivation_range_end: u32, - normalizer: &dyn DescriptorNormalizer, -) -> Result, AnalysisError> { - let mut resolved = 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() { - return Err(AnalysisError::EmptyDescriptor); - } - - 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.clone(), false)] - }; - - for (candidate, internal) in candidates { - let normalized = normalizer - .normalize(&candidate) - .map_err(|error| match error { - AnalysisError::DescriptorNormalization { .. } => error, - other => AnalysisError::DescriptorNormalization { - descriptor: candidate.clone(), - message: other.to_string(), - }, - })?; - - let descriptor = ResolvedDescriptor { - desc: normalized, - internal, - active: true, - range_end: derivation_range_end, - }; - - if !resolved.iter().any(|item| item == &descriptor) { - resolved.push(descriptor); - } - } - } - - Ok(resolved) -} diff --git a/crates/stealth-core/src/detectors.rs b/crates/stealth-core/src/detectors.rs deleted file mode 100644 index 0696cd5..0000000 --- a/crates/stealth-core/src/detectors.rs +++ /dev/null @@ -1,1042 +0,0 @@ -use std::collections::HashSet; - -use serde_json::json; - -use crate::config::{AnalysisConfig, DetectorId}; -use crate::graph::{TxGraph, btc_to_sats}; -use crate::model::{ - Finding, FindingDetails, FindingKind, Severity, TransactionParticipant, Warning, - WarningDetails, WarningKind, -}; - -#[derive(Debug, Default, Clone, PartialEq)] -pub struct DetectorResult { - pub findings: Vec, - pub warnings: Vec, -} - -impl DetectorResult { - pub fn extend(&mut self, other: Self) { - self.findings.extend(other.findings); - self.warnings.extend(other.warnings); - } -} - -pub struct DetectorContext<'a> { - pub graph: &'a TxGraph, - pub config: &'a AnalysisConfig, - pub known_exchange_txids: &'a HashSet, - pub known_risky_txids: &'a HashSet, -} - -pub fn run_all(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut result = DetectorResult::default(); - let enabled = &ctx.config.enabled_detectors; - - for (detector, run) in [ - ( - DetectorId::AddressReuse, - detect_address_reuse as fn(&DetectorContext<'_>) -> DetectorResult, - ), - (DetectorId::Cioh, detect_cioh), - (DetectorId::Dust, detect_dust), - (DetectorId::DustSpending, detect_dust_spending), - (DetectorId::ChangeDetection, detect_change_detection), - (DetectorId::Consolidation, detect_consolidation), - (DetectorId::ScriptTypeMixing, detect_script_type_mixing), - (DetectorId::ClusterMerge, detect_cluster_merge), - (DetectorId::UtxoAgeSpread, detect_utxo_age_spread), - (DetectorId::ExchangeOrigin, detect_exchange_origin), - (DetectorId::TaintedUtxoMerge, detect_tainted_utxo_merge), - ( - DetectorId::BehavioralFingerprint, - detect_behavioral_fingerprint, - ), - ] { - if enabled.contains(&detector) { - result.extend(run(ctx)); - } - } - - result -} - -pub fn detect_address_reuse(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for address in ctx.graph.addresses() { - let receive_txids: Vec<_> = ctx - .graph - .wallet_entries(&address.address) - .iter() - .filter(|entry| matches!(entry.category, crate::model::WalletTxCategory::Receive)) - .map(|entry| entry.txid.clone()) - .collect(); - let distinct: HashSet<_> = receive_txids.into_iter().collect(); - if distinct.len() < 2 { - continue; - } - - let mut txids = distinct - .iter() - .map(|txid| { - let confirmations = ctx - .graph - .tx(txid) - .map(|tx| tx.confirmations) - .unwrap_or_default(); - json!({ - "txid": txid, - "confirmations": confirmations, - }) - }) - .collect::>(); - txids.sort_by(|left, right| left["txid"].as_str().cmp(&right["txid"].as_str())); - - findings.push(finding( - FindingKind::AddressReuse, - Severity::High, - format!( - "Address {} ({}) reused across {} transactions", - address.address, - ctx.graph.address_role(&address.address), - distinct.len() - ), - json!({ - "address": address.address, - "role": ctx.graph.address_role(&address.address), - "tx_count": distinct.len(), - "txids": txids, - }), - Some("Generate a fresh address for each receipt and avoid static reuse.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_cioh(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let Some(tx) = ctx.graph.tx(txid) else { - continue; - }; - if tx.vin.len() < 2 { - continue; - } - - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - - let our_inputs: Vec<_> = inputs.iter().filter(|input| input.is_ours).collect(); - if our_inputs.len() < 2 { - continue; - } - let external_inputs = inputs.len() - our_inputs.len(); - let ownership_pct = (our_inputs.len() as f64 / inputs.len() as f64 * 100.0).round() as u64; - let severity = if our_inputs.len() == inputs.len() { - Severity::Critical - } else { - Severity::High - }; - - findings.push(finding( - FindingKind::Cioh, - severity, - format!( - "TX {txid} merges {}/{} of your inputs ({}% ownership)", - our_inputs.len(), - inputs.len(), - ownership_pct - ), - json!({ - "txid": txid, - "total_inputs": inputs.len(), - "our_inputs": our_inputs.len(), - "external_inputs": external_inputs, - "ownership_pct": ownership_pct, - "our_addresses": our_inputs.iter().map(|input| json!({ - "address": input.address, - "role": ctx.graph.address_role(&input.address), - "amount_btc": round_btc(input.value_btc), - })).collect::>(), - }), - Some( - "Use coin control or collaborative spending tools to avoid linking inputs.".into(), - ), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_dust(ctx: &DetectorContext<'_>) -> DetectorResult { - let dust_sats = ctx.config.thresholds.dust_sats; - let strict_dust_sats = ctx.config.thresholds.strict_dust_sats; - let mut findings = Vec::new(); - - let current = ctx - .graph - .utxos() - .iter() - .filter(|utxo| { - ctx.graph.is_ours(&utxo.address) && btc_to_sats(utxo.amount_btc) <= dust_sats - }) - .collect::>(); - - for utxo in ¤t { - let sats = btc_to_sats(utxo.amount_btc); - let label = if sats <= strict_dust_sats { - "STRICT_DUST" - } else { - "dust-class" - }; - findings.push(finding( - FindingKind::Dust, - if sats <= strict_dust_sats { - Severity::High - } else { - Severity::Medium - }, - format!( - "Dust UTXO at {} ({} sats, {}, unspent)", - utxo.address, sats, label - ), - json!({ - "status": "unspent", - "address": utxo.address, - "sats": sats, - "label": label, - "txid": utxo.txid, - "vout": utxo.vout, - }), - Some("Freeze dust UTXOs instead of spending them alongside normal funds.".into()), - )); - } - - let current_keys = current - .iter() - .map(|utxo| (utxo.txid.clone(), utxo.address.clone())) - .collect::>(); - let mut historical_seen = HashSet::new(); - - for txid in ctx.graph.our_txids() { - for output in ctx.graph.output_participants(txid) { - if !output.is_ours || output.value_sats > dust_sats { - continue; - } - let key = (txid.clone(), output.address.clone()); - if current_keys.contains(&key) || !historical_seen.insert(key.clone()) { - continue; - } - findings.push(finding( - FindingKind::Dust, - Severity::Low, - format!( - "Historical dust output at {} ({} sats, already spent)", - output.address, output.value_sats - ), - json!({ - "status": "spent", - "address": output.address, - "sats": output.value_sats, - "txid": txid, - }), - Some("Reject unsolicited dust or isolate it before spending.".into()), - )); - } - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_dust_spending(ctx: &DetectorContext<'_>) -> DetectorResult { - let dust_sats = ctx.config.thresholds.dust_sats; - let normal_min = ctx.config.thresholds.normal_input_min_sats; - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - - let mut dust_inputs = Vec::new(); - let mut normal_inputs = Vec::new(); - - for input in inputs.iter().filter(|input| input.is_ours) { - if input.value_sats <= dust_sats { - dust_inputs.push(input); - } else if input.value_sats > normal_min { - normal_inputs.push(input); - } - } - - if dust_inputs.is_empty() || normal_inputs.is_empty() { - continue; - } - - findings.push(finding( - FindingKind::DustSpending, - Severity::High, - format!( - "TX {txid} spends {} dust input(s) alongside {} normal input(s)", - dust_inputs.len(), - normal_inputs.len() - ), - json!({ - "txid": txid, - "dust_inputs": dust_inputs.iter().map(|input| json!({ - "address": input.address, - "sats": input.value_sats, - })).collect::>(), - "normal_inputs": normal_inputs.iter().map(|input| json!({ - "address": input.address, - "amount_btc": round_btc(input.value_btc), - })).collect::>(), - }), - Some("Do not combine dust with normal inputs in the same spend.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_change_detection(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let outputs = ctx.graph.output_participants(txid); - if outputs.len() < 2 { - continue; - } - let inputs = ctx.graph.input_participants(txid); - let our_inputs: Vec<_> = inputs.iter().filter(|input| input.is_ours).collect(); - if our_inputs.is_empty() { - continue; - } - - let our_outputs: Vec<_> = outputs.iter().filter(|output| output.is_ours).collect(); - let external_outputs: Vec<_> = outputs.iter().filter(|output| !output.is_ours).collect(); - if our_outputs.is_empty() || external_outputs.is_empty() { - continue; - } - - let input_types = our_inputs - .iter() - .map(|input| input.script_type) - .collect::>(); - let mut reasons = Vec::new(); - - for change in &our_outputs { - let change_round = is_round_amount(change.value_sats); - let change_internal = - ctx.graph - .derived_address(&change.address) - .is_some_and(|address| { - address.chain_role == crate::model::DescriptorChainRole::Internal - }); - - for payment in &external_outputs { - if is_round_amount(payment.value_sats) && !change_round { - reasons.push(format!( - "Round payment ({} sats) vs non-round change ({} sats)", - payment.value_sats, change.value_sats - )); - } - - if input_types.contains(&change.script_type) - && change.script_type != payment.script_type - { - reasons.push(format!( - "Change script type ({}) matches inputs and differs from payment ({})", - change.script_type.as_script_name(), - payment.script_type.as_script_name() - )); - } - - if change_internal { - reasons - .push("Change uses an internal (BIP-44 /1/*) derivation path".to_string()); - } - } - } - - if reasons.is_empty() { - continue; - } - - reasons.sort(); - reasons.dedup(); - - findings.push(finding( - FindingKind::ChangeDetection, - Severity::Medium, - format!( - "TX {txid} has identifiable change output(s) ({} heuristic(s) matched)", - reasons.len() - ), - json!({ - "txid": txid, - "reasons": reasons, - "change_outputs": our_outputs.iter().map(|output| json!({ - "address": output.address, - "amount_btc": round_btc(output.value_btc), - })).collect::>(), - }), - Some("Prefer payment construction that avoids trivially identifiable change.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_consolidation(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - let min_inputs = ctx.config.thresholds.consolidation_min_inputs; - let max_outputs = ctx.config.thresholds.consolidation_max_outputs; - - for utxo in ctx - .graph - .utxos() - .iter() - .filter(|utxo| ctx.graph.is_ours(&utxo.address)) - { - let Some(parent) = ctx.graph.tx(&utxo.txid) else { - continue; - }; - if parent.vin.len() < min_inputs || parent.vout.len() > max_outputs { - continue; - } - let parent_inputs = ctx.graph.input_participants(&utxo.txid); - let our_parent_inputs = parent_inputs.iter().filter(|input| input.is_ours).count(); - findings.push(finding( - FindingKind::Consolidation, - Severity::Medium, - format!( - "UTXO {}:{} ({:.8} BTC) born from a {}-input consolidation", - utxo.txid, - utxo.vout, - utxo.amount_btc, - parent.vin.len() - ), - json!({ - "txid": utxo.txid, - "vout": utxo.vout, - "amount_btc": round_btc(utxo.amount_btc), - "consolidation_inputs": parent.vin.len(), - "consolidation_outputs": parent.vout.len(), - "our_inputs_in_consolidation": our_parent_inputs, - }), - Some("Avoid large one-shot consolidations unless you can hide the linkage.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_script_type_mixing(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - let our_inputs = inputs.iter().filter(|input| input.is_ours).count(); - if our_inputs < 2 { - continue; - } - - let mut types = inputs - .iter() - .map(|input| input.script_type) - .filter(|script_type| *script_type != crate::model::DescriptorType::Unknown) - .collect::>(); - types.sort(); - types.dedup(); - - if types.len() < 2 { - continue; - } - - findings.push(finding( - FindingKind::ScriptTypeMixing, - Severity::High, - format!("TX {txid} mixes input script types: {:?}", types), - json!({ - "txid": txid, - "script_types": types.iter().map(|script_type| script_type.as_script_name()).collect::>(), - "inputs": inputs.iter().map(|input| json!({ - "address": input.address, - "script_type": input.script_type.as_script_name(), - "ours": input.is_ours, - })).collect::>(), - }), - Some("Standardize on a single script family per spend.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_cluster_merge(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - let our_inputs = inputs - .iter() - .filter(|input| input.is_ours) - .collect::>(); - if our_inputs.len() < 2 { - continue; - } - - let mut funding_sources = serde_json::Map::new(); - let mut source_sets = Vec::new(); - - for input in our_inputs { - let Some(parent_txid) = input.funding_txid.as_deref() else { - continue; - }; - let Some(parent_tx) = ctx.graph.tx(parent_txid) else { - continue; - }; - let mut sources = parent_tx - .vin - .iter() - .map(|vin| { - if vin.coinbase { - "coinbase".to_string() - } else { - vin.previous_txid.chars().take(16).collect::() - } - }) - .collect::>(); - sources.sort(); - sources.dedup(); - let source_set = sources.iter().cloned().collect::>(); - source_sets.push(source_set); - funding_sources.insert( - format!( - "{}:{}", - parent_txid.chars().take(16).collect::(), - input.funding_vout.unwrap_or_default() - ), - json!(sources), - ); - } - - let mut merged = false; - for i in 0..source_sets.len() { - for j in (i + 1)..source_sets.len() { - if source_sets[i].is_disjoint(&source_sets[j]) { - merged = true; - } - } - } - - if !merged { - continue; - } - - findings.push(finding( - FindingKind::ClusterMerge, - Severity::High, - format!( - "TX {txid} merges UTXOs from {} different funding chains", - funding_sources.len() - ), - json!({ - "txid": txid, - "funding_sources": funding_sources, - }), - Some("Avoid co-spending UTXOs from unrelated provenance clusters.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_utxo_age_spread(ctx: &DetectorContext<'_>) -> DetectorResult { - let our_utxos = ctx - .graph - .utxos() - .iter() - .filter(|utxo| ctx.graph.is_ours(&utxo.address)) - .collect::>(); - if our_utxos.len() < 2 { - return DetectorResult::default(); - } - - let mut ordered = our_utxos; - ordered.sort_by_key(|utxo| std::cmp::Reverse(utxo.confirmations)); - let oldest = ordered.first().expect("ordered has at least two"); - let newest = ordered.last().expect("ordered has at least two"); - let spread = oldest.confirmations.saturating_sub(newest.confirmations); - - let mut findings = Vec::new(); - let mut warnings = Vec::new(); - - if spread >= ctx.config.thresholds.utxo_age_spread_blocks { - findings.push(finding( - FindingKind::UtxoAgeSpread, - Severity::Low, - format!("UTXO age spread of {spread} blocks between oldest and newest"), - json!({ - "spread_blocks": spread, - "oldest": { - "txid": oldest.txid, - "confirmations": oldest.confirmations, - "amount_btc": round_btc(oldest.amount_btc), - }, - "newest": { - "txid": newest.txid, - "confirmations": newest.confirmations, - "amount_btc": round_btc(newest.amount_btc), - }, - }), - Some("Normalize UTXO ages or isolate long-dormant coins before spending.".into()), - )); - } - - let dormant = ordered - .iter() - .filter(|utxo| utxo.confirmations >= ctx.config.thresholds.dormant_utxo_blocks) - .count(); - if dormant > 0 { - warnings.push(warning( - WarningKind::DormantUtxos, - Severity::Low, - format!( - "{} UTXO(s) have ≥{} confirmations (dormant/hoarded coins pattern)", - dormant, ctx.config.thresholds.dormant_utxo_blocks - ), - json!({ - "count": dormant, - "threshold_blocks": ctx.config.thresholds.dormant_utxo_blocks, - }), - )); - } - - DetectorResult { findings, warnings } -} - -pub fn detect_exchange_origin(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - let threshold = ctx.config.thresholds.exchange_batch_outputs; - - for txid in ctx.graph.our_txids() { - let Some(tx) = ctx.graph.tx(txid) else { - continue; - }; - if tx.vout.len() < threshold { - continue; - } - - let our_inputs = ctx - .graph - .input_participants(txid) - .into_iter() - .filter(|input| input.is_ours) - .collect::>(); - if !our_inputs.is_empty() { - continue; - } - let our_outputs = ctx - .graph - .output_participants(txid) - .into_iter() - .filter(|output| output.is_ours) - .collect::>(); - if our_outputs.is_empty() { - continue; - } - - let mut signals = vec![format!("High output count: {}", tx.vout.len())]; - let unique_recipients = tx - .vout - .iter() - .filter(|output| !output.address.is_empty()) - .map(|output| output.address.clone()) - .collect::>(); - if unique_recipients.len() >= threshold { - signals.push(format!( - "{} unique recipient addresses", - unique_recipients.len() - )); - } - if ctx.known_exchange_txids.contains(txid) { - signals.push("TX matches known exchange wallet history".into()); - } - - let inputs = ctx.graph.input_participants(txid); - let input_total = inputs.iter().map(|input| input.value_btc).sum::(); - let mut output_values = tx - .vout - .iter() - .map(|output| output.value_btc) - .collect::>(); - output_values.sort_by(|left, right| left.total_cmp(right)); - if let Some(median) = output_values.get(output_values.len() / 2).copied() { - if median > 0.0 { - let ratio = input_total / median; - if ratio > 10.0 { - signals.push(format!("Input/median-output ratio: {:.0}x", ratio)); - } - } - } - - if signals.len() < 2 { - continue; - } - - findings.push(finding( - FindingKind::ExchangeOrigin, - Severity::Medium, - format!( - "TX {txid} looks like an exchange batch withdrawal ({} signal(s))", - signals.len() - ), - json!({ - "txid": txid, - "signals": signals, - "received_outputs": our_outputs.iter().map(|output| json!({ - "address": output.address, - "amount_btc": round_btc(output.value_btc), - })).collect::>(), - }), - Some( - "Treat exchange withdrawals as linkable entry points and remix before reuse." - .into(), - ), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_tainted_utxo_merge(ctx: &DetectorContext<'_>) -> DetectorResult { - if ctx.known_risky_txids.is_empty() { - return DetectorResult::default(); - } - - let mut findings = Vec::new(); - let mut warnings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 || !inputs.iter().any(|input| input.is_ours) { - continue; - } - - let mut tainted = Vec::new(); - let mut clean = Vec::new(); - for input in &inputs { - let is_tainted = input - .funding_txid - .as_ref() - .is_some_and(|funding_txid| ctx.known_risky_txids.contains(funding_txid)); - if is_tainted { - tainted.push(input); - } else { - clean.push(input); - } - } - - if !tainted.is_empty() && !clean.is_empty() { - let taint_pct = (tainted.len() as f64 / inputs.len() as f64 * 100.0).round() as u64; - findings.push(finding( - FindingKind::TaintedUtxoMerge, - Severity::High, - format!( - "TX {txid} merges {} tainted + {} clean inputs ({}% taint)", - tainted.len(), - clean.len(), - taint_pct - ), - json!({ - "txid": txid, - "tainted_inputs": tainted.iter().map(|input| participant_json(input)).collect::>(), - "clean_inputs": clean.iter().map(|input| participant_json(input)).collect::>(), - "taint_pct": taint_pct, - }), - Some("Keep tainted and clean flows isolated to avoid propagating risk.".into()), - )); - } - } - - for txid in ctx.graph.our_txids() { - if !ctx.known_risky_txids.contains(txid) { - continue; - } - let our_outputs = ctx - .graph - .output_participants(txid) - .into_iter() - .filter(|output| output.is_ours) - .collect::>(); - if our_outputs.is_empty() { - continue; - } - warnings.push(warning( - WarningKind::DirectTaint, - Severity::High, - format!("TX {txid} is directly from a known risky source"), - json!({ - "txid": txid, - "received_outputs": our_outputs.iter().map(|output| json!({ - "address": output.address, - "amount_btc": round_btc(output.value_btc), - })).collect::>(), - }), - )); - } - - DetectorResult { findings, warnings } -} - -pub fn detect_behavioral_fingerprint(ctx: &DetectorContext<'_>) -> DetectorResult { - let send_txids = ctx - .graph - .our_txids() - .filter(|txid| { - ctx.graph - .input_participants(txid) - .iter() - .any(|input| input.is_ours) - }) - .cloned() - .collect::>(); - if send_txids.len() < 3 { - return DetectorResult::default(); - } - - let mut output_counts = Vec::new(); - let mut input_script_types = Vec::new(); - let mut rbf_signals = Vec::new(); - let mut locktime_values = Vec::new(); - let mut fee_rates = Vec::new(); - let mut n_inputs = Vec::new(); - let mut total_payments = 0usize; - let mut round_payments = 0usize; - let mut change_types = HashSet::new(); - let mut payment_types = HashSet::new(); - - for txid in &send_txids { - let Some(tx) = ctx.graph.tx(txid) else { - continue; - }; - output_counts.push(tx.vout.len()); - n_inputs.push(tx.vin.len()); - locktime_values.push(tx.locktime); - rbf_signals.extend(tx.vin.iter().map(|vin| vin.sequence < 0xffff_fffe)); - - let inputs = ctx.graph.input_participants(txid); - input_script_types.extend( - inputs - .iter() - .filter(|input| input.is_ours) - .map(|input| input.script_type), - ); - - for output in ctx.graph.output_participants(txid) { - if output.is_ours { - change_types.insert(output.script_type); - } else { - payment_types.insert(output.script_type); - total_payments += 1; - if is_round_amount(output.value_sats) { - round_payments += 1; - } - } - } - - if tx.vsize > 0 { - let in_total = inputs.iter().map(|input| input.value_btc).sum::(); - let out_total = tx.vout.iter().map(|output| output.value_btc).sum::(); - let fee_sats = ((in_total - out_total) * 100_000_000.0).round(); - if fee_sats > 0.0 { - fee_rates.push(fee_sats / tx.vsize as f64); - } - } - } - - let mut patterns = Vec::new(); - - if total_payments > 0 { - let round_pct = round_payments as f64 / total_payments as f64 * 100.0; - if round_pct > 60.0 { - patterns.push(format!( - "Round payment amounts: {:.0}% of payments are round numbers", - round_pct - )); - } - } - - if output_counts.len() >= 3 && output_counts.iter().all(|count| *count == output_counts[0]) { - patterns.push(format!( - "Uniform output count: all {} send TXs have exactly {} outputs", - output_counts.len(), - output_counts[0] - )); - } - - let input_types = input_script_types.iter().copied().collect::>(); - if input_types.len() > 1 { - patterns.push("Mixed input script types used across send transactions".into()); - } else if input_types.len() == 1 && input_types.contains(&crate::model::DescriptorType::P2pkh) { - patterns.push("All inputs use legacy P2PKH".into()); - } - - if !rbf_signals.is_empty() { - let rbf_enabled = rbf_signals.iter().filter(|signal| **signal).count(); - if rbf_enabled == rbf_signals.len() { - patterns.push("RBF always enabled".into()); - } else if rbf_enabled == 0 { - patterns.push("RBF never enabled".into()); - } - } - - if locktime_values.len() >= 3 { - let non_zero = locktime_values.iter().filter(|value| **value > 0).count(); - if non_zero == locktime_values.len() { - patterns.push("Anti-fee-sniping locktime always set".into()); - } else if non_zero == 0 { - patterns.push("Locktime always 0".into()); - } - } - - if fee_rates.len() >= 3 { - let avg = fee_rates.iter().sum::() / fee_rates.len() as f64; - if avg > 0.0 { - let variance = fee_rates - .iter() - .map(|rate| (*rate - avg).powi(2)) - .sum::() - / fee_rates.len() as f64; - let stddev = variance.sqrt(); - let cv = stddev / avg; - if cv < 0.15 { - patterns.push(format!("Very consistent fee rate: avg {:.1} sat/vB", avg)); - } - } - } - - if !change_types.is_empty() && !payment_types.is_empty() && change_types != payment_types { - patterns.push("Change uses different script type than payments".into()); - } - - if n_inputs.len() >= 3 - && n_inputs - .iter() - .all(|count| *count == n_inputs[0] && *count > 1) - { - patterns.push(format!("Always uses exactly {} inputs per TX", n_inputs[0])); - } - - if patterns.is_empty() { - return DetectorResult::default(); - } - - DetectorResult { - findings: vec![finding( - FindingKind::BehavioralFingerprint, - Severity::Medium, - format!( - "Behavioral fingerprint detected across {} send transactions ({} pattern(s))", - send_txids.len(), - patterns.len() - ), - json!({ - "send_tx_count": send_txids.len(), - "patterns": patterns, - }), - Some( - "Vary spend structure and standardize wallet defaults to reduce fingerprinting." - .into(), - ), - )], - warnings: Vec::new(), - } -} - -fn finding( - kind: FindingKind, - severity: Severity, - description: String, - details: serde_json::Value, - correction: Option, -) -> Finding { - Finding { - kind, - severity, - description, - details: FindingDetails::Generic(details), - correction, - } -} - -fn warning( - kind: WarningKind, - severity: Severity, - description: String, - details: serde_json::Value, -) -> Warning { - Warning { - kind, - severity, - description, - details: WarningDetails::Generic(details), - } -} - -fn participant_json(participant: &TransactionParticipant) -> serde_json::Value { - json!({ - "address": participant.address, - "amount_btc": round_btc(participant.value_btc), - "source_txid": participant.funding_txid, - }) -} - -fn round_btc(value: f64) -> f64 { - (value * 100_000_000.0).round() / 100_000_000.0 -} - -fn is_round_amount(sats: u64) -> bool { - sats > 0 && (sats % 100_000 == 0 || sats % 1_000_000 == 0) -} diff --git a/crates/stealth-core/src/engine.rs b/crates/stealth-core/src/engine.rs deleted file mode 100644 index 0b39c9b..0000000 --- a/crates/stealth-core/src/engine.rs +++ /dev/null @@ -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, - pub known_risky_wallets: Vec, -} - -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), - 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 { - 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, 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) - } -} diff --git a/crates/stealth-core/src/error.rs b/crates/stealth-core/src/error.rs deleted file mode 100644 index 2129d8a..0000000 --- a/crates/stealth-core/src/error.rs +++ /dev/null @@ -1,13 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error, Clone, PartialEq, Eq)] -pub enum AnalysisError { - #[error("descriptor input cannot be empty")] - EmptyDescriptor, - #[error("descriptor `{descriptor}` failed normalization: {message}")] - DescriptorNormalization { descriptor: String, message: String }, - #[error("environment unavailable: {0}")] - EnvironmentUnavailable(String), - #[error("analysis found no history for the supplied descriptors")] - AnalysisEmpty, -} diff --git a/crates/stealth-core/src/gateway.rs b/crates/stealth-core/src/gateway.rs deleted file mode 100644 index 6cecbed..0000000 --- a/crates/stealth-core/src/gateway.rs +++ /dev/null @@ -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; - fn derive_addresses( - &self, - descriptor: &ResolvedDescriptor, - ) -> Result, AnalysisError>; - fn scan_descriptors( - &self, - descriptors: &[ResolvedDescriptor], - ) -> Result; - fn list_wallet_descriptors( - &self, - wallet_name: &str, - ) -> Result, AnalysisError>; - fn scan_wallet(&self, wallet_name: &str) -> Result; - fn known_wallet_txids(&self, wallet_names: &[String]) - -> Result, AnalysisError>; -} - -impl DescriptorNormalizer for T -where - T: BlockchainGateway + ?Sized, -{ - fn normalize(&self, descriptor: &str) -> Result { - self.normalize_descriptor(descriptor) - } -} diff --git a/crates/stealth-core/src/graph.rs b/crates/stealth-core/src/graph.rs deleted file mode 100644 index 3424484..0000000 --- a/crates/stealth-core/src/graph.rs +++ /dev/null @@ -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, - our_addrs: HashSet, - history: WalletHistory, - addr_txs: HashMap>, - tx_addrs: HashMap>, - our_txids: HashSet, -} - -impl TxGraph { - pub fn new(addresses: Vec, history: WalletHistory) -> Self { - let mut address_map = HashMap::new(); - let mut our_addrs = HashSet::new(); - let mut addr_txs: HashMap> = HashMap::new(); - let mut tx_addrs: HashMap> = 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 { - 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> { - 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 { - 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 { - 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 { - 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 -} diff --git a/crates/stealth-core/src/lib.rs b/crates/stealth-core/src/lib.rs deleted file mode 100644 index cd8253b..0000000 --- a/crates/stealth-core/src/lib.rs +++ /dev/null @@ -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 { - 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); - } -} diff --git a/crates/stealth-core/src/model.rs b/crates/stealth-core/src/model.rs deleted file mode 100644 index 51a4759..0000000 --- a/crates/stealth-core/src/model.rs +++ /dev/null @@ -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, -} - -#[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, - pub warnings: Vec, - pub summary: AnalysisSummary, -} - -impl AnalysisReport { - pub fn new( - transactions_analyzed: usize, - addresses_derived: usize, - findings: Vec, - warnings: Vec, - ) -> 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, - pub vout: Vec, - 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, - pub utxos: Vec, - pub transactions: std::collections::HashMap, -} - -#[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, - pub funding_vout: Option, -} diff --git a/crates/stealth-core/tests/detectors.rs b/crates/stealth-core/tests/detectors.rs deleted file mode 100644 index 6cde82e..0000000 --- a/crates/stealth-core/tests/detectors.rs +++ /dev/null @@ -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, - vout: Vec, - 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, - wallet_txs: Vec, - utxos: Vec, - transactions: Vec, -) -> TxGraph { - let history = WalletHistory { - wallet_txs, - utxos, - transactions: transactions - .into_iter() - .map(|item| (item.txid.clone(), item)) - .collect::>(), - }; - TxGraph::new(addresses, history) -} - -fn context<'a>( - graph: &'a TxGraph, - config: &'a AnalysisConfig, - known_exchange_txids: &'a HashSet, - known_risky_txids: &'a HashSet, -) -> 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); -} diff --git a/crates/stealth-core/tests/engine.rs b/crates/stealth-core/tests/engine.rs deleted file mode 100644 index dcf5c19..0000000 --- a/crates/stealth-core/tests/engine.rs +++ /dev/null @@ -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, - derived: HashMap>, - descriptor_history: Option, - wallet_descriptors: HashMap>, - wallet_history: HashMap, - known_wallet_txids: HashMap>, -} - -impl BlockchainGateway for MockGateway { - fn normalize_descriptor(&self, descriptor: &str) -> Result { - 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, AnalysisError> { - self.derived.get(&descriptor.desc).cloned().ok_or_else(|| { - AnalysisError::EnvironmentUnavailable("missing derivation fixture".into()) - }) - } - - fn scan_descriptors( - &self, - _descriptors: &[ResolvedDescriptor], - ) -> Result { - self.descriptor_history - .clone() - .ok_or(AnalysisError::AnalysisEmpty) - } - - fn list_wallet_descriptors( - &self, - wallet_name: &str, - ) -> Result, 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 { - self.wallet_history - .get(wallet_name) - .cloned() - .ok_or(AnalysisError::AnalysisEmpty) - } - - fn known_wallet_txids( - &self, - wallet_names: &[String], - ) -> Result, 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); -}