mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-09 14:11:52 -07:00
feat: add rust analysis workspace
This commit is contained in:
@@ -12,3 +12,4 @@ dist/
|
||||
.pnpm-store
|
||||
.qwen
|
||||
**/__pycache__/
|
||||
target/
|
||||
|
||||
Generated
+1735
File diff suppressed because it is too large
Load Diff
+20
@@ -0,0 +1,20 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/stealth-app",
|
||||
"crates/stealth-bitcoincore",
|
||||
"crates/stealth-core",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
axum = "0.8"
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
@@ -0,0 +1,18 @@
|
||||
[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"] }
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use stealth_app::{build_router, build_runtime_service, default_bitcoin_config_path};
|
||||
use stealth_core::engine::EngineSettings;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ApiCli {
|
||||
#[arg(long, default_value = "0.0.0.0")]
|
||||
host: String,
|
||||
#[arg(long, default_value_t = 8080)]
|
||||
port: u16,
|
||||
#[arg(long, default_value = "http://localhost:5173")]
|
||||
cors_origin: String,
|
||||
#[arg(long, default_value_os_t = default_bitcoin_config_path())]
|
||||
config: PathBuf,
|
||||
#[arg(long = "known-risky-wallet")]
|
||||
known_risky_wallets: Vec<String>,
|
||||
#[arg(long = "known-exchange-wallet")]
|
||||
known_exchange_wallets: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = ApiCli::parse();
|
||||
let settings = EngineSettings {
|
||||
known_exchange_wallets: cli.known_exchange_wallets,
|
||||
known_risky_wallets: cli.known_risky_wallets,
|
||||
..EngineSettings::default()
|
||||
};
|
||||
let service = build_runtime_service(&cli.config, settings)?;
|
||||
let router = build_router(Arc::new(service), Some(&cli.cors_origin));
|
||||
let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderValue, Method, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Json, Router, routing::get};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
|
||||
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
|
||||
use stealth_core::error::AnalysisError;
|
||||
use stealth_core::gateway::BlockchainGateway;
|
||||
use stealth_core::model::AnalysisReport;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
pub trait ScanService: Send + Sync + 'static {
|
||||
fn analyze_descriptor(&self, descriptor: String) -> Result<AnalysisReport, AnalysisError>;
|
||||
}
|
||||
|
||||
pub struct CoreScanService<G> {
|
||||
gateway: G,
|
||||
settings: EngineSettings,
|
||||
}
|
||||
|
||||
impl<G> CoreScanService<G> {
|
||||
pub fn new(gateway: G, settings: EngineSettings) -> Self {
|
||||
Self { gateway, settings }
|
||||
}
|
||||
}
|
||||
|
||||
impl<G> ScanService for CoreScanService<G>
|
||||
where
|
||||
G: BlockchainGateway + Send + Sync + 'static,
|
||||
{
|
||||
fn analyze_descriptor(&self, descriptor: String) -> Result<AnalysisReport, AnalysisError> {
|
||||
AnalysisEngine::new(&self.gateway, self.settings.clone())
|
||||
.analyze(ScanTarget::Descriptors(vec![descriptor]))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_bitcoin_config_path() -> PathBuf {
|
||||
PathBuf::from("backend/script/config.ini")
|
||||
}
|
||||
|
||||
pub fn build_runtime_service(
|
||||
config_path: &Path,
|
||||
settings: EngineSettings,
|
||||
) -> Result<CoreScanService<BitcoinCoreRpc>, AnalysisError> {
|
||||
let config = BitcoinCoreConfig::from_ini_file(config_path)?;
|
||||
let gateway = BitcoinCoreRpc::new(config)?;
|
||||
Ok(CoreScanService::new(gateway, settings))
|
||||
}
|
||||
|
||||
pub fn build_router<S>(service: Arc<S>, cors_origin: Option<&str>) -> Router
|
||||
where
|
||||
S: ScanService,
|
||||
{
|
||||
let mut router = Router::new()
|
||||
.route("/api/wallet/scan", get(scan_handler::<S>))
|
||||
.with_state(service);
|
||||
|
||||
if let Some(origin) = cors_origin {
|
||||
if let Ok(header_value) = HeaderValue::from_str(origin) {
|
||||
router = router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(header_value)
|
||||
.allow_methods([Method::GET])
|
||||
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::ACCEPT]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScanQuery {
|
||||
descriptor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
async fn scan_handler<S>(State(service): State<Arc<S>>, Query(query): Query<ScanQuery>) -> Response
|
||||
where
|
||||
S: ScanService,
|
||||
{
|
||||
let Some(descriptor) = query.descriptor.map(|value| value.trim().to_string()) else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"descriptor query parameter is required".into(),
|
||||
);
|
||||
};
|
||||
if descriptor.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"descriptor query parameter is required".into(),
|
||||
);
|
||||
}
|
||||
|
||||
match service.analyze_descriptor(descriptor) {
|
||||
Ok(report) => Json(report).into_response(),
|
||||
Err(error) => map_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_error(error: AnalysisError) -> Response {
|
||||
match error {
|
||||
AnalysisError::EmptyDescriptor | AnalysisError::DescriptorNormalization { .. } => {
|
||||
json_error(StatusCode::BAD_REQUEST, error.to_string())
|
||||
}
|
||||
AnalysisError::AnalysisEmpty => json_error(StatusCode::NOT_FOUND, error.to_string()),
|
||||
AnalysisError::EnvironmentUnavailable(_) => {
|
||||
json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, message: String) -> Response {
|
||||
(status, Json(ErrorBody { error: message })).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::json;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct MockService(Result<AnalysisReport, AnalysisError>);
|
||||
|
||||
impl ScanService for MockService {
|
||||
fn analyze_descriptor(&self, _descriptor: String) -> Result<AnalysisReport, AnalysisError> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_400_for_missing_descriptor() {
|
||||
let app = build_router(
|
||||
Arc::new(MockService(Ok(AnalysisReport::new(
|
||||
0,
|
||||
0,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)))),
|
||||
None,
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/wallet/scan")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_json_report_for_successful_scan() {
|
||||
let report = AnalysisReport::new(1, 2, Vec::new(), Vec::new());
|
||||
let app = build_router(Arc::new(MockService(Ok(report))), None);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/wallet/scan?descriptor=wpkh(test)")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json = serde_json::from_slice::<serde_json::Value>(&body).unwrap();
|
||||
assert_eq!(json["summary"]["clean"], json!(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use stealth_app::default_bitcoin_config_path;
|
||||
use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc};
|
||||
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {
|
||||
#[arg(long = "descriptor", short = 'd')]
|
||||
descriptors: Vec<String>,
|
||||
#[arg(long)]
|
||||
wallet: Option<String>,
|
||||
#[arg(long, default_value_os_t = default_bitcoin_config_path())]
|
||||
config: PathBuf,
|
||||
#[arg(long = "known-risky-wallet")]
|
||||
known_risky_wallets: Vec<String>,
|
||||
#[arg(long = "known-exchange-wallet")]
|
||||
known_exchange_wallets: Vec<String>,
|
||||
#[arg(long)]
|
||||
pretty: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
let settings = EngineSettings {
|
||||
known_exchange_wallets: cli.known_exchange_wallets,
|
||||
known_risky_wallets: cli.known_risky_wallets,
|
||||
..EngineSettings::default()
|
||||
};
|
||||
let config = BitcoinCoreConfig::from_ini_file(&cli.config)?;
|
||||
let gateway = BitcoinCoreRpc::new(config)?;
|
||||
let engine = AnalysisEngine::new(&gateway, settings);
|
||||
|
||||
let report = if let Some(wallet_name) = cli.wallet {
|
||||
engine.analyze(ScanTarget::WalletName(wallet_name))?
|
||||
} else {
|
||||
engine.analyze(ScanTarget::Descriptors(cli.descriptors))?
|
||||
};
|
||||
|
||||
if cli.pretty {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
println!("{}", serde_json::to_string(&report)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[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"
|
||||
@@ -0,0 +1,558 @@
|
||||
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<PathBuf>,
|
||||
pub rpchost: String,
|
||||
pub rpcport: u16,
|
||||
pub rpcuser: Option<String>,
|
||||
pub rpcpassword: Option<String>,
|
||||
}
|
||||
|
||||
impl BitcoinCoreConfig {
|
||||
pub fn from_ini_file(path: impl AsRef<Path>) -> Result<Self, AnalysisError> {
|
||||
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::<u16>().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<Self, AnalysisError> {
|
||||
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<T: DeserializeOwned>(
|
||||
&self,
|
||||
wallet: Option<&str>,
|
||||
method: &str,
|
||||
params: Vec<Value>,
|
||||
) -> Result<T, AnalysisError> {
|
||||
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::<JsonRpcEnvelope<T>>()
|
||||
.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<WalletHistory, AnalysisError> {
|
||||
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::<HashSet<_>>();
|
||||
txids.extend(utxos.iter().map(|utxo| utxo.txid.clone()));
|
||||
|
||||
let mut transactions = HashMap::new();
|
||||
let mut queue = txids.into_iter().collect::<Vec<_>>();
|
||||
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<Vec<WalletTxEntry>, AnalysisError> {
|
||||
let entries = self.call::<Vec<ListTransactionEntry>>(
|
||||
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<Vec<Utxo>, AnalysisError> {
|
||||
let utxos = self.call::<Vec<ListUnspentEntry>>(
|
||||
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<DecodedTransaction, AnalysisError> {
|
||||
let tx =
|
||||
self.call::<RawTransaction>(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::<Value>(
|
||||
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::<Value>(None, "unloadwallet", vec![json!(wallet_name)]);
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockchainGateway for BitcoinCoreRpc {
|
||||
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError> {
|
||||
let response =
|
||||
self.call::<DescriptorInfo>(None, "getdescriptorinfo", vec![json!(descriptor)])?;
|
||||
Ok(response.descriptor)
|
||||
}
|
||||
|
||||
fn derive_addresses(
|
||||
&self,
|
||||
descriptor: &ResolvedDescriptor,
|
||||
) -> Result<Vec<String>, AnalysisError> {
|
||||
self.call(
|
||||
None,
|
||||
"deriveaddresses",
|
||||
vec![json!(descriptor.desc), json!([0, descriptor.range_end])],
|
||||
)
|
||||
}
|
||||
|
||||
fn scan_descriptors(
|
||||
&self,
|
||||
descriptors: &[ResolvedDescriptor],
|
||||
) -> Result<WalletHistory, AnalysisError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
let import_results = self.call::<Vec<ImportResult>>(
|
||||
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<Vec<ResolvedDescriptor>, AnalysisError> {
|
||||
let response =
|
||||
self.call::<ListDescriptorsResponse>(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<WalletHistory, AnalysisError> {
|
||||
self.load_history_for_wallet(wallet_name)
|
||||
}
|
||||
|
||||
fn known_wallet_txids(
|
||||
&self,
|
||||
wallet_names: &[String],
|
||||
) -> Result<HashSet<String>, 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<T> {
|
||||
result: Option<T>,
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
#[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<DescriptorRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DescriptorRecord {
|
||||
desc: String,
|
||||
internal: Option<bool>,
|
||||
active: Option<bool>,
|
||||
range: Option<DescriptorRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum DescriptorRange {
|
||||
Single(u32),
|
||||
Pair([u32; 2]),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListTransactionEntry {
|
||||
txid: String,
|
||||
address: Option<String>,
|
||||
category: Option<String>,
|
||||
amount: f64,
|
||||
confirmations: Option<u32>,
|
||||
blockheight: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListUnspentEntry {
|
||||
txid: String,
|
||||
vout: u32,
|
||||
address: Option<String>,
|
||||
amount: f64,
|
||||
confirmations: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawTransaction {
|
||||
txid: String,
|
||||
vin: Vec<RawVin>,
|
||||
vout: Vec<RawVout>,
|
||||
version: Option<i32>,
|
||||
locktime: Option<u32>,
|
||||
vsize: Option<u32>,
|
||||
confirmations: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVin {
|
||||
txid: Option<String>,
|
||||
vout: Option<u32>,
|
||||
coinbase: Option<String>,
|
||||
sequence: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVout {
|
||||
value: f64,
|
||||
n: u32,
|
||||
#[serde(rename = "scriptPubKey")]
|
||||
script_pub_key: RawScriptPubKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawScriptPubKey {
|
||||
address: Option<String>,
|
||||
addresses: Option<Vec<String>>,
|
||||
#[serde(rename = "type")]
|
||||
script_type: Option<String>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "stealth-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,74 @@
|
||||
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<DetectorId>,
|
||||
}
|
||||
|
||||
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,
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::error::AnalysisError;
|
||||
use crate::model::ResolvedDescriptor;
|
||||
|
||||
pub trait DescriptorNormalizer {
|
||||
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError>;
|
||||
}
|
||||
|
||||
pub fn normalize_descriptors(
|
||||
raw_descriptors: &[String],
|
||||
derivation_range_end: u32,
|
||||
normalizer: &dyn DescriptorNormalizer,
|
||||
) -> Result<Vec<ResolvedDescriptor>, 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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
use crate::config::AnalysisConfig;
|
||||
use crate::descriptor::normalize_descriptors;
|
||||
use crate::detectors::{DetectorContext, run_all};
|
||||
use crate::error::AnalysisError;
|
||||
use crate::gateway::BlockchainGateway;
|
||||
use crate::graph::TxGraph;
|
||||
use crate::model::{
|
||||
AnalysisReport, DerivedAddress, DescriptorChainRole, DescriptorType, ResolvedDescriptor,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EngineSettings {
|
||||
pub analysis: AnalysisConfig,
|
||||
pub known_exchange_wallets: Vec<String>,
|
||||
pub known_risky_wallets: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for EngineSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
analysis: AnalysisConfig::default(),
|
||||
known_exchange_wallets: Vec::new(),
|
||||
known_risky_wallets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScanTarget {
|
||||
Descriptors(Vec<String>),
|
||||
WalletName(String),
|
||||
}
|
||||
|
||||
pub struct AnalysisEngine<'a, G> {
|
||||
gateway: &'a G,
|
||||
settings: EngineSettings,
|
||||
}
|
||||
|
||||
impl<'a, G> AnalysisEngine<'a, G>
|
||||
where
|
||||
G: BlockchainGateway,
|
||||
{
|
||||
pub fn new(gateway: &'a G, settings: EngineSettings) -> Self {
|
||||
Self { gateway, settings }
|
||||
}
|
||||
|
||||
pub fn analyze(&self, target: ScanTarget) -> Result<AnalysisReport, AnalysisError> {
|
||||
let (descriptors, history) = match target {
|
||||
ScanTarget::Descriptors(raw_descriptors) => {
|
||||
if raw_descriptors.is_empty() {
|
||||
return Err(AnalysisError::EmptyDescriptor);
|
||||
}
|
||||
let descriptors = normalize_descriptors(
|
||||
&raw_descriptors,
|
||||
self.settings.analysis.derivation_range_end,
|
||||
self.gateway,
|
||||
)?;
|
||||
let history = self.gateway.scan_descriptors(&descriptors)?;
|
||||
(descriptors, history)
|
||||
}
|
||||
ScanTarget::WalletName(wallet_name) => {
|
||||
let descriptors = self.gateway.list_wallet_descriptors(&wallet_name)?;
|
||||
let history = self.gateway.scan_wallet(&wallet_name)?;
|
||||
(descriptors, history)
|
||||
}
|
||||
};
|
||||
|
||||
if history.wallet_txs.is_empty() {
|
||||
return Err(AnalysisError::AnalysisEmpty);
|
||||
}
|
||||
|
||||
let derived_addresses = self.derive_all_addresses(&descriptors)?;
|
||||
let graph = TxGraph::new(derived_addresses.clone(), history);
|
||||
let known_exchange_txids = self
|
||||
.gateway
|
||||
.known_wallet_txids(&self.settings.known_exchange_wallets)?;
|
||||
let known_risky_txids = self
|
||||
.gateway
|
||||
.known_wallet_txids(&self.settings.known_risky_wallets)?;
|
||||
|
||||
let detector_result = run_all(&DetectorContext {
|
||||
graph: &graph,
|
||||
config: &self.settings.analysis,
|
||||
known_exchange_txids: &known_exchange_txids,
|
||||
known_risky_txids: &known_risky_txids,
|
||||
});
|
||||
|
||||
Ok(AnalysisReport::new(
|
||||
graph.our_txids().count(),
|
||||
derived_addresses.len(),
|
||||
detector_result.findings,
|
||||
detector_result.warnings,
|
||||
))
|
||||
}
|
||||
|
||||
fn derive_all_addresses(
|
||||
&self,
|
||||
descriptors: &[ResolvedDescriptor],
|
||||
) -> Result<Vec<DerivedAddress>, AnalysisError> {
|
||||
let mut addresses = Vec::new();
|
||||
|
||||
for descriptor in descriptors {
|
||||
let descriptor_type = DescriptorType::from_descriptor(&descriptor.desc);
|
||||
let chain_role = if descriptor.internal {
|
||||
DescriptorChainRole::Internal
|
||||
} else {
|
||||
DescriptorChainRole::External
|
||||
};
|
||||
let derived = self.gateway.derive_addresses(descriptor)?;
|
||||
addresses.extend(derived.into_iter().enumerate().map(|(index, address)| {
|
||||
DerivedAddress {
|
||||
address,
|
||||
descriptor_type,
|
||||
chain_role,
|
||||
derivation_index: index as u32,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(addresses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::descriptor::DescriptorNormalizer;
|
||||
use crate::error::AnalysisError;
|
||||
use crate::model::{ResolvedDescriptor, WalletHistory};
|
||||
|
||||
pub trait BlockchainGateway {
|
||||
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError>;
|
||||
fn derive_addresses(
|
||||
&self,
|
||||
descriptor: &ResolvedDescriptor,
|
||||
) -> Result<Vec<String>, AnalysisError>;
|
||||
fn scan_descriptors(
|
||||
&self,
|
||||
descriptors: &[ResolvedDescriptor],
|
||||
) -> Result<WalletHistory, AnalysisError>;
|
||||
fn list_wallet_descriptors(
|
||||
&self,
|
||||
wallet_name: &str,
|
||||
) -> Result<Vec<ResolvedDescriptor>, AnalysisError>;
|
||||
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError>;
|
||||
fn known_wallet_txids(&self, wallet_names: &[String])
|
||||
-> Result<HashSet<String>, AnalysisError>;
|
||||
}
|
||||
|
||||
impl<T> DescriptorNormalizer for T
|
||||
where
|
||||
T: BlockchainGateway + ?Sized,
|
||||
{
|
||||
fn normalize(&self, descriptor: &str) -> Result<String, AnalysisError> {
|
||||
self.normalize_descriptor(descriptor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::model::{
|
||||
DecodedTransaction, DerivedAddress, DescriptorChainRole, DescriptorType,
|
||||
TransactionParticipant, TxOutput, Utxo, WalletHistory, WalletTxEntry,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TxGraph {
|
||||
addresses: HashMap<String, DerivedAddress>,
|
||||
our_addrs: HashSet<String>,
|
||||
history: WalletHistory,
|
||||
addr_txs: HashMap<String, Vec<WalletTxEntry>>,
|
||||
tx_addrs: HashMap<String, HashSet<String>>,
|
||||
our_txids: HashSet<String>,
|
||||
}
|
||||
|
||||
impl TxGraph {
|
||||
pub fn new(addresses: Vec<DerivedAddress>, history: WalletHistory) -> Self {
|
||||
let mut address_map = HashMap::new();
|
||||
let mut our_addrs = HashSet::new();
|
||||
let mut addr_txs: HashMap<String, Vec<WalletTxEntry>> = HashMap::new();
|
||||
let mut tx_addrs: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
let mut our_txids = HashSet::new();
|
||||
|
||||
for address in addresses {
|
||||
our_addrs.insert(address.address.clone());
|
||||
address_map.insert(address.address.clone(), address);
|
||||
}
|
||||
|
||||
for entry in &history.wallet_txs {
|
||||
our_txids.insert(entry.txid.clone());
|
||||
if !entry.address.is_empty() {
|
||||
addr_txs
|
||||
.entry(entry.address.clone())
|
||||
.or_default()
|
||||
.push(entry.clone());
|
||||
tx_addrs
|
||||
.entry(entry.txid.clone())
|
||||
.or_default()
|
||||
.insert(entry.address.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
addresses: address_map,
|
||||
our_addrs,
|
||||
history,
|
||||
addr_txs,
|
||||
tx_addrs,
|
||||
our_txids,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn addresses(&self) -> impl Iterator<Item = &DerivedAddress> {
|
||||
self.addresses.values()
|
||||
}
|
||||
|
||||
pub fn derived_address(&self, address: &str) -> Option<&DerivedAddress> {
|
||||
self.addresses.get(address)
|
||||
}
|
||||
|
||||
pub fn wallet_entries(&self, address: &str) -> &[WalletTxEntry] {
|
||||
self.addr_txs.get(address).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn tx_addrs(&self, txid: &str) -> Option<&HashSet<String>> {
|
||||
self.tx_addrs.get(txid)
|
||||
}
|
||||
|
||||
pub fn tx(&self, txid: &str) -> Option<&DecodedTransaction> {
|
||||
self.history.transactions.get(txid)
|
||||
}
|
||||
|
||||
pub fn our_txids(&self) -> impl Iterator<Item = &String> {
|
||||
self.our_txids.iter()
|
||||
}
|
||||
|
||||
pub fn utxos(&self) -> &[Utxo] {
|
||||
&self.history.utxos
|
||||
}
|
||||
|
||||
pub fn is_ours(&self, address: &str) -> bool {
|
||||
self.our_addrs.contains(address)
|
||||
}
|
||||
|
||||
pub fn get_script_type(&self, address: &str) -> DescriptorType {
|
||||
self.derived_address(address)
|
||||
.map(|item| item.descriptor_type)
|
||||
.unwrap_or_else(|| DescriptorType::infer_from_address(address))
|
||||
}
|
||||
|
||||
pub fn output_by_outpoint(&self, txid: &str, vout: u32) -> Option<&TxOutput> {
|
||||
self.tx(txid)?.vout.iter().find(|output| output.n == vout)
|
||||
}
|
||||
|
||||
pub fn input_participants(&self, txid: &str) -> Vec<TransactionParticipant> {
|
||||
let Some(tx) = self.tx(txid) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
tx.vin
|
||||
.iter()
|
||||
.filter(|input| !input.coinbase)
|
||||
.filter_map(|input| {
|
||||
let previous_output =
|
||||
self.output_by_outpoint(&input.previous_txid, input.previous_vout)?;
|
||||
let script_type = self.get_script_type(&previous_output.address);
|
||||
Some(TransactionParticipant {
|
||||
address: previous_output.address.clone(),
|
||||
value_btc: previous_output.value_btc,
|
||||
value_sats: btc_to_sats(previous_output.value_btc),
|
||||
script_type,
|
||||
is_ours: self.is_ours(&previous_output.address),
|
||||
funding_txid: Some(input.previous_txid.clone()),
|
||||
funding_vout: Some(input.previous_vout),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn output_participants(&self, txid: &str) -> Vec<TransactionParticipant> {
|
||||
let Some(tx) = self.tx(txid) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
tx.vout
|
||||
.iter()
|
||||
.map(|output| TransactionParticipant {
|
||||
address: output.address.clone(),
|
||||
value_btc: output.value_btc,
|
||||
value_sats: btc_to_sats(output.value_btc),
|
||||
script_type: self.get_script_type(&output.address),
|
||||
is_ours: self.is_ours(&output.address),
|
||||
funding_txid: Some(txid.to_string()),
|
||||
funding_vout: Some(output.n),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn address_role(&self, address: &str) -> &'static str {
|
||||
match self.derived_address(address).map(|item| item.chain_role) {
|
||||
Some(DescriptorChainRole::Internal) => "change",
|
||||
_ => "receive",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn btc_to_sats(value_btc: f64) -> u64 {
|
||||
(value_btc * 100_000_000.0).round() as u64
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
pub mod config;
|
||||
pub mod descriptor;
|
||||
pub mod detectors;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod gateway;
|
||||
pub mod graph;
|
||||
pub mod model;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::descriptor::{DescriptorNormalizer, normalize_descriptors};
|
||||
use crate::model::{
|
||||
AnalysisReport, Finding, FindingDetails, FindingKind, Severity, Warning, WarningDetails,
|
||||
WarningKind,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalizes_checksums_and_infers_change_descriptor_pair() {
|
||||
struct RecordingNormalizer;
|
||||
|
||||
impl DescriptorNormalizer for RecordingNormalizer {
|
||||
fn normalize(&self, descriptor: &str) -> Result<String, crate::error::AnalysisError> {
|
||||
Ok(format!("normalized:{descriptor}"))
|
||||
}
|
||||
}
|
||||
|
||||
let normalized = normalize_descriptors(
|
||||
&[String::from("wpkh([abcd/84h/1h/0h]tpub123/0/*)#checksum")],
|
||||
777,
|
||||
&RecordingNormalizer,
|
||||
)
|
||||
.expect("descriptor normalization should succeed");
|
||||
|
||||
assert_eq!(normalized.len(), 2);
|
||||
assert_eq!(
|
||||
normalized[0].desc,
|
||||
"normalized:wpkh([abcd/84h/1h/0h]tpub123/0/*)"
|
||||
);
|
||||
assert!(!normalized[0].internal);
|
||||
assert_eq!(
|
||||
normalized[1].desc,
|
||||
"normalized:wpkh([abcd/84h/1h/0h]tpub123/1/*)"
|
||||
);
|
||||
assert!(normalized[1].internal);
|
||||
assert_eq!(normalized[1].range_end, 777);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_tracks_clean_state_and_counts() {
|
||||
let finding = Finding {
|
||||
kind: FindingKind::AddressReuse,
|
||||
severity: Severity::High,
|
||||
description: "address reused".into(),
|
||||
details: FindingDetails::Generic(json!({"address":"bcrt1qexample"})),
|
||||
correction: Some("use a fresh address".into()),
|
||||
};
|
||||
let warning = Warning {
|
||||
kind: WarningKind::DormantUtxos,
|
||||
severity: Severity::Low,
|
||||
description: "dormant coins".into(),
|
||||
details: WarningDetails::Generic(json!({"count":1})),
|
||||
};
|
||||
|
||||
let report = AnalysisReport::new(12, 34, vec![finding], vec![warning]);
|
||||
|
||||
assert_eq!(report.summary.findings, 1);
|
||||
assert_eq!(report.summary.warnings, 1);
|
||||
assert!(!report.summary.clean);
|
||||
assert_eq!(report.stats.transactions_analyzed, 12);
|
||||
assert_eq!(report.stats.addresses_derived, 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_report_is_marked_clean() {
|
||||
let report = AnalysisReport::new(0, 0, Vec::new(), Vec::new());
|
||||
assert!(report.summary.clean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Severity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum FindingKind {
|
||||
AddressReuse,
|
||||
Cioh,
|
||||
Dust,
|
||||
DustSpending,
|
||||
ChangeDetection,
|
||||
Consolidation,
|
||||
ScriptTypeMixing,
|
||||
ClusterMerge,
|
||||
UtxoAgeSpread,
|
||||
ExchangeOrigin,
|
||||
TaintedUtxoMerge,
|
||||
BehavioralFingerprint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum WarningKind {
|
||||
DormantUtxos,
|
||||
DirectTaint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum FindingDetails {
|
||||
Generic(Value),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum WarningDetails {
|
||||
Generic(Value),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Finding {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: FindingKind,
|
||||
pub severity: Severity,
|
||||
pub description: String,
|
||||
pub details: FindingDetails,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub correction: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Warning {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: WarningKind,
|
||||
pub severity: Severity,
|
||||
pub description: String,
|
||||
pub details: WarningDetails,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AnalysisStats {
|
||||
pub transactions_analyzed: usize,
|
||||
pub addresses_derived: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AnalysisSummary {
|
||||
pub findings: usize,
|
||||
pub warnings: usize,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnalysisReport {
|
||||
pub stats: AnalysisStats,
|
||||
pub findings: Vec<Finding>,
|
||||
pub warnings: Vec<Warning>,
|
||||
pub summary: AnalysisSummary,
|
||||
}
|
||||
|
||||
impl AnalysisReport {
|
||||
pub fn new(
|
||||
transactions_analyzed: usize,
|
||||
addresses_derived: usize,
|
||||
findings: Vec<Finding>,
|
||||
warnings: Vec<Warning>,
|
||||
) -> Self {
|
||||
let summary = AnalysisSummary {
|
||||
findings: findings.len(),
|
||||
warnings: warnings.len(),
|
||||
clean: findings.is_empty() && warnings.is_empty(),
|
||||
};
|
||||
|
||||
Self {
|
||||
stats: AnalysisStats {
|
||||
transactions_analyzed,
|
||||
addresses_derived,
|
||||
},
|
||||
findings,
|
||||
warnings,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DescriptorChainRole {
|
||||
External,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DescriptorType {
|
||||
P2wpkh,
|
||||
P2tr,
|
||||
P2shP2wpkh,
|
||||
P2pkh,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DescriptorType {
|
||||
pub fn from_descriptor(descriptor: &str) -> Self {
|
||||
if descriptor.starts_with("wpkh(") {
|
||||
Self::P2wpkh
|
||||
} else if descriptor.starts_with("tr(") {
|
||||
Self::P2tr
|
||||
} else if descriptor.starts_with("sh(wpkh(") {
|
||||
Self::P2shP2wpkh
|
||||
} else if descriptor.starts_with("pkh(") {
|
||||
Self::P2pkh
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
pub fn infer_from_address(address: &str) -> Self {
|
||||
if address.starts_with("bc1q")
|
||||
|| address.starts_with("tb1q")
|
||||
|| address.starts_with("bcrt1q")
|
||||
{
|
||||
Self::P2wpkh
|
||||
} else if address.starts_with("bc1p")
|
||||
|| address.starts_with("tb1p")
|
||||
|| address.starts_with("bcrt1p")
|
||||
{
|
||||
Self::P2tr
|
||||
} else if address.starts_with('2') || address.starts_with('3') {
|
||||
Self::P2shP2wpkh
|
||||
} else if address.starts_with('1') || address.starts_with('m') || address.starts_with('n') {
|
||||
Self::P2pkh
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_script_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P2wpkh => "witness_v0_keyhash",
|
||||
Self::P2tr => "witness_v1_taproot",
|
||||
Self::P2shP2wpkh => "scripthash",
|
||||
Self::P2pkh => "pubkeyhash",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DerivedAddress {
|
||||
pub address: String,
|
||||
pub descriptor_type: DescriptorType,
|
||||
pub chain_role: DescriptorChainRole,
|
||||
pub derivation_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedDescriptor {
|
||||
pub desc: String,
|
||||
pub internal: bool,
|
||||
pub active: bool,
|
||||
pub range_end: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WalletTxCategory {
|
||||
Send,
|
||||
Receive,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WalletTxEntry {
|
||||
pub txid: String,
|
||||
pub address: String,
|
||||
pub category: WalletTxCategory,
|
||||
pub amount_btc: f64,
|
||||
pub confirmations: u32,
|
||||
pub blockheight: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TxInputRef {
|
||||
#[serde(rename = "txid")]
|
||||
pub previous_txid: String,
|
||||
#[serde(rename = "vout")]
|
||||
pub previous_vout: u32,
|
||||
pub sequence: u32,
|
||||
pub coinbase: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TxOutput {
|
||||
pub n: u32,
|
||||
pub address: String,
|
||||
pub value_btc: f64,
|
||||
pub script_type: DescriptorType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DecodedTransaction {
|
||||
pub txid: String,
|
||||
pub vin: Vec<TxInputRef>,
|
||||
pub vout: Vec<TxOutput>,
|
||||
pub version: i32,
|
||||
pub locktime: u32,
|
||||
pub vsize: u32,
|
||||
pub confirmations: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Utxo {
|
||||
pub txid: String,
|
||||
pub vout: u32,
|
||||
pub address: String,
|
||||
pub amount_btc: f64,
|
||||
pub confirmations: u32,
|
||||
pub script_type: DescriptorType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WalletHistory {
|
||||
pub wallet_txs: Vec<WalletTxEntry>,
|
||||
pub utxos: Vec<Utxo>,
|
||||
pub transactions: std::collections::HashMap<String, DecodedTransaction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TransactionParticipant {
|
||||
pub address: String,
|
||||
pub value_btc: f64,
|
||||
pub value_sats: u64,
|
||||
pub script_type: DescriptorType,
|
||||
pub is_ours: bool,
|
||||
pub funding_txid: Option<String>,
|
||||
pub funding_vout: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use stealth_core::config::AnalysisConfig;
|
||||
use stealth_core::detectors::{
|
||||
DetectorContext, detect_address_reuse, detect_behavioral_fingerprint, detect_change_detection,
|
||||
detect_cioh, detect_cluster_merge, detect_consolidation, detect_dust, detect_dust_spending,
|
||||
detect_exchange_origin, detect_script_type_mixing, detect_tainted_utxo_merge,
|
||||
detect_utxo_age_spread,
|
||||
};
|
||||
use stealth_core::graph::TxGraph;
|
||||
use stealth_core::model::{
|
||||
DecodedTransaction, DerivedAddress, DescriptorChainRole, DescriptorType, FindingKind,
|
||||
TxInputRef, TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry, WarningKind,
|
||||
};
|
||||
|
||||
fn satoshis(value: u64) -> f64 {
|
||||
value as f64 / 100_000_000.0
|
||||
}
|
||||
|
||||
fn our_address(
|
||||
address: &str,
|
||||
descriptor_type: DescriptorType,
|
||||
chain_role: DescriptorChainRole,
|
||||
) -> DerivedAddress {
|
||||
DerivedAddress {
|
||||
address: address.to_string(),
|
||||
descriptor_type,
|
||||
chain_role,
|
||||
derivation_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_entry(
|
||||
txid: &str,
|
||||
address: &str,
|
||||
category: WalletTxCategory,
|
||||
sats: u64,
|
||||
confirmations: u32,
|
||||
) -> WalletTxEntry {
|
||||
WalletTxEntry {
|
||||
txid: txid.to_string(),
|
||||
address: address.to_string(),
|
||||
category,
|
||||
amount_btc: satoshis(sats),
|
||||
confirmations,
|
||||
blockheight: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn tx(
|
||||
txid: &str,
|
||||
vin: Vec<TxInputRef>,
|
||||
vout: Vec<TxOutput>,
|
||||
confirmations: u32,
|
||||
) -> DecodedTransaction {
|
||||
DecodedTransaction {
|
||||
txid: txid.to_string(),
|
||||
vin,
|
||||
vout,
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
vsize: 200,
|
||||
confirmations,
|
||||
}
|
||||
}
|
||||
|
||||
fn input(previous_txid: &str, previous_vout: u32) -> TxInputRef {
|
||||
TxInputRef {
|
||||
previous_txid: previous_txid.to_string(),
|
||||
previous_vout,
|
||||
sequence: 0xffff_fffd,
|
||||
coinbase: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn output(n: u32, address: &str, sats: u64, script_type: DescriptorType) -> TxOutput {
|
||||
TxOutput {
|
||||
n,
|
||||
address: address.to_string(),
|
||||
value_btc: satoshis(sats),
|
||||
script_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn utxo(
|
||||
txid: &str,
|
||||
vout: u32,
|
||||
address: &str,
|
||||
sats: u64,
|
||||
confirmations: u32,
|
||||
script_type: DescriptorType,
|
||||
) -> Utxo {
|
||||
Utxo {
|
||||
txid: txid.to_string(),
|
||||
vout,
|
||||
address: address.to_string(),
|
||||
amount_btc: satoshis(sats),
|
||||
confirmations,
|
||||
script_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn graph(
|
||||
addresses: Vec<DerivedAddress>,
|
||||
wallet_txs: Vec<WalletTxEntry>,
|
||||
utxos: Vec<Utxo>,
|
||||
transactions: Vec<DecodedTransaction>,
|
||||
) -> TxGraph {
|
||||
let history = WalletHistory {
|
||||
wallet_txs,
|
||||
utxos,
|
||||
transactions: transactions
|
||||
.into_iter()
|
||||
.map(|item| (item.txid.clone(), item))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
};
|
||||
TxGraph::new(addresses, history)
|
||||
}
|
||||
|
||||
fn context<'a>(
|
||||
graph: &'a TxGraph,
|
||||
config: &'a AnalysisConfig,
|
||||
known_exchange_txids: &'a HashSet<String>,
|
||||
known_risky_txids: &'a HashSet<String>,
|
||||
) -> DetectorContext<'a> {
|
||||
DetectorContext {
|
||||
graph,
|
||||
config,
|
||||
known_exchange_txids,
|
||||
known_risky_txids,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_reuse_is_detected() {
|
||||
let receive = our_address(
|
||||
"bcrt1qreceive",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let graph = graph(
|
||||
vec![receive.clone()],
|
||||
vec![
|
||||
wallet_entry(
|
||||
"reuse-1",
|
||||
&receive.address,
|
||||
WalletTxCategory::Receive,
|
||||
1_000_000,
|
||||
6,
|
||||
),
|
||||
wallet_entry(
|
||||
"reuse-2",
|
||||
&receive.address,
|
||||
WalletTxCategory::Receive,
|
||||
2_000_000,
|
||||
5,
|
||||
),
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
tx(
|
||||
"reuse-1",
|
||||
Vec::new(),
|
||||
vec![output(
|
||||
0,
|
||||
&receive.address,
|
||||
1_000_000,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
6,
|
||||
),
|
||||
tx(
|
||||
"reuse-2",
|
||||
Vec::new(),
|
||||
vec![output(
|
||||
0,
|
||||
&receive.address,
|
||||
2_000_000,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
5,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let config = AnalysisConfig::default();
|
||||
let known_exchange = HashSet::new();
|
||||
let known_risky = HashSet::new();
|
||||
let findings =
|
||||
detect_address_reuse(&context(&graph, &config, &known_exchange, &known_risky)).findings;
|
||||
assert_eq!(findings.len(), 1);
|
||||
assert_eq!(findings[0].kind, FindingKind::AddressReuse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dust_current_and_historical_are_detected() {
|
||||
let strict = our_address(
|
||||
"bcrt1qdust",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let spent = our_address(
|
||||
"bcrt1qspent",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let graph = graph(
|
||||
vec![strict.clone(), spent.clone()],
|
||||
vec![
|
||||
wallet_entry(
|
||||
"dust-live",
|
||||
&strict.address,
|
||||
WalletTxCategory::Receive,
|
||||
546,
|
||||
3,
|
||||
),
|
||||
wallet_entry(
|
||||
"dust-spent",
|
||||
&spent.address,
|
||||
WalletTxCategory::Receive,
|
||||
1_000,
|
||||
2,
|
||||
),
|
||||
],
|
||||
vec![utxo(
|
||||
"dust-live",
|
||||
0,
|
||||
&strict.address,
|
||||
546,
|
||||
3,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
vec![
|
||||
tx(
|
||||
"dust-live",
|
||||
Vec::new(),
|
||||
vec![output(0, &strict.address, 546, DescriptorType::P2wpkh)],
|
||||
3,
|
||||
),
|
||||
tx(
|
||||
"dust-spent",
|
||||
Vec::new(),
|
||||
vec![output(0, &spent.address, 1_000, DescriptorType::P2wpkh)],
|
||||
2,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let config = AnalysisConfig::default();
|
||||
let known_exchange = HashSet::new();
|
||||
let known_risky = HashSet::new();
|
||||
let findings = detect_dust(&context(&graph, &config, &known_exchange, &known_risky)).findings;
|
||||
assert_eq!(findings.len(), 2);
|
||||
assert!(
|
||||
findings
|
||||
.iter()
|
||||
.any(|finding| finding.kind == FindingKind::Dust)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_input_heuristics_are_detected() {
|
||||
let a = our_address(
|
||||
"bcrt1qin1",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let b = our_address(
|
||||
"bcrt1qin2",
|
||||
DescriptorType::P2tr,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let change = our_address(
|
||||
"bcrt1qchange",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::Internal,
|
||||
);
|
||||
let transactions = vec![
|
||||
tx(
|
||||
"fund-a",
|
||||
vec![input("bob-parent", 0)],
|
||||
vec![output(0, &a.address, 50_000, DescriptorType::P2wpkh)],
|
||||
10,
|
||||
),
|
||||
tx(
|
||||
"fund-b",
|
||||
vec![input("carol-parent", 0)],
|
||||
vec![output(0, &b.address, 60_000, DescriptorType::P2tr)],
|
||||
10,
|
||||
),
|
||||
tx(
|
||||
"spend",
|
||||
vec![input("fund-a", 0), input("fund-b", 0)],
|
||||
vec![
|
||||
output(0, "mipcPayment", 1_000_000, DescriptorType::P2pkh),
|
||||
output(1, &change.address, 10_345, DescriptorType::P2wpkh),
|
||||
],
|
||||
2,
|
||||
),
|
||||
];
|
||||
let graph = graph(
|
||||
vec![a.clone(), b.clone(), change.clone()],
|
||||
vec![
|
||||
wallet_entry("fund-a", &a.address, WalletTxCategory::Receive, 50_000, 10),
|
||||
wallet_entry("fund-b", &b.address, WalletTxCategory::Receive, 60_000, 10),
|
||||
wallet_entry("spend", &change.address, WalletTxCategory::Send, 10_345, 2),
|
||||
],
|
||||
vec![utxo(
|
||||
"spend",
|
||||
1,
|
||||
&change.address,
|
||||
10_345,
|
||||
2,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
transactions,
|
||||
);
|
||||
|
||||
let config = AnalysisConfig::default();
|
||||
let known_exchange = HashSet::new();
|
||||
let known_risky = HashSet::new();
|
||||
let ctx = context(&graph, &config, &known_exchange, &known_risky);
|
||||
assert_eq!(detect_cioh(&ctx).findings[0].kind, FindingKind::Cioh);
|
||||
assert_eq!(
|
||||
detect_change_detection(&ctx).findings[0].kind,
|
||||
FindingKind::ChangeDetection
|
||||
);
|
||||
assert_eq!(
|
||||
detect_script_type_mixing(&ctx).findings[0].kind,
|
||||
FindingKind::ScriptTypeMixing
|
||||
);
|
||||
assert_eq!(
|
||||
detect_cluster_merge(&ctx).findings[0].kind,
|
||||
FindingKind::ClusterMerge
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidation_and_dust_spending_are_detected() {
|
||||
let dust_addr = our_address(
|
||||
"bcrt1qdustin",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let normal_addr = our_address(
|
||||
"bcrt1qnormal",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let consolidated = our_address(
|
||||
"bcrt1qconsolidated",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let transactions = vec![
|
||||
tx(
|
||||
"dust-fund",
|
||||
vec![input("miner-a", 0)],
|
||||
vec![output(0, &dust_addr.address, 1_000, DescriptorType::P2wpkh)],
|
||||
20,
|
||||
),
|
||||
tx(
|
||||
"normal-fund",
|
||||
vec![input("miner-b", 0)],
|
||||
vec![output(
|
||||
0,
|
||||
&normal_addr.address,
|
||||
25_000,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
20,
|
||||
),
|
||||
tx(
|
||||
"consolidation-parent",
|
||||
vec![input("src-1", 0), input("src-2", 0), input("src-3", 0)],
|
||||
vec![output(
|
||||
0,
|
||||
&consolidated.address,
|
||||
26_000,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
5,
|
||||
),
|
||||
tx(
|
||||
"spend-dust",
|
||||
vec![input("dust-fund", 0), input("normal-fund", 0)],
|
||||
vec![output(0, "mipcRecipient", 20_000, DescriptorType::P2pkh)],
|
||||
2,
|
||||
),
|
||||
];
|
||||
let graph = graph(
|
||||
vec![dust_addr.clone(), normal_addr.clone(), consolidated.clone()],
|
||||
vec![
|
||||
wallet_entry(
|
||||
"dust-fund",
|
||||
&dust_addr.address,
|
||||
WalletTxCategory::Receive,
|
||||
1_000,
|
||||
20,
|
||||
),
|
||||
wallet_entry(
|
||||
"normal-fund",
|
||||
&normal_addr.address,
|
||||
WalletTxCategory::Receive,
|
||||
25_000,
|
||||
20,
|
||||
),
|
||||
wallet_entry(
|
||||
"consolidation-parent",
|
||||
&consolidated.address,
|
||||
WalletTxCategory::Receive,
|
||||
26_000,
|
||||
5,
|
||||
),
|
||||
wallet_entry(
|
||||
"spend-dust",
|
||||
"mipcRecipient",
|
||||
WalletTxCategory::Send,
|
||||
20_000,
|
||||
2,
|
||||
),
|
||||
],
|
||||
vec![utxo(
|
||||
"consolidation-parent",
|
||||
0,
|
||||
&consolidated.address,
|
||||
26_000,
|
||||
5,
|
||||
DescriptorType::P2wpkh,
|
||||
)],
|
||||
transactions,
|
||||
);
|
||||
|
||||
let config = AnalysisConfig::default();
|
||||
let known_exchange = HashSet::new();
|
||||
let known_risky = HashSet::new();
|
||||
let ctx = context(&graph, &config, &known_exchange, &known_risky);
|
||||
assert_eq!(
|
||||
detect_dust_spending(&ctx).findings[0].kind,
|
||||
FindingKind::DustSpending
|
||||
);
|
||||
assert_eq!(
|
||||
detect_consolidation(&ctx).findings[0].kind,
|
||||
FindingKind::Consolidation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_spread_emits_finding_and_warning() {
|
||||
let old = our_address(
|
||||
"bcrt1qold",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let fresh = our_address(
|
||||
"bcrt1qfresh",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let graph = graph(
|
||||
vec![old.clone(), fresh.clone()],
|
||||
Vec::new(),
|
||||
vec![
|
||||
utxo(
|
||||
"old-utxo",
|
||||
0,
|
||||
&old.address,
|
||||
300_000,
|
||||
120,
|
||||
DescriptorType::P2wpkh,
|
||||
),
|
||||
utxo(
|
||||
"fresh-utxo",
|
||||
0,
|
||||
&fresh.address,
|
||||
310_000,
|
||||
5,
|
||||
DescriptorType::P2wpkh,
|
||||
),
|
||||
],
|
||||
vec![
|
||||
tx(
|
||||
"old-utxo",
|
||||
Vec::new(),
|
||||
vec![output(0, &old.address, 300_000, DescriptorType::P2wpkh)],
|
||||
120,
|
||||
),
|
||||
tx(
|
||||
"fresh-utxo",
|
||||
Vec::new(),
|
||||
vec![output(0, &fresh.address, 310_000, DescriptorType::P2wpkh)],
|
||||
5,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let config = AnalysisConfig::default();
|
||||
let known_exchange = HashSet::new();
|
||||
let known_risky = HashSet::new();
|
||||
let result = detect_utxo_age_spread(&context(&graph, &config, &known_exchange, &known_risky));
|
||||
assert_eq!(result.findings[0].kind, FindingKind::UtxoAgeSpread);
|
||||
assert_eq!(result.warnings[0].kind, WarningKind::DormantUtxos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exchange_origin_and_tainted_merge_are_detected() {
|
||||
let receive = our_address(
|
||||
"bcrt1qexchange",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let clean = our_address(
|
||||
"bcrt1qclean",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let tainted = our_address(
|
||||
"bcrt1qtainted",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let transactions = vec![
|
||||
tx(
|
||||
"exchange-batch",
|
||||
vec![input("exchange-hot", 0)],
|
||||
vec![
|
||||
output(0, &receive.address, 200_000, DescriptorType::P2wpkh),
|
||||
output(1, "bcrt1qsomeone1", 190_000, DescriptorType::P2wpkh),
|
||||
output(2, "bcrt1qsomeone2", 180_000, DescriptorType::P2wpkh),
|
||||
output(3, "bcrt1qsomeone3", 170_000, DescriptorType::P2wpkh),
|
||||
output(4, "bcrt1qsomeone4", 160_000, DescriptorType::P2wpkh),
|
||||
],
|
||||
4,
|
||||
),
|
||||
tx(
|
||||
"risky-source",
|
||||
vec![input("risky-parent", 0)],
|
||||
vec![output(0, &tainted.address, 80_000, DescriptorType::P2wpkh)],
|
||||
8,
|
||||
),
|
||||
tx(
|
||||
"clean-source",
|
||||
vec![input("clean-parent", 0)],
|
||||
vec![output(0, &clean.address, 90_000, DescriptorType::P2wpkh)],
|
||||
8,
|
||||
),
|
||||
tx(
|
||||
"merge-taint",
|
||||
vec![input("risky-source", 0), input("clean-source", 0)],
|
||||
vec![output(0, "mipcOut", 150_000, DescriptorType::P2pkh)],
|
||||
1,
|
||||
),
|
||||
];
|
||||
let graph = graph(
|
||||
vec![receive.clone(), clean.clone(), tainted.clone()],
|
||||
vec![
|
||||
wallet_entry(
|
||||
"exchange-batch",
|
||||
&receive.address,
|
||||
WalletTxCategory::Receive,
|
||||
200_000,
|
||||
4,
|
||||
),
|
||||
wallet_entry(
|
||||
"risky-source",
|
||||
&tainted.address,
|
||||
WalletTxCategory::Receive,
|
||||
80_000,
|
||||
8,
|
||||
),
|
||||
wallet_entry(
|
||||
"clean-source",
|
||||
&clean.address,
|
||||
WalletTxCategory::Receive,
|
||||
90_000,
|
||||
8,
|
||||
),
|
||||
wallet_entry("merge-taint", "mipcOut", WalletTxCategory::Send, 150_000, 1),
|
||||
],
|
||||
Vec::new(),
|
||||
transactions,
|
||||
);
|
||||
|
||||
let known_exchange_txids = HashSet::from([String::from("exchange-batch")]);
|
||||
let known_risky_txids = HashSet::from([String::from("risky-source")]);
|
||||
let config = AnalysisConfig::default();
|
||||
let ctx = context(&graph, &config, &known_exchange_txids, &known_risky_txids);
|
||||
|
||||
assert_eq!(
|
||||
detect_exchange_origin(&ctx).findings[0].kind,
|
||||
FindingKind::ExchangeOrigin
|
||||
);
|
||||
|
||||
let taint_result = detect_tainted_utxo_merge(&ctx);
|
||||
assert_eq!(taint_result.findings[0].kind, FindingKind::TaintedUtxoMerge);
|
||||
assert_eq!(taint_result.warnings[0].kind, WarningKind::DirectTaint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behavioral_fingerprint_requires_consistent_patterns() {
|
||||
let in1 = our_address(
|
||||
"bcrt1qbeh1",
|
||||
DescriptorType::P2pkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let in2 = our_address(
|
||||
"bcrt1qbeh2",
|
||||
DescriptorType::P2pkh,
|
||||
DescriptorChainRole::External,
|
||||
);
|
||||
let change = our_address(
|
||||
"bcrt1qbehchange",
|
||||
DescriptorType::P2wpkh,
|
||||
DescriptorChainRole::Internal,
|
||||
);
|
||||
let transactions = vec![
|
||||
tx(
|
||||
"fund-1",
|
||||
vec![input("source-1", 0)],
|
||||
vec![output(0, &in1.address, 400_000, DescriptorType::P2pkh)],
|
||||
20,
|
||||
),
|
||||
tx(
|
||||
"fund-2",
|
||||
vec![input("source-2", 0)],
|
||||
vec![output(0, &in2.address, 400_000, DescriptorType::P2pkh)],
|
||||
20,
|
||||
),
|
||||
tx(
|
||||
"send-1",
|
||||
vec![input("fund-1", 0), input("fund-2", 0)],
|
||||
vec![
|
||||
output(0, "mipcDest1", 100_000, DescriptorType::P2pkh),
|
||||
output(1, &change.address, 20_000, DescriptorType::P2wpkh),
|
||||
],
|
||||
3,
|
||||
),
|
||||
tx(
|
||||
"send-2",
|
||||
vec![input("fund-1", 0), input("fund-2", 0)],
|
||||
vec![
|
||||
output(0, "mipcDest2", 200_000, DescriptorType::P2pkh),
|
||||
output(1, &change.address, 30_000, DescriptorType::P2wpkh),
|
||||
],
|
||||
2,
|
||||
),
|
||||
tx(
|
||||
"send-3",
|
||||
vec![input("fund-1", 0), input("fund-2", 0)],
|
||||
vec![
|
||||
output(0, "mipcDest3", 300_000, DescriptorType::P2pkh),
|
||||
output(1, &change.address, 40_000, DescriptorType::P2wpkh),
|
||||
],
|
||||
1,
|
||||
),
|
||||
];
|
||||
let graph = graph(
|
||||
vec![in1.clone(), in2.clone(), change.clone()],
|
||||
vec![
|
||||
wallet_entry(
|
||||
"fund-1",
|
||||
&in1.address,
|
||||
WalletTxCategory::Receive,
|
||||
400_000,
|
||||
20,
|
||||
),
|
||||
wallet_entry(
|
||||
"fund-2",
|
||||
&in2.address,
|
||||
WalletTxCategory::Receive,
|
||||
400_000,
|
||||
20,
|
||||
),
|
||||
wallet_entry("send-1", &change.address, WalletTxCategory::Send, 20_000, 3),
|
||||
wallet_entry("send-2", &change.address, WalletTxCategory::Send, 30_000, 2),
|
||||
wallet_entry("send-3", &change.address, WalletTxCategory::Send, 40_000, 1),
|
||||
],
|
||||
Vec::new(),
|
||||
transactions,
|
||||
);
|
||||
|
||||
let config = AnalysisConfig::default();
|
||||
let known_exchange = HashSet::new();
|
||||
let known_risky = HashSet::new();
|
||||
let findings =
|
||||
detect_behavioral_fingerprint(&context(&graph, &config, &known_exchange, &known_risky))
|
||||
.findings;
|
||||
assert_eq!(findings[0].kind, FindingKind::BehavioralFingerprint);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use stealth_core::config::AnalysisConfig;
|
||||
use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget};
|
||||
use stealth_core::error::AnalysisError;
|
||||
use stealth_core::gateway::BlockchainGateway;
|
||||
use stealth_core::model::{
|
||||
DecodedTransaction, DescriptorType, ResolvedDescriptor, TxOutput, WalletHistory,
|
||||
WalletTxCategory, WalletTxEntry,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockGateway {
|
||||
normalized: HashMap<String, String>,
|
||||
derived: HashMap<String, Vec<String>>,
|
||||
descriptor_history: Option<WalletHistory>,
|
||||
wallet_descriptors: HashMap<String, Vec<ResolvedDescriptor>>,
|
||||
wallet_history: HashMap<String, WalletHistory>,
|
||||
known_wallet_txids: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
|
||||
impl BlockchainGateway for MockGateway {
|
||||
fn normalize_descriptor(&self, descriptor: &str) -> Result<String, AnalysisError> {
|
||||
self.normalized.get(descriptor).cloned().ok_or_else(|| {
|
||||
AnalysisError::DescriptorNormalization {
|
||||
descriptor: descriptor.to_string(),
|
||||
message: "missing normalization fixture".into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_addresses(
|
||||
&self,
|
||||
descriptor: &ResolvedDescriptor,
|
||||
) -> Result<Vec<String>, AnalysisError> {
|
||||
self.derived.get(&descriptor.desc).cloned().ok_or_else(|| {
|
||||
AnalysisError::EnvironmentUnavailable("missing derivation fixture".into())
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_descriptors(
|
||||
&self,
|
||||
_descriptors: &[ResolvedDescriptor],
|
||||
) -> Result<WalletHistory, AnalysisError> {
|
||||
self.descriptor_history
|
||||
.clone()
|
||||
.ok_or(AnalysisError::AnalysisEmpty)
|
||||
}
|
||||
|
||||
fn list_wallet_descriptors(
|
||||
&self,
|
||||
wallet_name: &str,
|
||||
) -> Result<Vec<ResolvedDescriptor>, AnalysisError> {
|
||||
self.wallet_descriptors
|
||||
.get(wallet_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| AnalysisError::EnvironmentUnavailable("wallet not found".into()))
|
||||
}
|
||||
|
||||
fn scan_wallet(&self, wallet_name: &str) -> Result<WalletHistory, AnalysisError> {
|
||||
self.wallet_history
|
||||
.get(wallet_name)
|
||||
.cloned()
|
||||
.ok_or(AnalysisError::AnalysisEmpty)
|
||||
}
|
||||
|
||||
fn known_wallet_txids(
|
||||
&self,
|
||||
wallet_names: &[String],
|
||||
) -> Result<HashSet<String>, AnalysisError> {
|
||||
Ok(wallet_names
|
||||
.iter()
|
||||
.filter_map(|wallet_name| self.known_wallet_txids.get(wallet_name))
|
||||
.flat_map(|txids| txids.iter().cloned())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn satoshis(value: u64) -> f64 {
|
||||
value as f64 / 100_000_000.0
|
||||
}
|
||||
|
||||
fn descriptor(desc: &str, internal: bool) -> ResolvedDescriptor {
|
||||
ResolvedDescriptor {
|
||||
desc: desc.to_string(),
|
||||
internal,
|
||||
active: true,
|
||||
range_end: 50,
|
||||
}
|
||||
}
|
||||
|
||||
fn history_for_address_reuse(address: &str) -> WalletHistory {
|
||||
WalletHistory {
|
||||
wallet_txs: vec![
|
||||
WalletTxEntry {
|
||||
txid: "tx-1".into(),
|
||||
address: address.into(),
|
||||
category: WalletTxCategory::Receive,
|
||||
amount_btc: satoshis(100_000),
|
||||
confirmations: 6,
|
||||
blockheight: 0,
|
||||
},
|
||||
WalletTxEntry {
|
||||
txid: "tx-2".into(),
|
||||
address: address.into(),
|
||||
category: WalletTxCategory::Receive,
|
||||
amount_btc: satoshis(200_000),
|
||||
confirmations: 5,
|
||||
blockheight: 0,
|
||||
},
|
||||
],
|
||||
utxos: Vec::new(),
|
||||
transactions: HashMap::from([
|
||||
(
|
||||
"tx-1".into(),
|
||||
DecodedTransaction {
|
||||
txid: "tx-1".into(),
|
||||
vin: Vec::new(),
|
||||
vout: vec![TxOutput {
|
||||
n: 0,
|
||||
address: address.into(),
|
||||
value_btc: satoshis(100_000),
|
||||
script_type: DescriptorType::P2wpkh,
|
||||
}],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
vsize: 100,
|
||||
confirmations: 6,
|
||||
},
|
||||
),
|
||||
(
|
||||
"tx-2".into(),
|
||||
DecodedTransaction {
|
||||
txid: "tx-2".into(),
|
||||
vin: Vec::new(),
|
||||
vout: vec![TxOutput {
|
||||
n: 0,
|
||||
address: address.into(),
|
||||
value_btc: satoshis(200_000),
|
||||
script_type: DescriptorType::P2wpkh,
|
||||
}],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
vsize: 100,
|
||||
confirmations: 5,
|
||||
},
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_scan_normalizes_derives_and_reports_findings() {
|
||||
let normalized_external = "normalized:wpkh(xpub/0/*)";
|
||||
let normalized_internal = "normalized:wpkh(xpub/1/*)";
|
||||
let address = "bcrt1qengine";
|
||||
let gateway = MockGateway {
|
||||
normalized: HashMap::from([
|
||||
("wpkh(xpub/0/*)".into(), normalized_external.into()),
|
||||
("wpkh(xpub/1/*)".into(), normalized_internal.into()),
|
||||
]),
|
||||
derived: HashMap::from([
|
||||
(normalized_external.into(), vec![address.into()]),
|
||||
(normalized_internal.into(), vec!["bcrt1qchange".into()]),
|
||||
]),
|
||||
descriptor_history: Some(history_for_address_reuse(address)),
|
||||
..MockGateway::default()
|
||||
};
|
||||
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
|
||||
|
||||
let report = engine
|
||||
.analyze(ScanTarget::Descriptors(vec!["wpkh(xpub/0/*)#abcd".into()]))
|
||||
.expect("analysis should succeed");
|
||||
|
||||
assert_eq!(report.summary.findings, 1);
|
||||
assert_eq!(
|
||||
report.findings[0].kind,
|
||||
stealth_core::model::FindingKind::AddressReuse
|
||||
);
|
||||
assert_eq!(report.stats.addresses_derived, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_scan_uses_existing_wallet_descriptors() {
|
||||
let address = "bcrt1qwallet";
|
||||
let wallet_name = "alice";
|
||||
let gateway = MockGateway {
|
||||
derived: HashMap::from([
|
||||
("normalized:wpkh(wallet/0/*)".into(), vec![address.into()]),
|
||||
(
|
||||
"normalized:wpkh(wallet/1/*)".into(),
|
||||
vec!["bcrt1qwalletchange".into()],
|
||||
),
|
||||
]),
|
||||
wallet_descriptors: HashMap::from([(
|
||||
wallet_name.into(),
|
||||
vec![
|
||||
descriptor("normalized:wpkh(wallet/0/*)", false),
|
||||
descriptor("normalized:wpkh(wallet/1/*)", true),
|
||||
],
|
||||
)]),
|
||||
wallet_history: HashMap::from([(wallet_name.into(), history_for_address_reuse(address))]),
|
||||
..MockGateway::default()
|
||||
};
|
||||
let engine = AnalysisEngine::new(&gateway, EngineSettings::default());
|
||||
|
||||
let report = engine
|
||||
.analyze(ScanTarget::WalletName(wallet_name.into()))
|
||||
.expect("wallet analysis should succeed");
|
||||
|
||||
assert_eq!(report.summary.findings, 1);
|
||||
assert_eq!(report.stats.transactions_analyzed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_returns_typed_error() {
|
||||
let gateway = MockGateway {
|
||||
normalized: HashMap::from([
|
||||
("wpkh(xpub/0/*)".into(), "normalized:wpkh(xpub/0/*)".into()),
|
||||
("wpkh(xpub/1/*)".into(), "normalized:wpkh(xpub/1/*)".into()),
|
||||
]),
|
||||
derived: HashMap::from([
|
||||
(
|
||||
"normalized:wpkh(xpub/0/*)".into(),
|
||||
vec!["bcrt1qnone".into()],
|
||||
),
|
||||
(
|
||||
"normalized:wpkh(xpub/1/*)".into(),
|
||||
vec!["bcrt1qnonechange".into()],
|
||||
),
|
||||
]),
|
||||
descriptor_history: Some(WalletHistory {
|
||||
wallet_txs: Vec::new(),
|
||||
utxos: Vec::new(),
|
||||
transactions: HashMap::new(),
|
||||
}),
|
||||
..MockGateway::default()
|
||||
};
|
||||
let engine = AnalysisEngine::new(
|
||||
&gateway,
|
||||
EngineSettings {
|
||||
analysis: AnalysisConfig::default(),
|
||||
known_exchange_wallets: Vec::new(),
|
||||
known_risky_wallets: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let error = engine
|
||||
.analyze(ScanTarget::Descriptors(vec!["wpkh(xpub/0/*)".into()]))
|
||||
.expect_err("analysis should fail");
|
||||
|
||||
assert_eq!(error, AnalysisError::AnalysisEmpty);
|
||||
}
|
||||
Reference in New Issue
Block a user