feat: add rust analysis workspace

This commit is contained in:
Breno Brito
2026-03-17 14:53:17 -03:00
parent 5205133638
commit 9792b231ba
21 changed files with 5422 additions and 0 deletions
+1
View File
@@ -12,3 +12,4 @@ dist/
.pnpm-store
.qwen
**/__pycache__/
target/
Generated
+1735
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"] }
+18
View File
@@ -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"] }
+39
View File
@@ -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(())
}
+189
View File
@@ -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));
}
}
+48
View File
@@ -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(())
}
+14
View File
@@ -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"
+558
View File
@@ -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);
}
}
+10
View File
@@ -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
+74
View File
@@ -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,
]),
}
}
}
+66
View File
@@ -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
+122
View File
@@ -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)
}
}
+13
View File
@@ -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,
}
+33
View File
@@ -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)
}
}
+151
View File
@@ -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
}
+80
View File
@@ -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);
}
}
+267
View File
@@ -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>,
}
+689
View File
@@ -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);
}
+253
View File
@@ -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);
}